diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2166b57..9cd3714 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -45,7 +45,7 @@ xcodebuild -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS ## Checklist -- [ ] My code follows the project's Swift 6.2 conventions (see [CONTRIBUTING.md](CONTRIBUTING.md)) +- [ ] My code follows the project's Swift 6.2 conventions (see [CONTRIBUTING.md](../CONTRIBUTING.md)) - [ ] All UI is SwiftUI (no AppKit UI components) - [ ] I have tested on macOS 15.6+ - [ ] I have tested with both light and dark mode diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2ab02..1de6364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to RClick are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [2.2.1] - 2026-09-13 + +### Added +- Custom menu layout: arrange top-level and nested items in `custom_menu.json` +- French localization +- Simplified Chinese, Japanese and Spanish names for the built-in new-file types (Word 文档 / Excel 表格 / PowerPoint 演示文稿) +- About: live star count and a link to the issue tracker + +### Changed +- Settings: the custom menu is applied only when you press "Apply", so editing the file no longer changes the Finder menu mid-keystroke +- Settings: the About tab is rebuilt around the update status, with the description folded into the identity block +- Settings: launch-at-login now leads the General toggles +- Menu layout switches moved into the shared app group, alongside the other preferences + +### Fixed +- Adding or deleting an app could leave the Finder menu unchanged until the next heartbeat +- A failed save was silent; it now surfaces in the settings window +- Checking for updates reported "Failed to Check for Updates" even when the app was simply up to date +- A network failure during an update check was reported as "already up to date" +- The Japanese "Open With" submenu fell back to English +- AirDrop was untranslated in Simplified Chinese +- A one-time migration preserves existing menu layout settings after their move to the app group + +### Removed +- 20 localization keys that nothing referenced +- The extension's string catalog no longer carries 131 unused copies of the app's strings + ## [2.1.0] - 2026-07-12 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 8e27473..2347a1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,10 +136,13 @@ When working with files outside the sandbox container (required for the App Stor ### Localization - Default language is **English** (also the fallback) - **Simplified Chinese** (`zh-Hans`) is the primary localization target -- **Japanese** (`ja`) is supported — activated when system language is Japanese +- **Japanese** (`ja`), **Spanish** (`es`) and **French** (`fr`) are also supported, activated when the system language matches - All string keys in code use English, localized via `Localizable.xcstrings` (xcstrings format) - Language detection is fully automatic via `Bundle.main.localizedString` — no manual language picker - To add a new language: add entries to `Localizable.xcstrings` and register in Xcode project +- There are **two** catalogs — `RClick/` (app) and `FinderSyncExt/` (extension). Each target resolves against its own via `Bundle.main`, so a key the extension renders must exist in the extension's catalog too +- `scripts/check-localization.py` verifies the catalogs; run it before a release +- Edit the catalogs with exact-string text edits. Re-serializing the JSON rewrites the whole file and does not round-trip byte-for-byte ### Data Persistence - SwiftData models use `@Model` macro diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index ba3d482..f67d8c6 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -23,6 +23,8 @@ private let logger = Logger( /// 只负责菜单渲染和事件转发,不读取 SwiftData class FinderSyncExt: FIFinderSync, @unchecked Sendable { + private var volumeObserver: MountedVolumeObserver? + // MARK: - Properties /// 菜单配置缓存(内存缓存,从 Main App 推送) @@ -84,19 +86,17 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { // MARK: - Directory Observing - /// 设置监听目录(全盘监听) - /// - /// Observing "/" covers every reachable folder — /Users, /Applications, - /// /opt, /tmp, external and network volumes (mounted under /Volumes) — - /// which is what the original per-path list intended but missed for - /// anything on the system volume outside /Users. FileProvider-backed - /// locations (iCloud Drive, synced Desktop & Documents) still get no - /// FinderSync menus; that is a macOS restriction on all FinderSync - /// extensions, not something an observation URL can change. + /// Register each mounted volume explicitly; filesystem ancestry alone is not + /// a reliable observation boundary for Finder Sync across mount points. private func setupObservingDirectories() { - let directories: Set = [URL(fileURLWithPath: "/")] - FIFinderSyncController.default().directoryURLs = directories - logger.info("Observing directories: \(directories.map { $0.path })") + // Finder Sync requires an initial set during extension startup. + FIFinderSyncController.default().directoryURLs = [URL(fileURLWithPath: "/")] + Task { @MainActor [weak self] in + self?.volumeObserver = MountedVolumeObserver { directories in + FIFinderSyncController.default().directoryURLs = directories + logger.info("Observing \(directories.count) filesystem roots") + } + } } // MARK: - Message Handling @@ -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/FinderSyncExt/Localizable.xcstrings b/FinderSyncExt/Localizable.xcstrings index 54cecae..9dd030e 100644 --- a/FinderSyncExt/Localizable.xcstrings +++ b/FinderSyncExt/Localizable.xcstrings @@ -1,4143 +1,422 @@ { "sourceLanguage": "en", "strings": { - "1. Click \"Settings…\" on the right to open System Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "1. Click \"Settings…\" on the right to open System Settings" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "1. Haz clic en “Ajustes…” a la derecha para abrir Ajustes del Sistema" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1. 右側の「設定…」をクリックしてシステム設定を開きます" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "1. 点击右侧“设置…”按钮打开系统设置" - } - } - } - }, - "2. Click the lock icon and authenticate": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "2. Click the lock icon and authenticate" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "2. Haz clic en el icono del candado y autentícate" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "2. 左下の鍵アイコンをクリックして認証します" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "2. 点击左下角锁图标并认证" - } - } - } - }, - "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "3. Haz clic en “+” -> abre “Aplicaciones” -> selecciona “RClick”" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "3. 「+」を押して「アプリケーション」から「RClick」を選択します" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "3. 点击“+”并前往“应用程序”,选择“RClick”" - } - } - } - }, - "4. Make sure the switch next to RClick is turned on": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "4. Make sure the switch next to RClick is turned on" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "4. Asegúrate de que el interruptor junto a RClick esté activado" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "4. RClick の横にあるスイッチがオンになっていることを確認します" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "4. 确保 RClick 旁边的开关已打开" - } - } - } - }, - "About": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "About" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acerca de" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "情報" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "关于" - } - } - } - }, - "Accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Accessibility" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Accesibilidad" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アクセシビリティ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "辅助功能" - } - } - } - }, - "Actions": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Actions" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acciones" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "操作" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "操作" - } - } - } - }, - "Add": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加" - } - } - } - }, - "Add App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add App" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar aplicación" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを追加" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加应用" - } - } - } - }, - "Add File Type": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add File Type" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar tipo de archivo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル種類を追加" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加文件类型" - } - } - } - }, - "Add Folder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add Folder" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar carpeta" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを追加" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加文件夹" - } - } - } - }, - "Added Folders": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Added Folders" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpetas agregadas" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加済みフォルダ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已添加的文件夹" - } - } - } - }, - "AirDrop": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" - } - } - } - }, - "Applications": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Applications" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aplicaciones" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリケーション" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用程序" - } - } - } - }, - "Apps": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Apps" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Apps" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用" - } - } - } - }, - "Arguments (semicolon separated)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Arguments (semicolon separated)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Argumentos (separados por punto y coma)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "引数(セミコロン区切り)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "参数(分号分隔)" - } - } - } - }, - "Arguments: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Arguments: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Argumentos: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "引数: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "参数:%@" - } - } - } - }, - "Authorized": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authorized" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Autorizado" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "許可済み" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已授权" - } - } - } - }, - "Automatic": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Automatic" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Automático" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "自動" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "自动" - } - } - } - }, - "Backup": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Backup" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Respaldo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バックアップ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "备份" - } - } - } - }, - "Cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cancelar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "取消" - } - } - } - }, - "Change App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Change App" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cambiar aplicación" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを変更" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更换应用" - } - } - } - }, - "Change Template": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Change Template" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cambiar plantilla" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テンプレートを変更" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更换模板" - } - } - } - }, - "Check for Updates": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Check for Updates" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Buscar actualizaciones" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートを確認" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "检查更新" - } - } - } - }, - "Checking for updates...": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Checking for updates..." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Buscando actualizaciones..." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートを確認中..." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在检查更新..." - } - } - } - }, - "Choose Default App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose Default App" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Elegir aplicación predeterminada" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "既定アプリを選択" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "选择默认打开应用" - } - } - } - }, - "Choose Template File": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose Template File" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Elegir archivo de plantilla" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テンプレートファイルを選択" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "选择模板文件" - } - } - } - }, - "Collapse actions menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse actions menu" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú de acciones" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "操作メニューを折りたたむ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠动作菜单" - } - } - } - }, - "Collapse apps menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse apps menu" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú de aplicaciones" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリメニューを折りたたむ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠应用菜单" - } - } - } - }, - "Collapse menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse menu" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メニューを折りたたむ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠菜单" - } - } - } - }, - "Collapse new file menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse new file menu" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú de nuevo archivo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新規ファイルメニューを折りたたむ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠新建文件菜单" - } - } - } - }, - "Common Dir": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Common Folders" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpetas frecuentes" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "よく使うフォルダ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "常用文件夹" - } - } - } - }, - "Common Dirs": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Common Folders" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpetas frecuentes" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "よく使うフォルダ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "常用文件夹" - } - } - } - }, - "Copy Path": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy Path" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Copiar ruta" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "パスをコピー" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "复制路径" - } - } - } - }, - "Default Open App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Default Open App" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aplicación predeterminada" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "既定で開くアプリ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "默认打开应用" - } - } - } - }, - "Delete App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete App" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminar aplicación" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを削除" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除应用" - } - } - } - }, - "Delete Direct": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete Direct" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminar directamente" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "直接削除" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "直接删除" - } - } - } - }, - "Desktop": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Desktop" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Escritorio" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デスクトップ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "桌面" - } - } - } - }, - "Display Name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Display Name" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nombre visible" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "表示名" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "显示名称" - } - } - } - }, - "Documents": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Documents" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Documentos" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "書類" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文档" - } - } - } - }, - "Don't Ask Again": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Don't Ask Again" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No preguntar de nuevo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今後表示しない" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "不再询问" - } - } - } - }, - "Download and Install": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download and Install" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descargar e instalar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロードしてインストール" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下载并安装" - } - } - } - }, - "Download Manually": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download Manually" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descargar manualmente" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "手動でダウンロード" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "手动下载" - } - } - } - }, - "Downloads": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloads" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descargas" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロード" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下载" - } - } - } - }, - "Edit App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit App" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar aplicación" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを編集" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑应用" - } - } - } - }, - "Edit App Properties": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit App Properties" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar propiedades de la aplicación" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリ設定を編集" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑应用属性" - } - } - } - }, - "Edit File Type": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit File Type" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar tipo de archivo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル種類を編集" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑文件类型" - } - } - } - }, - "Enable common folders": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable common folders" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activar carpetas frecuentes" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "よく使うフォルダを有効化" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用常用文件夹" - } - } - } - }, - "Enable Full Disk Access": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable Full Disk Access" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activar Acceso total al disco" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フルディスクアクセスを有効にする" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用完全磁盘访问权限" - } - } - } - }, - "Enable RClick": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable RClick" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activar RClick" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick を有効にする" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用 RClick" - } - } - } - }, - "Enable RClick in File Provider to show its actions in Finder context menus": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable RClick in File Provider to show its actions in Finder context menus" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activa RClick en Proveedor de archivos para que sus acciones aparezcan en los menús contextuales del Finder" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイルプロバイダで RClick を有効にすると Finder の右クリックメニューに表示されます" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在文件提供程序中启用 RClick 以在 Finder 右键菜单中显示功能" - } - } - } - }, - "Enabled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enabled" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activado" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "有効" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用" - } - } - } - }, - "English": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "English" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Inglés" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "English" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "English" - } - } - } - }, - "Enter an SF Symbol name, for example doc.text or curlybraces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter an SF Symbol name, for example doc.text or curlybraces" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Escribe el nombre de un SF Symbol, por ejemplo doc.text o curlybraces" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol 名を入力してください。例: doc.text、curlybraces" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "输入 SF Symbol 名称,例如 doc.text、curlybraces" - } - } - } - }, - "Environment Variables": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Environment Variables" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Variables de entorno" - } - }, - "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" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Variables de entorno: %lld" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "環境変数: %lld 個" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "环境变量:%lld 个" - } - } - } - }, - "Export Logs…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Export Logs…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Exportar registros…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログを書き出し…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "导出日志…" - } - } - } - }, - "Export…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Export…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Exportar…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "書き出し…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "导出…" - } - } - } - }, - "Extension: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Extension: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Extensión: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "拡張子: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "扩展名:%@" - } - } - } - }, - "Extraction failed: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Extraction failed: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error al descomprimir: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "展開に失敗しました: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "解压失败:%@" - } - } - } - }, - "Failed to Check for Updates": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to Check for Updates" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudieron buscar actualizaciones" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートの確認に失敗しました" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "检查更新失败" - } - } - } - }, - "File Extension": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "File Extension" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Extensión de archivo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル拡張子" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件扩展名" - } - } - } - }, - "File Provider: Select \"RClick\" in the list to enable the Finder context menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "File Provider: Select \"RClick\" in the list to enable the Finder context menu" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Proveedor de archivos: selecciona “RClick” en la lista para activar el menú contextual del Finder" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイルプロバイダ一覧で「RClick」を選択すると Finder の右クリックメニューを有効にできます" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件提供程序:在列表中选择“RClick”以启用 Finder 右键菜单" - } - } - } - }, - "Finder Extension": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Finder Extension" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Extensión del Finder" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Finder 拡張" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Finder 扩展" - } - } - } - }, - "Folder access permission is required to use this feature.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Folder access permission is required to use this feature." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se necesita permiso de acceso a la carpeta para usar esta función." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この機能を使うにはフォルダへのアクセス許可が必要です。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "必须授予文件夹访问权限才能使用此功能。" - } - } - } - }, - "Folders cannot be shared via AirDrop: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Folders cannot be shared via AirDrop: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Las carpetas no se pueden compartir por AirDrop: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダは AirDrop で共有できません: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "不能通过 AirDrop 分享文件夹:%@" - } - } - } - }, - "For example: txt, md, json": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "For example: txt, md, json" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Por ejemplo: txt, md, json" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "例: txt、md、json" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "例如:txt、md、json" - } - } - } - }, - "Format: KEY=VALUE, one per line": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Format: KEY=VALUE, one per line" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Formato: CLAVE=VALOR, uno por línea" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "形式: KEY=VALUE を1行ずつ入力" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "格式:KEY=VALUE,每行一个" - } - } - } - }, - "Full Disk Access": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Full Disk Access" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acceso total al disco" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フルディスクアクセス" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "完全磁盘访问权限" - } - } - } - }, - "Full Disk Access: Required to create and delete files in protected directories": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Full Disk Access: Required to create and delete files in protected directories" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acceso total al disco: se necesita para crear y eliminar archivos en directorios protegidos" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フルディスクアクセスは保護されたディレクトリでのファイル作成と削除に必要です" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "完全磁盘访问权限:用于在受保护目录中创建和删除文件" - } - } - } - }, - "General": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "General" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "General" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "一般" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "通用" - } - } - } - }, - "Grant Permission": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Grant Permission" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Otorgar permiso" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "権限を付与" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授予权限" - } - } - } - }, - "Hide": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Hide" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ocultar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "隠す" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "隐藏" - } - } - } - }, - "Home": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Home" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpeta de inicio" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ホーム" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用户主目录" - } - } - } - }, - "Icon": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Icon" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Icono" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アイコン" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "图标" - } - } - } - }, - "Ignore This Version": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Ignore This Version" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ignorar esta versión" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このバージョンを無視" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "忽略此版本" - } - } - } - }, - "Import…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Import…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Importar…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "読み込み…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "导入…" - } - } - } - }, - "Installation failed: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Installation failed: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error al instalar: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "インストールに失敗しました: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "安装失败:%@" - } - } - } - }, - "Invalid Folder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Invalid Folder" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpeta no válida" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "無効なフォルダ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无效文件夹" - } - } - } - }, - "Japanese": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Japanese" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Japonés" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "日本語" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "日本語" - } - } - } - }, - "Language": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Language" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Idioma" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "言語" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "语言" - } - } - } - }, - "Later": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Later" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Más tarde" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "あとで" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "稍后" - } - } - } - }, - "Launch at login": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Launch at login" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir al iniciar sesión" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログイン時に起動" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "登录时启动" - } - } - } - }, - "Logs": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Logs" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Registros" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "日志" - } - } - } - }, - "Main Controls": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Main Controls" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Controles principales" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メイン設定" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "主要控制" - } - } - } - }, - "Name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Name" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nombre" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名称" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "名称" - } - } - } - }, - "New File": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New File" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nuevo archivo" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新規ファイル" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新建文件" - } - } - } - }, - "New Version Available": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Version Available" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nueva versión disponible" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいバージョンがあります" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "发现新版本" - } - } - } - }, - "No .app application was found in the ZIP file.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No .app application was found in the ZIP file." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se encontró ninguna aplicación .app en el archivo ZIP." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ZIP ファイル内に .app アプリケーションが見つかりません。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在 ZIP 文件中未找到 .app 应用程序。" - } - } - } - }, - "No .app.zip application package was found.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No .app.zip application package was found." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se encontró ningún paquete de aplicación .app.zip." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": ".app.zip 形式のアプリケーションパッケージが見つかりません。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未找到 .app.zip 格式的应用程序包。" - } - } - } - }, - "No update is available.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No update is available." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No hay actualizaciones disponibles." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "利用可能なアップデートはありません。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "没有可用的更新。" - } - } - } - }, - "Not Authorized": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not Authorized" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sin autorización" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未許可" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未授权" - } - } - } - }, - "Notice": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notice" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aviso" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "お知らせ" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "提示" - } - } - } - }, - "OK": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "OK" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "OK" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "OK" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定" - } - } - } - }, - "Open Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Settings" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir Ajustes" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定を開く" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "打开设置" - } - } - } - }, - "Open With": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open With" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir con" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用...打开" - } - } - } - }, - "Permissions": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Permissions" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Permisos" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "権限" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "权限" - } - } - } - }, - "Please select the correct Applications folder.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Please select the correct Applications folder." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Selecciona la carpeta Aplicaciones correcta." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "正しいアプリケーションフォルダを選択してください。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "请选择正确的“应用程序”文件夹。" - } - } - } - }, - "Preview:": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Preview:" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Vista previa:" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プレビュー:" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "预览:" - } - } - } - }, - "Protected system folders cannot be deleted: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Protected system folders cannot be deleted: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pueden eliminar las carpetas protegidas del sistema: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保護されたシステムフォルダは削除できません: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法删除系统保护文件夹:%@" - } - } - } - }, - "Protected system folders cannot be shared: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Protected system folders cannot be shared: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pueden compartir las carpetas protegidas del sistema: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保護されたシステムフォルダは共有できません: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法分享系统保护文件夹:%@" - } - } - } - }, - "Quit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Quit" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Salir" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "終了" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "退出" - } - } - } - }, - "RClick (loading...)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick (loading...)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick (cargando...)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick(読み込み中...)" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick(加载中...)" - } - } - } - }, - "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick es una extensión del menú contextual que te permite agregar aplicaciones para abrir carpetas e incluye algunas acciones frecuentes." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick はフォルダを開くアプリを追加できる右クリックメニュー拡張で、いくつかの便利な操作も含まれています。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 是一个右键菜单扩展,可以添加打开文件夹的应用,并提供一些常用操作。" - } - } - } - }, - "RClick needs Full Disk Access to create files, delete files, and manage hidden files in protected folders (like Desktop, Documents, and Downloads).\n\nWithout this permission, some operations may fail on protected directories.\n\nTo enable:\n1. Click \"Open Settings\" below\n2. Click the lock icon and authenticate\n3. Click \"+\" and add RClick from your Applications folder\n4. Toggle the switch next to RClick to ON": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs Full Disk Access to create files, delete files, and manage hidden files in protected folders (like Desktop, Documents, and Downloads).\n\nWithout this permission, some operations may fail on protected directories.\n\nTo enable:\n1. Click \"Open Settings\" below\n2. Click the lock icon and authenticate\n3. Click \"+\" and add RClick from your Applications folder\n4. Toggle the switch next to RClick to ON" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita Acceso total al disco para crear archivos, eliminar archivos y administrar archivos ocultos en carpetas protegidas, como Escritorio, Documentos y Descargas.\n\nSin este permiso, algunas operaciones pueden fallar en los directorios protegidos.\n\nPara activarlo:\n1. Haz clic en “Abrir Ajustes” más abajo\n2. Haz clic en el icono del candado y autentícate\n3. Haz clic en “+” y agrega RClick desde tu carpeta Aplicaciones\n4. Activa el interruptor junto a RClick" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がデスクトップ、書類、ダウンロードなどの保護されたフォルダでファイルの作成、削除、隠しファイルの管理を行うには、フルディスクアクセスが必要です。\n\nこの権限がない場合、保護されたディレクトリで一部の操作が失敗することがあります。\n\n有効にするには:\n1. 下の「設定を開く」をクリックします\n2. 鍵アイコンをクリックして認証します\n3. 「+」をクリックし、アプリケーションフォルダから RClick を追加します\n4. RClick の横にあるスイッチをオンにします" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要完全磁盘访问权限,才能在受保护文件夹中创建文件、删除文件和管理隐藏文件,例如桌面、文稿和下载。\n\n没有此权限时,某些操作可能会在受保护目录中失败。\n\n启用方法:\n1. 点击下方“打开设置”\n2. 点击锁图标并认证\n3. 点击“+”,从“应用程序”文件夹添加 RClick\n4. 打开 RClick 旁边的开关" - } - } - } - }, - "RClick needs Full Disk Access to work with files in protected directories": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs Full Disk Access to work with files in protected directories" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita Acceso total al disco para trabajar con archivos en directorios protegidos" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick が保護されたディレクトリ内のファイルを操作するにはフルディスクアクセスが必要です" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要完全磁盘访问权限才能正常操作受保护目录中的文件" - } - } - } - }, - "RClick needs permission to install the update into your Applications folder.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs permission to install the update into your Applications folder." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita permiso para instalar la actualización en tu carpeta Aplicaciones." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がアップデートをアプリケーションフォルダにインストールするには権限が必要です。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要权限以将更新安装到您的“应用程序”文件夹中。" - } - } - } - }, - "RClick: Click for menu options": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick: Click for menu options" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick: haz clic para ver las opciones del menú" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick: クリックしてメニューオプションを表示" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick:点按以显示菜单选项" - } - } - } - }, - "Reset": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restablecer" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リセット" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置" - } - } - } - }, - "Reset All Settings?": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset All Settings?" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "¿Restablecer todos los ajustes?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての設定をリセットしますか?" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置所有设置?" - } - } - } - }, - "Reset All Settings…": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset All Settings…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restablecer todos los ajustes…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての設定をリセット…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置所有设置…" - } - } - } - }, - "Resetting all settings restores the default configuration and cannot be undone": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Resetting all settings restores the default configuration and cannot be undone" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restablecer todos los ajustes restaura la configuración predeterminada y no se puede deshacer" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての設定をリセットすると既定値に戻り、この操作は元に戻せません" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置所有设置将恢复默认配置,此操作不可逆" - } - } - } - }, - "Restart Later": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart Later" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reiniciar más tarde" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "あとで再起動" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "稍后重启" - } - } - } - }, - "Restart Now": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart Now" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reiniciar ahora" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今すぐ再起動" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "立即重启" - } - } - } - }, - "Restore Defaults": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restore Defaults" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restaurar valores predeterminados" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デフォルトに戻す" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "恢复默认" - } - } - } - }, - "Run Arguments": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run Arguments" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Argumentos de ejecución" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "実行引数" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "运行参数" - } - } - } - }, - "Save": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Save" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Guardar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保存" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "保存" - } - } - } - }, - "Separate multiple arguments with semicolons (;)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Separate multiple arguments with semicolons (;)" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Separa varios argumentos con punto y coma (;)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "複数の引数はセミコロン(;)で区切ってください" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "多个参数用分号(;)分隔" - } - } - } - }, - "Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ajustes" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置" - } - } - } - }, - "Settings Management": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings Management" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Administración de ajustes" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定管理" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置管理" - } - } - } - }, - "Settings…": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ajustes…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置…" - } - } - } - }, - "SF Symbol Name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol Name" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nombre del SF Symbol" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol 名" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol 名称" - } - } - } - }, - "Show icon in menu bar": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Show icon in menu bar" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mostrar el icono en la barra de menús" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メニューバーにアイコンを表示" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在菜单栏显示图标" - } - } - } - }, - "Simplified Chinese": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Simplified Chinese" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Chino simplificado" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "简体中文" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "简体中文" - } - } - } - }, - "Template": { + "Actions": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Template" + "value": "Actions" } }, "es": { "stringUnit": { "state": "translated", - "value": "Plantilla" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テンプレート" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "模板" - } - } - } - }, - "The application bundle is invalid or damaged.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The application bundle is invalid or damaged." + "value": "Acciones" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "El paquete de la aplicación no es válido o está dañado." + "value": "Actions" } }, "ja": { "stringUnit": { "state": "translated", - "value": "アプリケーションバンドルが無効または破損しています。" + "value": "操作" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "应用程序包无效或损坏。" + "value": "操作" } } } }, - "The application has been updated successfully. Restart the app to finish the update.": { + "Common Dir": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The application has been updated successfully. Restart the app to finish the update." + "value": "Common Folders" } }, "es": { "stringUnit": { "state": "translated", - "value": "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリケーションは正常に更新されました。更新を完了するにはアプリを再起動してください。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用程序已成功更新。需要重启应用来完成更新过程。" - } - } - } - }, - "The current folder cannot be deleted. Please select files or subfolders instead.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The current folder cannot be deleted. Please select files or subfolders instead." + "value": "Carpetas frecuentes" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "No se puede eliminar la carpeta actual. En su lugar, selecciona archivos o subcarpetas." + "value": "Dossiers courants" } }, "ja": { "stringUnit": { "state": "translated", - "value": "現在のフォルダは削除できません。代わりにファイルまたはサブフォルダを選択してください。" + "value": "よく使うフォルダ" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法删除当前文件夹,请选择文件或子文件夹进行删除。" + "value": "常用文件夹" } } } }, - "The current folder cannot be shared. Please select files or subfolders instead.": { + "Common Dirs": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The current folder cannot be shared. Please select files or subfolders instead." + "value": "Common Folders" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se puede compartir la carpeta actual. En su lugar, selecciona archivos o subcarpetas." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現在のフォルダは共有できません。代わりにファイルまたはサブフォルダを選択してください。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法共享当前文件夹,请选择文件或子文件夹进行共享。" - } - } - } - }, - "The current version is already up to date.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The current version is already up to date." + "value": "Carpetas frecuentes" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "La versión actual ya está actualizada." + "value": "Dossiers courants" } }, "ja": { "stringUnit": { "state": "translated", - "value": "現在のバージョンはすでに最新です。" + "value": "よく使うフォルダ" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "当前版本已是最新,无需更新。" + "value": "常用文件夹" } } } }, - "The selected folder is a subfolder of an already selected folder. Please choose a different folder.": { + "Enabled": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The selected folder is a subfolder of an already selected folder. Please choose a different folder." + "value": "Enabled" } }, "es": { "stringUnit": { "state": "translated", - "value": "La carpeta seleccionada es una subcarpeta de otra que ya elegiste. Elige una carpeta diferente." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "選択したフォルダは既に選択済みのフォルダのサブフォルダです。別のフォルダを選択してください。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "所选文件夹是已选文件夹的子目录,请选择其他文件夹。" - } - } - } - }, - "The user cancelled authorization.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The user cancelled authorization." + "value": "Activado" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "El usuario canceló la autorización." + "value": "Activé" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ユーザーが認証をキャンセルしました。" + "value": "有効" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "用户取消了授权。" + "value": "启用" } } } }, - "This will delete all custom configurations and restore the defaults. This action cannot be undone.": { + "Icon": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This will delete all custom configurations and restore the defaults. This action cannot be undone." + "value": "Icon" } }, "es": { "stringUnit": { "state": "translated", - "value": "Se eliminarán todas las configuraciones personalizadas y se restaurarán los valores predeterminados. Esta acción no se puede deshacer." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この操作によりすべてのカスタム設定が削除され、既定値に戻ります。元に戻すことはできません。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "此操作将删除所有自定义配置,恢复到默认状态。此操作不可逆。" - } - } - } - }, - "Unauthorized Folder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unauthorized Folder" + "value": "Icono" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Carpeta sin autorización" + "value": "Icône" } }, "ja": { "stringUnit": { "state": "translated", - "value": "未承認のフォルダ" + "value": "アイコン" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "未授权文件夹" + "value": "图标" } } } }, - "Unhide": { + "Name": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unhide" + "value": "Name" } }, "es": { "stringUnit": { "state": "translated", - "value": "Mostrar" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再表示" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "显示" - } - } - } - }, - "Unknown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unknown" + "value": "Nombre" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Desconocido" + "value": "Nom" } }, "ja": { "stringUnit": { "state": "translated", - "value": "不明" + "value": "名称" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "未知" + "value": "名称" } } } }, - "Untitled": { + "New File": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Untitled" + "value": "New File" } }, "es": { "stringUnit": { "state": "translated", - "value": "Sin título" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名称未設定" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未命名" - } - } - } - }, - "Up to Date": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Up to Date" + "value": "Nuevo archivo" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Todo actualizado" + "value": "Nouveau fichier" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最新です" + "value": "新規ファイル" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已是最新版本" + "value": "新建文件" } } } }, - "Update Installed": { + "OK": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Update Installed" + "value": "OK" } }, "es": { "stringUnit": { "state": "translated", - "value": "Actualización instalada" + "value": "OK" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "OK" } }, "ja": { "stringUnit": { "state": "translated", - "value": "アップデートがインストールされました" + "value": "OK" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "更新安装完成" + "value": "确定" } } } }, - "Version %@": { + "Open With": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Version %@" + "value": "Open With" } }, "es": { "stringUnit": { "state": "translated", - "value": "Versión %@" + "value": "Abrir con" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir avec" } }, "ja": { "stringUnit": { "state": "translated", - "value": "バージョン %@" + "value": "アプリで開く" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "版本 %@" + "value": "用...打开" } } } }, - "Version %@ (%@)": { + "RClick (loading...)": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Version %@ (%@)" + "value": "RClick (loading...)" } }, "es": { "stringUnit": { "state": "translated", - "value": "Versión %@ (%@)" + "value": "RClick (cargando...)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RClick (chargement…)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "バージョン %@ (%@)" + "value": "RClick(読み込み中...)" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "版本 %@ (%@)" + "value": "RClick(加载中...)" } } } }, - "Version %@ is ignored": { + "RClick: Click for menu options": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Version %@ is ignored" + "value": "RClick: Click for menu options" } }, "es": { "stringUnit": { "state": "translated", - "value": "Se ignoró la versión %@" + "value": "RClick: haz clic para ver las opciones del menú" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RClick : cliquez pour afficher les options du menu" } }, "ja": { "stringUnit": { "state": "translated", - "value": "バージョン %@ は無視されています" + "value": "RClick: クリックしてメニューオプションを表示" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已忽略版本 %@" + "value": "RClick:点按以显示菜单选项" } } } }, - "Warning": { + "Template": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Warning" + "value": "Template" } }, "es": { "stringUnit": { "state": "translated", - "value": "Advertencia" + "value": "Plantilla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modèle" } }, "ja": { "stringUnit": { "state": "translated", - "value": "警告" + "value": "テンプレート" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "警告" + "value": "模板" } } } @@ -4157,6 +436,12 @@ "value": "RClick no está en ejecución" } }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RClick n’est pas lancé" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -4186,6 +471,12 @@ "value": "Reinicia RClick para restaurar el menú" } }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Relancez RClick pour rétablir le menu" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -4215,6 +506,12 @@ "value": "Esta acción requiere que RClick esté en ejecución. Inicia RClick e inténtalo de nuevo." } }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette action nécessite que RClick soit lancé. Veuillez lancer RClick et réessayer." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -4244,6 +541,12 @@ "value": "RClick no respondió" } }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RClick ne répond pas" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -4273,6 +576,12 @@ "value": "Es posible que la operación no se haya ejecutado. Comprueba que RClick esté en ejecución." } }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’opération n’a peut-être pas été exécutée. Vérifiez que RClick est lancé." + } + }, "ja": { "stringUnit": { "state": "translated", diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index 5029b17..c7e1d84 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -8,39 +8,9 @@ /* Begin PBXBuildFile section */ 5C8A291F2F83D74A00FE687C /* FinderSyncExt.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5C8A29182F83D74A00FE687C /* FinderSyncExt.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 5C969FB72F8A494600D022F8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB32F8A494600D022F8 /* README.md */; }; - 5C969FB82F8A494600D022F8 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB12F8A494600D022F8 /* CLAUDE.md */; }; 5C969FB92F8A494600D022F8 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB22F8A494600D022F8 /* LICENSE */; }; - 5C969FBA2F8A494600D022F8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB32F8A494600D022F8 /* README.md */; }; - 5C969FBB2F8A494600D022F8 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB12F8A494600D022F8 /* CLAUDE.md */; }; 5C969FBC2F8A494600D022F8 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB22F8A494600D022F8 /* LICENSE */; }; - 5C969FBD2F8A494600D022F8 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB32F8A494600D022F8 /* README.md */; }; - 5C969FBE2F8A494600D022F8 /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB12F8A494600D022F8 /* CLAUDE.md */; }; 5C969FBF2F8A494600D022F8 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FB22F8A494600D022F8 /* LICENSE */; }; - 5C969FCF2F8A496000D022F8 /* 架构重构设计及计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCA2F8A496000D022F8 /* 架构重构设计及计划.md */; }; - 5C969FD02F8A496000D022F8 /* 重构计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCC2F8A496000D022F8 /* 重构计划.md */; }; - 5C969FD12F8A496000D022F8 /* 重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCD2F8A496000D022F8 /* 重构设计.md */; }; - 5C969FD22F8A496000D022F8 /* communication-protocol.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC52F8A496000D022F8 /* communication-protocol.md */; }; - 5C969FD32F8A496000D022F8 /* data-models.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC62F8A496000D022F8 /* data-models.md */; }; - 5C969FD42F8A496000D022F8 /* extension-reconnect.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC72F8A496000D022F8 /* extension-reconnect.md */; }; - 5C969FD52F8A496000D022F8 /* git-branch-strategy.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */; }; - 5C969FD62F8A496000D022F8 /* 设置界面重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */; }; - 5C969FD72F8A496000D022F8 /* 架构重构设计及计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCA2F8A496000D022F8 /* 架构重构设计及计划.md */; }; - 5C969FD82F8A496000D022F8 /* 重构计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCC2F8A496000D022F8 /* 重构计划.md */; }; - 5C969FD92F8A496000D022F8 /* 重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCD2F8A496000D022F8 /* 重构设计.md */; }; - 5C969FDA2F8A496000D022F8 /* communication-protocol.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC52F8A496000D022F8 /* communication-protocol.md */; }; - 5C969FDB2F8A496000D022F8 /* data-models.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC62F8A496000D022F8 /* data-models.md */; }; - 5C969FDC2F8A496000D022F8 /* extension-reconnect.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC72F8A496000D022F8 /* extension-reconnect.md */; }; - 5C969FDD2F8A496000D022F8 /* git-branch-strategy.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */; }; - 5C969FDE2F8A496000D022F8 /* 设置界面重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */; }; - 5C969FDF2F8A496000D022F8 /* 架构重构设计及计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCA2F8A496000D022F8 /* 架构重构设计及计划.md */; }; - 5C969FE02F8A496000D022F8 /* 重构计划.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCC2F8A496000D022F8 /* 重构计划.md */; }; - 5C969FE12F8A496000D022F8 /* 重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCD2F8A496000D022F8 /* 重构设计.md */; }; - 5C969FE22F8A496000D022F8 /* communication-protocol.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC52F8A496000D022F8 /* communication-protocol.md */; }; - 5C969FE32F8A496000D022F8 /* data-models.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC62F8A496000D022F8 /* data-models.md */; }; - 5C969FE42F8A496000D022F8 /* extension-reconnect.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC72F8A496000D022F8 /* extension-reconnect.md */; }; - 5C969FE52F8A496000D022F8 /* git-branch-strategy.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */; }; - 5C969FE62F8A496000D022F8 /* 设置界面重构设计.md in Resources */ = {isa = PBXBuildFile; fileRef = 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -81,14 +51,7 @@ 5C969FB12F8A494600D022F8 /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; 5C969FB22F8A494600D022F8 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 5C969FB32F8A494600D022F8 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; - 5C969FC52F8A496000D022F8 /* communication-protocol.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "communication-protocol.md"; sourceTree = ""; }; - 5C969FC62F8A496000D022F8 /* data-models.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "data-models.md"; sourceTree = ""; }; - 5C969FC72F8A496000D022F8 /* extension-reconnect.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "extension-reconnect.md"; sourceTree = ""; }; 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "git-branch-strategy.md"; sourceTree = ""; }; - 5C969FCA2F8A496000D022F8 /* 架构重构设计及计划.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "架构重构设计及计划.md"; sourceTree = ""; }; - 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "设置界面重构设计.md"; sourceTree = ""; }; - 5C969FCC2F8A496000D022F8 /* 重构计划.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "重构计划.md"; sourceTree = ""; }; - 5C969FCD2F8A496000D022F8 /* 重构设计.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "重构设计.md"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -198,9 +161,6 @@ 5C969FC92F8A496000D022F8 /* contracts */ = { isa = PBXGroup; children = ( - 5C969FC52F8A496000D022F8 /* communication-protocol.md */, - 5C969FC62F8A496000D022F8 /* data-models.md */, - 5C969FC72F8A496000D022F8 /* extension-reconnect.md */, 5C969FC82F8A496000D022F8 /* git-branch-strategy.md */, ); path = contracts; @@ -210,10 +170,6 @@ isa = PBXGroup; children = ( 5C969FC92F8A496000D022F8 /* contracts */, - 5C969FCA2F8A496000D022F8 /* 架构重构设计及计划.md */, - 5C969FCB2F8A496000D022F8 /* 设置界面重构设计.md */, - 5C969FCC2F8A496000D022F8 /* 重构计划.md */, - 5C969FCD2F8A496000D022F8 /* 重构设计.md */, ); path = specs; sourceTree = ""; @@ -317,6 +273,7 @@ "zh-Hans", ja, es, + fr, ); mainGroup = 5C8A28D12F83D65900FE687C; minimizedProjectReferenceProxies = 1; @@ -337,16 +294,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5C969FB72F8A494600D022F8 /* README.md in Resources */, - 5C969FB82F8A494600D022F8 /* CLAUDE.md in Resources */, - 5C969FD72F8A496000D022F8 /* 架构重构设计及计划.md in Resources */, - 5C969FD82F8A496000D022F8 /* 重构计划.md in Resources */, - 5C969FD92F8A496000D022F8 /* 重构设计.md in Resources */, - 5C969FDA2F8A496000D022F8 /* communication-protocol.md in Resources */, - 5C969FDB2F8A496000D022F8 /* data-models.md in Resources */, - 5C969FDC2F8A496000D022F8 /* extension-reconnect.md in Resources */, - 5C969FDD2F8A496000D022F8 /* git-branch-strategy.md in Resources */, - 5C969FDE2F8A496000D022F8 /* 设置界面重构设计.md in Resources */, 5C969FB92F8A494600D022F8 /* LICENSE in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -355,17 +302,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5C969FBA2F8A494600D022F8 /* README.md in Resources */, - 5C969FBB2F8A494600D022F8 /* CLAUDE.md in Resources */, 5C969FBC2F8A494600D022F8 /* LICENSE in Resources */, - 5C969FDF2F8A496000D022F8 /* 架构重构设计及计划.md in Resources */, - 5C969FE02F8A496000D022F8 /* 重构计划.md in Resources */, - 5C969FE12F8A496000D022F8 /* 重构设计.md in Resources */, - 5C969FE22F8A496000D022F8 /* communication-protocol.md in Resources */, - 5C969FE32F8A496000D022F8 /* data-models.md in Resources */, - 5C969FE42F8A496000D022F8 /* extension-reconnect.md in Resources */, - 5C969FE52F8A496000D022F8 /* git-branch-strategy.md in Resources */, - 5C969FE62F8A496000D022F8 /* 设置界面重构设计.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -373,17 +310,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5C969FBD2F8A494600D022F8 /* README.md in Resources */, - 5C969FBE2F8A494600D022F8 /* CLAUDE.md in Resources */, 5C969FBF2F8A494600D022F8 /* LICENSE in Resources */, - 5C969FCF2F8A496000D022F8 /* 架构重构设计及计划.md in Resources */, - 5C969FD02F8A496000D022F8 /* 重构计划.md in Resources */, - 5C969FD12F8A496000D022F8 /* 重构设计.md in Resources */, - 5C969FD22F8A496000D022F8 /* communication-protocol.md in Resources */, - 5C969FD32F8A496000D022F8 /* data-models.md in Resources */, - 5C969FD42F8A496000D022F8 /* extension-reconnect.md in Resources */, - 5C969FD52F8A496000D022F8 /* git-branch-strategy.md in Resources */, - 5C969FD62F8A496000D022F8 /* 设置界面重构设计.md in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -562,7 +489,7 @@ CODE_SIGN_ENTITLEMENTS = RClick/RClickDebug.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260823001; + CURRENT_PROJECT_VERSION = 20260913002; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -577,7 +504,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -599,7 +526,7 @@ CODE_SIGN_ENTITLEMENTS = RClick/RClick.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260823001; + CURRENT_PROJECT_VERSION = 20260913002; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -614,7 +541,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -677,7 +604,7 @@ CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260823001; + CURRENT_PROJECT_VERSION = 20260913002; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -693,7 +620,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick.FinderSyncExt; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -712,7 +639,7 @@ CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260823001; + CURRENT_PROJECT_VERSION = 20260913002; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -728,7 +655,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.2.0; + MARKETING_VERSION = 2.2.1; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick.FinderSyncExt; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/RClick/AppState.swift b/RClick/AppState.swift index ad3ad48..5a07794 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -29,20 +29,56 @@ class AppState: ObservableObject, ActionStateProviding { let bookmarkManager = BookmarkManager() // 折叠开关状态 - 每个分类独立控制 - @AppStorage("foldAppsMenu") var foldAppsMenu: Bool = false - @AppStorage("foldActionsMenu") var foldActionsMenu: Bool = false - @AppStorage("foldNewFileMenu") var foldNewFileMenu: Bool = true - @AppStorage("foldCommonDirMenu") var foldCommonDirMenu: Bool = true + // + // 全部存在 App Group 里,和 showMenuBarExtra 一致,全项目只剩一个 store。 + // 注意收益仅止于"不再有两个 store":扩展并不读这些键,它拿到的是 MenuService + // 算好的 MenuConfigPayload,所以这不是功能性改进,别高估它。 + // + // 它们原本在 UserDefaults.standard,靠 init 里那一次 migrateLayoutToggles 搬过来。 + @AppStorage("foldAppsMenu", store: .group) var foldAppsMenu: Bool = false + @AppStorage("foldActionsMenu", store: .group) var foldActionsMenu: Bool = false + @AppStorage("foldNewFileMenu", store: .group) var foldNewFileMenu: Bool = true + @AppStorage("foldCommonDirMenu", store: .group) var foldCommonDirMenu: Bool = true // 常用文件夹总开关(默认关闭) - @AppStorage("showCommonDirs") var showCommonDirs: Bool = false + @AppStorage("showCommonDirs", store: .group) var showCommonDirs: Bool = false - // 菜单栏显示 - @AppStorage(Key.showMenuBarExtra) var showMenuBar: Bool = true + // 菜单栏显示走 RClickApp / GeneralSettingsTabView 里的那份(store: .group)。 + // 这里曾有一份重复声明,没有任何地方读它;两份 store 不同,留着就是个陷阱。 /// 配置持久化服务 let configService = ConfigService() + /// Layout toggles that used to live in `UserDefaults.standard`. + /// + /// Listed by name because the migration has to find them in the old store, and + /// because renaming one without updating this list would silently strand the + /// user's value there. + nonisolated static let layoutToggleKeys = [ + "foldAppsMenu", "foldActionsMenu", "foldNewFileMenu", "foldCommonDirMenu", "showCommonDirs", + ] + + /// Moves the layout toggles into the shared group, once. + /// + /// Splitting these across two stores is the defect this fixes. It needs a + /// migration rather than a plain store swap because the old values would + /// otherwise become invisible and every switch would fall back to its + /// declaration default. "Enable common folders" going back to off is the one + /// that bites: it empties `payload.commonDirs`, so any custom menu referencing a + /// common folder stops resolving and the whole custom layout reverts. + /// + /// Takes both stores explicitly so a test can drive it with throwaway suites + /// rather than the real ones. + nonisolated static func migrateLayoutToggles(from old: UserDefaults, to new: UserDefaults) { + for key in layoutToggleKeys { + guard new.object(forKey: key) == nil, let existing = old.object(forKey: key) else { continue } + new.set(existing, forKey: key) + old.removeObject(forKey: key) + } + } + init(inExt: Bool = false) { + // Before anything reads the switches above. + Self.migrateLayoutToggles(from: .standard, to: .group) self.inExt = inExt Task { @MainActor in logger.debug("start load") @@ -55,27 +91,19 @@ class AppState: ObservableObject, ActionStateProviding { @MainActor func deleteApp(index: Int) { apps.remove(at: index) - do { - try save() - } catch { - logger.info("save error: \(error.localizedDescription)") - } + persist() } @MainActor func addApp(item: OpenWithApp) { logger.debug("start add app") apps.append(item) - do { - try save() - } catch { - logger.info("save error: \(error.localizedDescription)") - } + persist() } @MainActor func moveApps(from source: IndexSet, to destination: Int) { apps.move(fromOffsets: source, toOffset: destination) - persistMenuOrder() + persist() } @MainActor @@ -87,12 +115,12 @@ class AppState: ObservableObject, ActionStateProviding { updatedApp.environment = environment updatedApp.opensNewInstance = opensNewInstance apps[index] = updatedApp - try? save() + persist() } } func getAppItem(rid: String) -> OpenWithApp? { - return apps.first { rid.contains($0.id) } + return apps.first { rid == $0.id } } func getFileType(rid: String) -> NewFile? { @@ -105,28 +133,19 @@ class AppState: ObservableObject, ActionStateProviding { @MainActor func deleteNewFile(id: String) { newFiles.removeAll { $0.id == id } - do { - try save() - } catch { - logger.info("save error: \(error.localizedDescription)") - } - NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + persist() } @MainActor func addNewFile(_ item: NewFile) { logger.debug("start add new file type") newFiles.append(item) - do { - try save() - } catch { - logger.info("save error: \(error.localizedDescription)") - } + persist() } @MainActor func moveNewFiles(from source: IndexSet, to destination: Int) { newFiles.move(fromOffsets: source, toOffset: destination) - persistMenuOrder() + persist() } // MARK: - Actions @@ -138,23 +157,22 @@ class AppState: ObservableObject, ActionStateProviding { } @MainActor func toggleActionItem() { - try? save() - NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + persist() } @MainActor func moveActions(from source: IndexSet, to destination: Int) { actions.move(fromOffsets: source, toOffset: destination) - persistMenuOrder() + persist() } @MainActor func resetActionItems() { actions = RCAction.all - try? save() + persist() } @MainActor func resetFiletypeItems() { newFiles = NewFile.all - try? save() + persist() } @MainActor func refresh() { @@ -166,20 +184,50 @@ class AppState: ObservableObject, ActionStateProviding { } @MainActor func sync() { - try? save() + persist() } // MARK: - 持久化 + /// Set when the last write to SwiftData failed, cleared on the next success. + /// + /// Persistence failures used to be logged at `.info`, which never reaches the + /// log store: a failed save looked exactly like a successful one, and the only + /// symptom was a setting that had silently not been kept. + @Published private(set) var lastSaveError: String? + + /// Dismisses the alert in the settings window and arms it for the next failure. @MainActor - private func persistMenuOrder() { + func clearSaveError() { + lastSaveError = nil + } + + /// Writes the in-memory configuration to SwiftData and tells the app to rebuild + /// the menu from it. + /// + /// One entry point on purpose. Every mutation used to persist and notify by + /// hand and the call sites disagreed about both halves: most dropped the error, + /// and several skipped the notification entirely - adding an app, adding a file + /// type, resetting either list and syncing common folders all stayed invisible + /// until the next heartbeat. Doing both here means a mutation cannot get one + /// without the other. + /// + /// Deliberately non-throwing; a failure lands in `lastSaveError` for the + /// settings window to show, and is logged at `.error`. + @MainActor + private func persist() { reindexMenuItems() do { - try save() - NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + try configService.save(AppConfigData(apps: apps, actions: actions, newFiles: newFiles, commonDirs: cdirs)) + lastSaveError = nil } catch { - logger.info("save error: \(error.localizedDescription)") + lastSaveError = error.localizedDescription + logger.error("save error: \(error.localizedDescription)") } + // Posted even when persisting failed: the menu is built from these in-memory + // arrays, so suppressing it would leave the app and the menu disagreeing + // about what is configured. + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) } @MainActor @@ -195,10 +243,4 @@ class AppState: ObservableObject, ActionStateProviding { return newFile } } - - @MainActor - private func save() throws { - reindexMenuItems() - try configService.save(AppConfigData(apps: apps, actions: actions, newFiles: newFiles, commonDirs: cdirs)) - } } diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 2fdabb0..fb1ca68 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -1,4823 +1,6546 @@ { - "sourceLanguage": "en", - "strings": { - "": { - "extractionState": "stale", - "localizations": { - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "" + "sourceLanguage" : "en", + "strings" : { + "%lld" : { + + }, + "%lld items" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld items" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld elementos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld éléments" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 項目" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld 个项目" + } + } + } + }, + "About" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À propos" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "情報" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关于" } } } }, - "%@": {}, - "%@(%@)": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@(%2$@)" + "Accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accessibility" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accesibilidad" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accessibilité" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アクセシビリティ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "辅助功能" + } + } + } + }, + "Actions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作" + } + } + } + }, + "Add" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加" + } + } + } + }, + "Add a Folder…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a Folder…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar una carpeta…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un dossier…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダを追加…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加一个文件夹…" + } + } + } + }, + "Add a Star" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a Star" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir una estrella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une étoile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スターを付ける" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加星" + } + } + } + }, + "Add App" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add App" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar aplicación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une app" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%1$@(%2$@)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを追加" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加应用" } } } }, - "%lld": {}, - "1. Click \"Settings…\" on the right to open System Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "1. Click \"Settings…\" on the right to open System Settings" + "Add File Type" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add File Type" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar tipo de archivo" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "1. Haz clic en “Ajustes…” a la derecha para abrir Ajustes del Sistema" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un type de fichier" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "1. 右側の「設定…」をクリックしてシステム設定を開きます" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル種類を追加" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "1. 点击右侧“设置…”按钮打开系统设置" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加文件类型" } } } }, - "2. Click the lock icon and authenticate": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "2. Click the lock icon and authenticate" + "Add Folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Folder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar carpeta" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "2. Haz clic en el icono del candado y autentícate" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un dossier" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "2. 左下の鍵アイコンをクリックして認証します" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダを追加" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "2. 点击左下角锁图标并认证" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加文件夹" } } } }, - "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"" + "Added Folders" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Added Folders" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "3. Haz clic en “+” -> abre “Aplicaciones” -> selecciona “RClick”" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpetas agregadas" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "3. 「+」を押して「アプリケーション」から「RClick」を選択します" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dossiers ajoutés" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "3. 点击“+”并前往“应用程序”,选择“RClick”" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加済みフォルダ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已添加的文件夹" } } } }, - "4. Make sure the switch next to RClick is turned on": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "4. Make sure the switch next to RClick is turned on" + "AirDrop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "4. Asegúrate de que el interruptor junto a RClick esté activado" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "4. RClick の横にあるスイッチがオンになっていることを確認します" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "4. 确保 RClick 旁边的开关已打开" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隔空投送" } } } }, - "About": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "About" + "Applications" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applications" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicaciones" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acerca de" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applications" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "情報" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリケーション" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "关于" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用程序" } } } }, - "Accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Accessibility" + "Apply Custom Menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apply Custom Menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicar menú personalizado" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Accesibilidad" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appliquer le menu personnalisé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アクセシビリティ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタムメニューを適用" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "辅助功能" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用配置" } } } }, - "Actions": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Actions" + "Apps" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acciones" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "操作" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "操作" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用" } } } }, - "Add": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add" + "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Seguro que quieres eliminar “%@” (%@)? Esta acción no se puede deshacer." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer « %@ » (%@) ? Cette action est irréversible." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\"(%@)を削除してもよろしいですか?この操作は取り消せません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要删除「%@」(%@)吗?此操作无法撤销。" } } } }, - "Add a Folder…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add a Folder…" + "Arguments (semicolon separated)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arguments (semicolon separated)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumentos (separados por punto y coma)" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar una carpeta…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arguments (séparés par des points-virgules)" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを追加…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "引数(セミコロン区切り)" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加一个文件夹…" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "参数(分号分隔)" } } } }, - "Add App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add App" + "Arguments: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arguments: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumentos: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar aplicación" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arguments : %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを追加" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "引数: %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加应用" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "参数:%@" } } } }, - "Add File Type": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add File Type" + "Authorize folders to let RClick create, delete, and manage files in them." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authorize folders to let RClick create, delete, and manage files in them." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar tipo de archivo" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriza carpetas para que RClick pueda crear, eliminar y administrar archivos en ellas." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル種類を追加" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisez des dossiers pour permettre à RClick d’y créer, supprimer et gérer des fichiers." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加文件类型" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダを認証して、RClick がファイルの作成、削除、管理を行えるようにします。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授权文件夹,让 RClick 可以在其中创建、删除和管理文件。" } } } }, - "Add Folder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add Folder" + "Authorized" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authorized" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizado" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Agregar carpeta" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを追加" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "許可済み" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加文件夹" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已授权" } } } }, - "Added Folders": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Added Folders" + "Authorizes this folder and all its subfolders" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authorizes this folder and all its subfolders" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriza esta carpeta y todas sus subcarpetas" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpetas agregadas" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorise ce dossier et tous ses sous-dossiers" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加済みフォルダ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このフォルダとそのすべてのサブフォルダを許可します" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已添加的文件夹" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授权此文件夹及其所有子文件夹" } } } }, - "AirDrop": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" + "Backup" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Backup" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respaldo" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sauvegarde" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "AirDrop" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バックアップ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "备份" } } } }, - "Applications": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Applications" + "Cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aplicaciones" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリケーション" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用程序" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" } } } }, - "Apps": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Apps" + "Change App" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change App" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Apps" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambiar aplicación" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer d’app" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを変更" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更换应用" } } } }, - "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." + "Change Template" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change Template" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambiar plantilla" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "¿Seguro que quieres eliminar “%@” (%@)? Esta acción no se puede deshacer." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer de modèle" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "\"%@\"(%@)を削除してもよろしいですか?この操作は取り消せません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テンプレートを変更" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定要删除「%@」(%@)吗?此操作无法撤销。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更换模板" } } } }, - "Arguments (semicolon separated)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Arguments (semicolon separated)" + "Changed since it was applied, so the menu still shows the previous layout." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changed since it was applied, so the menu still shows the previous layout." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se modificó después de aplicarse, así que el menú sigue mostrando el diseño anterior." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Argumentos (separados por punto y coma)" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifié depuis son application : le menu affiche toujours la disposition précédente." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "引数(セミコロン区切り)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "適用後に変更されたため、メニューは前回のレイアウトのままです。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "参数(分号分隔)" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件已修改,尚未应用,菜单仍是上一次生效的布局。" } } } }, - "Arguments: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Arguments: %@" + "Check for Updates" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check for Updates" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Argumentos: %@" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar actualizaciones" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "引数: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher des mises à jour" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "参数:%@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートを確認" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查更新" } } } }, - "Authorize folders to let RClick create, delete, and manage files in them.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authorize folders to let RClick create, delete, and manage files in them." + "Checking for updates..." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking for updates..." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Autoriza carpetas para que RClick pueda crear, eliminar y administrar archivos en ellas." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscando actualizaciones..." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダを認証して、RClick がファイルの作成、削除、管理を行えるようにします。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recherche de mises à jour…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权文件夹,让 RClick 可以在其中创建、删除和管理文件。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートを確認中..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查更新..." } } } }, - "Authorized": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authorized" + "Choose a folder for RClick to access." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a folder for RClick to access." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Autorizado" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una carpeta a la que RClick pueda acceder." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "許可済み" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez un dossier auquel RClick pourra accéder." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已授权" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick がアクセスできるフォルダを選択してください。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 RClick 可以访问的文件夹。" } } } }, - "Backup": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Backup" + "Choose Default App" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Default App" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elegir aplicación predeterminada" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Respaldo" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir l’app par défaut" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バックアップ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "既定アプリを選択" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "备份" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择默认打开应用" } } } }, - "Cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" + "Choose Template File" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Template File" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cancelar" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elegir archivo de plantilla" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir un fichier modèle" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "取消" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テンプレートファイルを選択" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择模板文件" } } } }, - "Change App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Change App" + "Collapse actions menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collapse actions menu" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cambiar aplicación" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de acciones" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを変更" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réduire le menu Actions" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更换应用" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作メニューを折りたたむ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "折叠动作菜单" } } } }, - "Change Template": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Change Template" + "Collapse apps menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collapse apps menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de aplicaciones" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cambiar plantilla" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réduire le menu Apps" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テンプレートを変更" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリメニューを折りたたむ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更换模板" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "折叠应用菜单" } } } }, - "Check for Updates": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Check for Updates" + "Collapse common folders menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collapse common folders menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de carpetas frecuentes" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Buscar actualizaciones" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réduire le menu Dossiers courants" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートを確認" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "よく使うフォルダメニューを折りたたむ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "检查更新" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "折叠常用文件夹菜单" } } } }, - "Checking for updates...": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Checking for updates..." + "Collapse new file menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collapse new file menu" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Buscando actualizaciones..." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de nuevo archivo" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートを確認中..." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réduire le menu Nouveau fichier" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在检查更新..." + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新規ファイルメニューを折りたたむ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "折叠新建文件菜单" } } } }, - "Choose a folder for RClick to access.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a folder for RClick to access." + "Common Dir" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Common Folders" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Elige una carpeta a la que RClick pueda acceder." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpetas frecuentes" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がアクセスできるフォルダを選択してください。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dossiers courants" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "选择 RClick 可以访问的文件夹。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "よく使うフォルダ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "常用文件夹" } } } }, - "Choose Default App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose Default App" + "Common Dirs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Common Folders" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpetas frecuentes" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Elegir aplicación predeterminada" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dossiers courants" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "既定アプリを選択" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "よく使うフォルダ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "选择默认打开应用" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "常用文件夹" } } } }, - "Choose Template File": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose Template File" + "Configuration invalid" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration invalid" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración no válida" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Elegir archivo de plantilla" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration non valide" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テンプレートファイルを選択" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定が無効" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "选择模板文件" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置无效" } } } }, - "Collapse actions menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse actions menu" + "Configuration Not Applied" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration Not Applied" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración no aplicada" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú de acciones" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration non appliquée" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "操作メニューを折りたたむ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定は適用されませんでした" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠动作菜单" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置未生效" } } } }, - "Collapse apps menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse apps menu" + "Copy Path" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy Path" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú de aplicaciones" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar ruta" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリメニューを折りたたむ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copier le chemin" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠应用菜单" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パスをコピー" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制路径" } } } }, - "Collapse menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse menu" + "Custom Menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menú personalizado" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu personnalisé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メニューを折りたたむ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタムメニュー" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠菜单" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义菜单" } } } }, - "Collapse new file menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapse new file menu" + "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ú." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraer el menú de nuevo archivo" + "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": "新規ファイルメニューを折りたたむ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "custom_menu.jsonで最上位の項目や多階層のサブメニューをカスタマイズできます。💡 ヒント:このファイルをAIアシスタント(ChatGPT / Claudeなど)に渡すと、メニュー構成の作成を手伝ってもらえます。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "折叠新建文件菜单" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 custom_menu.json 自定义一级平铺与多层子菜单。💡 提示:可直接将此文件提供给 AI 助手(如 ChatGPT / Claude)帮你一键编排结构。" } } } }, - "Common Dir": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Common Folders" + "Default Open App" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default Open App" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpetas frecuentes" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicación predeterminada" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "よく使うフォルダ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "App d’ouverture par défaut" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "常用文件夹" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "既定で開くアプリ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认打开应用" } } } }, - "Copy Path": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy Path" + "Delete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Copiar ruta" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "パスをコピー" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "复制路径" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" } } } }, - "Default Open App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Default Open App" + "Delete App" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete App" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aplicación predeterminada" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar aplicación" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "既定で開くアプリ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer l’app" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "默认打开应用" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを削除" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除应用" } } } }, - "Delete": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete" + "Delete Direct" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Direct" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar directamente" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminar" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer immédiatement" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接削除" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直接删除" } } } }, - "Delete App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete App" + "Delete File Type" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete File Type" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar tipo de archivo" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminar aplicación" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le type de fichier" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを削除" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル種類を削除" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除应用" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文件类型" } } } }, - "Delete Direct": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete Direct" + "Deletes the file and returns to the default layout, keeping a backup." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deletes the file and returns to the default layout, keeping a backup." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminar directamente" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina el archivo y vuelve al diseño predeterminado, conservando una copia." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "直接削除" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprime le fichier et revient à la disposition par défaut, en conservant une sauvegarde." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "直接删除" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルを削除してデフォルトのレイアウトに戻します。バックアップは保持されます。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除该文件并恢复默认布局,同时保留一份备份。" } } } }, - "Delete File Type": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete File Type" + "Desktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desktop" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminar tipo de archivo" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escritorio" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル種類を削除" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bureau" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除文件类型" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デスクトップ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "桌面" } } } }, - "Desktop": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Desktop" + "Display Name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display Name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre visible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom affiché" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示名" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示名称" + } + } + } + }, + "Documents" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documents" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documents" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "書類" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文档" + } + } + } + }, + "DOCX" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DOCX" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documento de Word" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Document Word" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Word 文書" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Word 文档" + } + } + } + }, + "Done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "完了" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, + "Download and Install" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download and Install" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargar e instalar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger et installer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードしてインストール" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载并安装" + } + } + } + }, + "Download failed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download failed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La descarga falló" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec du téléchargement" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロードに失敗しました" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载失败" + } + } + } + }, + "Download Manually" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download Manually" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargar manualmente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger manuellement" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "手動でダウンロード" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动下载" + } + } + } + }, + "Downloads" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Téléchargements" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ダウンロード" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载" + } + } + } + }, + "Edit App" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit App" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar aplicación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier l’app" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリを編集" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑应用" + } + } + } + }, + "Edit App Properties" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit App Properties" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar propiedades de la aplicación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier les propriétés de l’app" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリ設定を編集" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑应用属性" + } + } + } + }, + "Edit File Type" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit File Type" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar tipo de archivo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier le type de fichier" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル種類を編集" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑文件类型" + } + } + } + }, + "Edit Menu Config…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Menu Config…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar configuración del menú…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier la configuration du menu…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メニュー設定を編集…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑菜单配置…" + } + } + } + }, + "Editing the file changes nothing until you apply it." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editing the file changes nothing until you apply it." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar el archivo no cambia nada hasta que lo apliques." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier le fichier ne change rien tant que vous ne l'appliquez pas." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルを編集しても、適用するまで何も変わりません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "改完文件后,点「应用配置」才会生效。" + } + } + } + }, + "Empty menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empty menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menú vacío" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menu vide" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "空のメニュー" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空菜单" + } + } + } + }, + "Enable common folders" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable common folders" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar carpetas frecuentes" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Escritorio" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer les dossiers courants" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デスクトップ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "よく使うフォルダを有効化" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "桌面" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用常用文件夹" } } } }, - "Display Name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Display Name" + "Enabled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enabled" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nombre visible" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activado" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "表示名" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activé" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "显示名称" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "有効" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用" } } } }, - "Documents": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Documents" + "Enter an SF Symbol name, for example doc.text or curlybraces" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter an SF Symbol name, for example doc.text or curlybraces" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escribe el nombre de un SF Symbol, por ejemplo doc.text o curlybraces" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Documentos" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saisissez un nom de symbole SF, par exemple doc.text ou curlybraces" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "書類" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "SF Symbol 名を入力してください。例: doc.text、curlybraces" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文档" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入 SF Symbol 名称,例如 doc.text、curlybraces" } } } }, - "Don't Ask Again": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Don't Ask Again" + "Environment Variables" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Environment Variables" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No preguntar de nuevo" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables de entorno" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今後表示しない" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables d’environnement" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "不再询问" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "環境変数" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "环境变量" } } } }, - "Done": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Done" + "Environment variables: %lld" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Environment variables: %lld" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables de entorno: %lld" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Listo" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables d’environnement : %lld" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "完了" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "環境変数: %lld 個" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "完成" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "环境变量:%lld 个" } } } }, - "Download and Install": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download and Install" + "Export Logs…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export Logs…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descargar e instalar" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar registros…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロードしてインストール" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter les journaux…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下载并安装" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログを書き出し…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出日志…" } } } }, - "Download failed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download failed" + "Export…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "La descarga falló" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロードに失敗しました" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "書き出し…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下载失败" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出…" } } } }, - "Download Manually": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Download Manually" + "Extension: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extension: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descargar manualmente" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión: %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "手動でダウンロード" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extension : %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "手动下载" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "拡張子: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "扩展名:%@" } } } }, - "Downloads": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "Extraction failed: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extraction failed: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error al descomprimir: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descargas" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de l’extraction : %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダウンロード" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "展開に失敗しました: %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "下载" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解压失败:%@" } } } }, - "Edit App": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit App" + "Failed to Check for Updates" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to Check for Updates" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar aplicación" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron buscar actualizaciones" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリを編集" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de la recherche de mises à jour" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑应用" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートの確認に失敗しました" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查更新失败" } } } }, - "Edit App Properties": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit App Properties" + "Feedback & Issues" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feedback & Issues" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comentarios y problemas" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar propiedades de la aplicación" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retours et problèmes" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリ設定を編集" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フィードバックと問題" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑应用属性" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "反馈与建议" } } } }, - "Edit File Type": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit File Type" + "File Extension" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File Extension" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Editar tipo de archivo" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de archivo" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル種類を編集" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extension de fichier" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "编辑文件类型" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイル拡張子" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件扩展名" } } } }, - "Enable common folders": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable common folders" + "File Provider: Select \"RClick\" in the list to enable the Finder context menu" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File Provider: Select \"RClick\" in the list to enable the Finder context menu" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proveedor de archivos: selecciona “RClick” en la lista para activar el menú contextual del Finder" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activar carpetas frecuentes" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fournisseur de fichiers : sélectionnez « RClick » dans la liste pour activer le menu contextuel du Finder" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "よく使うフォルダを有効化" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルプロバイダ一覧で「RClick」を選択すると Finder の右クリックメニューを有効にできます" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用常用文件夹" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件提供程序:在列表中选择“RClick”以启用 Finder 右键菜单" } } } }, - "Enable Full Disk Access": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable Full Disk Access" + "Finder Extension" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finder Extension" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activar Acceso total al disco" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión del Finder" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フルディスクアクセスを有効にする" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extension Finder" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用完全磁盘访问权限" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finder 拡張" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finder 扩展" } } } }, - "Enable RClick": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable RClick" + "Folder access permission is required to use this feature." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folder access permission is required to use this feature." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se necesita permiso de acceso a la carpeta para usar esta función." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activar RClick" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’autorisation d’accès au dossier est requise pour utiliser cette fonctionnalité." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick を有効にする" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この機能を使うにはフォルダへのアクセス許可が必要です。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用 RClick" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "必须授予文件夹访问权限才能使用此功能。" } } } }, - "Enable RClick in File Provider to show its actions in Finder context menus": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable RClick in File Provider to show its actions in Finder context menus" + "Folder Permissions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folder Permissions" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activa RClick en Proveedor de archivos para que sus acciones aparezcan en los menús contextuales del Finder" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permisos de carpetas" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイルプロバイダで RClick を有効にすると Finder の右クリックメニューに表示されます" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisations de dossiers" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在文件提供程序中启用 RClick 以在 Finder 右键菜单中显示功能" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダ権限" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件夹权限" } } } }, - "Enabled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enabled" + "Folders cannot be shared via AirDrop: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folders cannot be shared via AirDrop: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las carpetas no se pueden compartir por AirDrop: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Activado" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les dossiers ne peuvent pas être partagés via AirDrop : %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "有効" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダは AirDrop で共有できません: %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "启用" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不能通过 AirDrop 分享文件夹:%@" } } } }, - "Enter an SF Symbol name, for example doc.text or curlybraces": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter an SF Symbol name, for example doc.text or curlybraces" + "For browser-like apps, launch a new instance instead of opening a new tab in the running one" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "For browser-like apps, launch a new instance instead of opening a new tab in the running one" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Escribe el nombre de un SF Symbol, por ejemplo doc.text o curlybraces" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Para apps tipo navegador, lanza una nueva instancia en lugar de abrir una pestaña en la ya abierta" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol 名を入力してください。例: doc.text、curlybraces" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pour les apps de type navigateur, lance une nouvelle instance au lieu d’ouvrir un nouvel onglet dans celle en cours" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "输入 SF Symbol 名称,例如 doc.text、curlybraces" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブラウザ系アプリの場合、起動中のインスタンスに新しいタブを開く代わりに、新しいインスタンスを起動します" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对浏览器类 app,改为启动新实例,而不是在已运行的实例中新开标签页" } } } }, - "Environment Variables": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Environment Variables" + "For example: txt, md, json" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "For example: txt, md, json" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por ejemplo: txt, md, json" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Variables de entorno" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Par exemple : txt, md, json" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "環境変数" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "例: txt、md、json" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "环境变量" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "例如:txt、md、json" } } } }, - "Environment variables: %lld": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Environment variables: %lld" + "Format: KEY=VALUE, one per line" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Format: KEY=VALUE, one per line" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Variables de entorno: %lld" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Formato: CLAVE=VALOR, uno por línea" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "環境変数: %lld 個" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Format : CLÉ=VALEUR, une par ligne" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "环境变量:%lld 个" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "形式: KEY=VALUE を1行ずつ入力" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "格式:KEY=VALUE,每行一个" } } } }, - "Export Logs…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Export Logs…" + "Full Disk Access" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Full Disk Access" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acceso total al disco" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Exportar registros…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accès complet au disque" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログを書き出し…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フルディスクアクセス" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "导出日志…" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完全磁盘访问权限" } } } }, - "Export…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Export…" + "General" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Exportar…" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "書き出し…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Général" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "导出…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一般" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通用" } } } }, - "Extension: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Extension: %@" + "Grant Access" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant Access" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir acceso" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Extensión: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser l’accès" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "拡張子: %@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アクセスを許可" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "扩展名:%@" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授权访问" } } } }, - "Extraction failed: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Extraction failed: %@" + "Grant Permission" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grant Permission" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error al descomprimir: %@" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otorgar permiso" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "展開に失敗しました: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accorder l’autorisation" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "解压失败:%@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "権限を付与" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "授予权限" } } } }, - "Failed to Check for Updates": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to Check for Updates" + "Hide" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hide" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudieron buscar actualizaciones" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートの確認に失敗しました" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "隠す" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "检查更新失败" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐藏" } } } }, - "File Extension": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "File Extension" + "Home" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Extensión de archivo" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta de inicio" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル拡張子" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Départ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件扩展名" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ホーム" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用户主目录" } } } }, - "File Provider: Select \"RClick\" in the list to enable the Finder context menu": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "File Provider: Select \"RClick\" in the list to enable the Finder context menu" + "Icon" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icon" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icono" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Proveedor de archivos: selecciona “RClick” en la lista para activar el menú contextual del Finder" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icône" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイルプロバイダ一覧で「RClick」を選択すると Finder の右クリックメニューを有効にできます" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アイコン" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件提供程序:在列表中选择“RClick”以启用 Finder 右键菜单" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图标" } } } }, - "Finder Extension": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Finder Extension" + "Ignore This Version" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignore This Version" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Extensión del Finder" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar esta versión" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Finder 拡張" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorer cette version" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Finder 扩展" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このバージョンを無視" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "忽略此版本" } } } }, - "Folder access permission is required to use this feature.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Folder access permission is required to use this feature." + "Import…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se necesita permiso de acceso a la carpeta para usar esta función." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importer…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この機能を使うにはフォルダへのアクセス許可が必要です。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "読み込み…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "必须授予文件夹访问权限才能使用此功能。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入…" } } } }, - "Folder Permissions": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Folder Permissions" + "Installation failed: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation failed: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Permisos de carpetas" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error al instalar: %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダ権限" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de l’installation : %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "文件夹权限" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インストールに失敗しました: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装失败:%@" } } } }, - "Folders cannot be shared via AirDrop: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Folders cannot be shared via AirDrop: %@" + "Invalid Folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Folder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta no válida" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Las carpetas no se pueden compartir por AirDrop: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dossier non valide" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダは AirDrop で共有できません: %@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無効なフォルダ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "不能通过 AirDrop 分享文件夹:%@" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效文件夹" } } } }, - "For example: txt, md, json": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "For example: txt, md, json" + "Invalid value" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid value" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Por ejemplo: txt, md, json" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valor no válido" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "例: txt、md、json" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valeur non valide" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "例如:txt、md、json" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "値が無効" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "值无效" } } } }, - "Format: KEY=VALUE, one per line": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Format: KEY=VALUE, one per line" + "Later" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Later" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más tarde" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Formato: CLAVE=VALOR, uno por línea" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus tard" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "形式: KEY=VALUE を1行ずつ入力" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "後で" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "格式:KEY=VALUE,每行一个" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "稍后" } } } }, - "Full Disk Access": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Full Disk Access" + "Launch at login" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Launch at login" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acceso total al disco" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir al iniciar sesión" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フルディスクアクセス" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir à la connexion" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "完全磁盘访问权限" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログイン時に起動" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "登录时启动" } } } }, - "Full Disk Access: Required to create and delete files in protected directories": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Full Disk Access: Required to create and delete files in protected directories" + "Launch at login is pending approval in System Settings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Launch at login is pending approval in System Settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciar sesión automáticamente está pendiente de aprobación en Ajustes del Sistema" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Acceso total al disco: se necesita para crear y eliminar archivos en directorios protegidos" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’ouverture à la connexion est en attente d’approbation dans Réglages Système" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フルディスクアクセスは保護されたディレクトリでのファイル作成と削除に必要です" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログイン時に起動するにはシステム設定での承認が必要です" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "完全磁盘访问权限:用于在受保护目录中创建和删除文件" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开机启动需在系统设置中批准" } } } }, - "General": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "General" + "Launch at login requires RClick to be installed in /Applications" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Launch at login requires RClick to be installed in /Applications" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "General" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Para iniciar sesión automáticamente, RClick debe instalarse en /Applications" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "一般" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’ouverture à la connexion nécessite que RClick soit installé dans /Applications" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "通用" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログイン時に起動するには RClick を /Applications にインストールする必要があります" } - } - } - }, - "github.com/wflixu/RClick": { - "localizations": { - "es": { - "stringUnit": { - "state": "translated", - "value": "github.com/wflixu/RClick" + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开机启动需要将 RClick 安装到 /Applications 目录" } } } }, - "Grant Access": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Grant Access" + "Locates custom_menu.json in Finder so you can open it with the editor you prefer. Creates it first if it does not exist yet." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Locates custom_menu.json in Finder so you can open it with the editor you prefer. Creates it first if it does not exist yet." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Permitir acceso" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Localiza custom_menu.json en el Finder para que lo abras con el editor que prefieras. Si aún no existe, lo crea primero." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アクセスを許可" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Localise custom_menu.json dans le Finder pour que vous puissiez l'ouvrir avec l'éditeur de votre choix. Le crée d'abord s'il n'existe pas encore." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权访问" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "custom_menu.json を Finder で表示するので、お好みのエディタで開けます。存在しない場合は先に作成します。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在访达中定位 custom_menu.json,让你用自己习惯的编辑器打开。文件不存在时会先生成一份。" } } } }, - "Grant Permission": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Grant Permission" + "Logs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Otorgar permiso" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Journaux" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "権限を付与" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ログ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授予权限" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日志" } } } }, - "Grant RClick access to this folder to perform file operations.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Grant RClick access to this folder to perform file operations." + "Main Controls" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main Controls" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Otorga a RClick acceso a esta carpeta para realizar operaciones con archivos." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controles principales" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル操作を実行するために、RClick にこのフォルダへのアクセスを許可してください。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commandes principales" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权 RClick 访问此文件夹以执行文件操作。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メイン設定" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主要控制" } } } }, - "Hide": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Hide" + "Manage…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ocultar" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gérer…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "隠す" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "隐藏" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "管理…" } } } }, - "Home": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Home" + "Missing required field" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing required field" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpeta de inicio" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falta el campo obligatorio" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ホーム" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Champ obligatoire manquant" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用户主目录" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "必須フィールドがありません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缺少必填字段" } } } }, - "Icon": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Icon" + "More" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Icono" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アイコン" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "その他" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "图标" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多" } } } }, - "Ignore This Version": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Ignore This Version" + "Name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ignorar esta versión" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このバージョンを無視" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "忽略此版本" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称" } } } }, - "Import…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Import…" + "New File" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New File" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuevo archivo" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Importar…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouveau fichier" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "読み込み…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新規ファイル" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "导入…" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建文件" } } } }, - "Installation failed: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Installation failed: %@" + "New Version Available" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Version Available" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error al instalar: %@" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva versión disponible" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "インストールに失敗しました: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouvelle version disponible" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "安装失败:%@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新しいバージョンがあります" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发现新版本" } } } }, - "Invalid Folder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Invalid Folder" + "No .app application was found in the ZIP file." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No .app application was found in the ZIP file." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontró ninguna aplicación .app en el archivo ZIP." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpeta no válida" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune application .app n’a été trouvée dans le fichier ZIP." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "無効なフォルダ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZIP ファイル内に .app アプリケーションが見つかりません。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无效文件夹" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 ZIP 文件中未找到 .app 应用程序。" } } } }, - "Launch at login": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Launch at login" + "No .app.zip application package was found." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No .app.zip application package was found." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir al iniciar sesión" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se encontró ningún paquete de aplicación .app.zip." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログイン時に起動" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun paquet d’application .app.zip n’a été trouvé." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "登录时启动" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : ".app.zip 形式のアプリケーションパッケージが見つかりません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到 .app.zip 格式的应用程序包。" } } } }, - "Logs": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Logs" + "No folders authorized" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No folders authorized" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay carpetas autorizadas" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Registros" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun dossier autorisé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログ" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "認証されたフォルダはありません" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "日志" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未授权任何文件夹" } } } }, - "Main Controls": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Main Controls" + "No update is available." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No update is available." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Controles principales" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay actualizaciones disponibles." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メイン設定" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune mise à jour disponible." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "主要控制" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用可能なアップデートはありません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可用的更新。" } } } }, - "Manage…": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Manage…" + "Not Authorized" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Authorized" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin autorización" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Administrar…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non autorisé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "管理…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未許可" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "管理…" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未授权" } } } }, - "Name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Name" + "Not checked yet" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not checked yet" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nombre" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin comprobar" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名称" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas encore vérifié" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "名称" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未確認" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未检查" } } } }, - "New File": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New File" + "Not enabled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not enabled" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No activado" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nuevo archivo" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non activé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新規ファイル" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未有効" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新建文件" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未启用" } } } }, - "New Version Available": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Version Available" + "Notice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notice" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nueva versión disponible" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいバージョンがあります" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avis" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "发现新版本" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提示" } } } }, - "No .app application was found in the ZIP file.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No .app application was found in the ZIP file." + "OK" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se encontró ninguna aplicación .app en el archivo ZIP." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ZIP ファイル内に .app アプリケーションが見つかりません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在 ZIP 文件中未找到 .app 应用程序。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定" } } } }, - "No .app.zip application package was found.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No .app.zip application package was found." + "Open App Store" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open App Store" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se encontró ningún paquete de aplicación .app.zip." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir App Store" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": ".app.zip 形式のアプリケーションパッケージが見つかりません。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir l’App Store" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未找到 .app.zip 格式的应用程序包。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Store を開く" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 App Store" } } } }, - "No folders authorized": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No folders authorized" + "Open in a new window" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open in a new window" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir en una nueva ventana" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No hay carpetas autorizadas" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir dans une nouvelle fenêtre" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "認証されたフォルダはありません" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "新しいウィンドウで開く" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "尚未授权任何文件夹" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在新窗口中打开" } } } }, - "No update is available.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No update is available." + "Open Issues" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Issues" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No hay actualizaciones disponibles." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir issues" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "利用可能なアップデートはありません。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir les issues" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "没有可用的更新。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Issue を開く" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开 Issues" } } } }, - "Not Authorized": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not Authorized" + "Permissions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permissions" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permisos" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sin autorización" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisations" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未許可" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "権限" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未授权" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "权限" } } } }, - "Notice": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notice" + "Please select the correct Applications folder." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please select the correct Applications folder." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aviso" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona la carpeta Aplicaciones correcta." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "お知らせ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veuillez sélectionner le bon dossier Applications." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "提示" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "正しいアプリケーションフォルダを選択してください。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请选择正确的“应用程序”文件夹。" } } } }, - "OK": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "OK" + "PPTX" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "PPTX" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PPTX" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "OK" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "PPTX" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "OK" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "PowerPoint プレゼンテーション" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "确定" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "PowerPoint 演示文稿" } } } }, - "Open Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Settings" + "Preview:" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preview:" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir Ajustes" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vista previa:" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定を開く" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu :" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "打开设置" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プレビュー:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预览:" } } } }, - "Open With": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open With" + "Protected system folders cannot be deleted: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protected system folders cannot be deleted: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir con" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pueden eliminar las carpetas protegidas del sistema: %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用...打开" - } - } - } - }, - "Open With %@": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open With %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les dossiers système protégés ne peuvent pas être supprimés : %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir con %@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保護されたシステムフォルダは削除できません: %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用 %@ 打开" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法删除系统保护文件夹:%@" } } } }, - "Permissions": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Permissions" + "Protected system folders cannot be shared: %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protected system folders cannot be shared: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pueden compartir las carpetas protegidas del sistema: %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Permisos" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les dossiers système protégés ne peuvent pas être partagés : %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "権限" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保護されたシステムフォルダは共有できません: %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "权限" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法分享系统保护文件夹:%@" } } } }, - "Please select the correct Applications folder.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Please select the correct Applications folder." + "Quit" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quit" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Selecciona la carpeta Aplicaciones correcta." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salir" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "正しいアプリケーションフォルダを選択してください。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitter" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "请选择正确的“应用程序”文件夹。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "退出" } } } }, - "PPTX": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "PPTX" + "RClick" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "PPTX" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "PowerPoint 演示文稿" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick" } } } }, - "Preview:": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Preview:" + "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Vista previa:" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick es una extensión del menú contextual que te permite agregar aplicaciones para abrir carpetas e incluye algunas acciones frecuentes." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プレビュー:" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick est une extension du menu clic droit qui vous permet d’ajouter des applications pour ouvrir des dossiers et inclut quelques actions courantes." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "预览:" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick はフォルダを開くアプリを追加できる右クリックメニュー拡張で、いくつかの便利な操作も含まれています。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick 是一个右键菜单扩展,可以添加打开文件夹的应用,并提供一些常用操作。" } } } }, - "Protected system folders cannot be deleted: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Protected system folders cannot be deleted: %@" + "RClick is installed from the Mac App Store. Please update it from the App Store." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick is installed from the Mac App Store. Please update it from the App Store." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick se instaló desde la Mac App Store. Actualízalo desde la App Store." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pueden eliminar las carpetas protegidas del sistema: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick a été installé depuis le Mac App Store. Veuillez le mettre à jour depuis l’App Store." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保護されたシステムフォルダは削除できません: %@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick は Mac App Store からインストールされています。App Store からアップデートしてください。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法删除系统保护文件夹:%@" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick 是从 Mac App Store 安装的。请从 App Store 更新。" } } } }, - "Protected system folders cannot be shared: %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Protected system folders cannot be shared: %@" + "RClick needs access to this folder to delete, create, and modify files. Choose this folder or a broader parent folder to cover its subfolders. You can manage these grants in Settings → Folder Permissions." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick needs access to this folder to delete, create, and modify files. Choose this folder or a broader parent folder to cover its subfolders. You can manage these grants in Settings → Folder Permissions." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pueden compartir las carpetas protegidas del sistema: %@" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick necesita acceso a esta carpeta para eliminar, crear y modificar archivos. Elige esta carpeta o una carpeta principal más amplia para cubrir también sus subcarpetas. Puedes administrar estos permisos en Ajustes → Permisos de carpetas." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保護されたシステムフォルダは共有できません: %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick a besoin d’accéder à ce dossier pour supprimer, créer et modifier des fichiers. Choisissez ce dossier ou un dossier parent plus large pour couvrir ses sous-dossiers. Vous pouvez gérer ces autorisations dans Réglages → Autorisations de dossiers." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法分享系统保护文件夹:%@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick がこのフォルダ内のファイルを削除・作成・変更するにはアクセス権が必要です。このフォルダ、またはより上位のフォルダを選択するとサブフォルダもカバーされます。許可は設定 → フォルダ権限で管理できます。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick 需要访问此文件夹以删除、创建和修改文件。请选择此文件夹或更上层的父文件夹以覆盖其子文件夹。可在 设置 → 文件夹权限 中管理这些授权。" } } } }, - "Quit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Quit" + "RClick needs permission to install the update into your Applications folder." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick needs permission to install the update into your Applications folder." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick necesita permiso para instalar la actualización en tu carpeta Aplicaciones." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Salir" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick a besoin d’une autorisation pour installer la mise à jour dans votre dossier Applications." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "終了" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick がアップデートをアプリケーションフォルダにインストールするには権限が必要です。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "退出" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick 需要权限以将更新安装到您的“应用程序”文件夹中。" } } } }, - "RClick": { - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick" + "RClick on GitHub" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick on GitHub" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick en GitHub" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick sur GitHub" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "GitHub の RClick" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick 项目主页" } } } }, - "RClick (loading...)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick (loading...)" + "Reads the file again and puts it into effect. If it has errors the menu is left as it was." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reads the file again and puts it into effect. If it has errors the menu is left as it was." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick (cargando...)" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuelve a leer el archivo y lo aplica. Si contiene errores, el menú se deja tal como estaba." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick(読み込み中...)" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relit le fichier et l'applique. En cas d'erreur, le menu reste inchangé." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick(加载中...)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ファイルを読み直して反映します。エラーがある場合はメニューを変更しません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新读取文件并生效。如果文件有错误,菜单保持不变。" } } } }, - "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions!": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions!" + "Remove folder permission" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove folder permission" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar el permiso de la carpeta" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick es una extensión del menú contextual que te permite agregar aplicaciones para abrir carpetas e incluye algunas acciones frecuentes." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retirer l’autorisation du dossier" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 是一个右键菜单扩展,可以添加打开文件夹的应用,可以添加一些常用的操作!" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フォルダ権限を削除" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除文件夹权限" } } } }, - "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions." + "Reset" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick es una extensión del menú contextual que te permite agregar aplicaciones para abrir carpetas e incluye algunas acciones frecuentes." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick はフォルダを開くアプリを追加できる右クリックメニュー拡張で、いくつかの便利な操作も含まれています。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リセット" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 是一个右键菜单扩展,可以添加打开文件夹的应用,并提供一些常用操作。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置" } } } }, - "RClick needs Full Disk Access to create files, delete files, and manage hidden files in protected folders (like Desktop, Documents, and Downloads).\n\nWithout this permission, some operations may fail on protected directories.\n\nTo enable:\n1. Click \"Open Settings\" below\n2. Click the lock icon and authenticate\n3. Click \"+\" and add RClick from your Applications folder\n4. Toggle the switch next to RClick to ON": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs Full Disk Access to create files, delete files, and manage hidden files in protected folders (like Desktop, Documents, and Downloads).\n\nWithout this permission, some operations may fail on protected directories.\n\nTo enable:\n1. Click \"Open Settings\" below\n2. Click the lock icon and authenticate\n3. Click \"+\" and add RClick from your Applications folder\n4. Toggle the switch next to RClick to ON" + "Reset All Settings?" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset All Settings?" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita Acceso total al disco para crear archivos, eliminar archivos y administrar archivos ocultos en carpetas protegidas, como Escritorio, Documentos y Descargas.\n\nSin este permiso, algunas operaciones pueden fallar en los directorios protegidos.\n\nPara activarlo:\n1. Haz clic en “Abrir Ajustes” más abajo\n2. Haz clic en el icono del candado y autentícate\n3. Haz clic en “+” y agrega RClick desde tu carpeta Aplicaciones\n4. Activa el interruptor junto a RClick" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Restablecer todos los ajustes?" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がデスクトップ、書類、ダウンロードなどの保護されたフォルダでファイルの作成、削除、隠しファイルの管理を行うには、フルディスクアクセスが必要です。\n\nこの権限がない場合、保護されたディレクトリで一部の操作が失敗することがあります。\n\n有効にするには:\n1. 下の「設定を開く」をクリックします\n2. 鍵アイコンをクリックして認証します\n3. 「+」をクリックし、アプリケーションフォルダから RClick を追加します\n4. RClick の横にあるスイッチをオンにします" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser tous les réglages ?" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要完全磁盘访问权限,才能在受保护文件夹中创建文件、删除文件和管理隐藏文件,例如桌面、文稿和下载。\n\n没有此权限时,某些操作可能会在受保护目录中失败。\n\n启用方法:\n1. 点击下方“打开设置”\n2. 点击锁图标并认证\n3. 点击“+”,从“应用程序”文件夹添加 RClick\n4. 打开 RClick 旁边的开关" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべての設定をリセットしますか?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置所有设置?" } } } }, - "RClick needs Full Disk Access to work with files in protected directories": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs Full Disk Access to work with files in protected directories" + "Reset All Settings…" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset All Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer todos los ajustes…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita Acceso total al disco para trabajar con archivos en directorios protegidos" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser tous les réglages…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick が保護されたディレクトリ内のファイルを操作するにはフルディスクアクセスが必要です" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべての設定をリセット…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要完全磁盘访问权限才能正常操作受保护目录中的文件" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置所有设置…" } } } }, - "RClick needs permission to install the update into your Applications folder.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs permission to install the update into your Applications folder." + "Resetting all settings restores the default configuration and cannot be undone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resetting all settings restores the default configuration and cannot be undone" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita permiso para instalar la actualización en tu carpeta Aplicaciones." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer todos los ajustes restaura la configuración predeterminada y no se puede deshacer" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がアップデートをアプリケーションフォルダにインストールするには権限が必要です。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La réinitialisation de tous les réglages restaure la configuration par défaut et est irréversible" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要权限以将更新安装到您的“应用程序”文件夹中。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべての設定をリセットすると既定値に戻り、この操作は元に戻せません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置所有设置将恢复默认配置,此操作不可逆" } } } }, - "Remove folder permission": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove folder permission" + "Restart Later" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart Later" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar más tarde" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Quitar el permiso de la carpeta" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrer plus tard" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "フォルダ権限を削除" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "あとで再起動" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "移除文件夹权限" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "稍后重启" } } } }, - "Reset": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset" + "Restart Now" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restart Now" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restablecer" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar ahora" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リセット" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrer maintenant" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今すぐ再起動" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即重启" } } } }, - "Reset All Settings?": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset All Settings?" + "Restore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "¿Restablecer todos los ajustes?" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての設定をリセットしますか?" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "戻す" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置所有设置?" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复" } } } }, - "Reset All Settings…": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset All Settings…" + "Restore Default Layout" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore Default Layout" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restablecer todos los ajustes…" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar diseño predeterminado" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての設定をリセット…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer la disposition par défaut" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置所有设置…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトのレイアウトに戻す" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认…" } } } }, - "Resetting all settings restores the default configuration and cannot be undone": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Resetting all settings restores the default configuration and cannot be undone" + "Restore Defaults" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore Defaults" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar valores predeterminados" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restablecer todos los ajustes restaura la configuración predeterminada y no se puede deshacer" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rétablir les valeurs par défaut" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての設定をリセットすると既定値に戻り、この操作は元に戻せません" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトに戻す" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重置所有设置将恢复默认配置,此操作不可逆" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认" } } } }, - "Restart Later": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart Later" + "Restore the default layout?" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore the default layout?" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reiniciar más tarde" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Restaurar el diseño predeterminado?" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "あとで再起動" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer la disposition par défaut ?" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "稍后重启" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトのレイアウトに戻しますか?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认布局?" } } } }, - "Restart Now": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restart Now" + "Run Arguments" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Run Arguments" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumentos de ejecución" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reiniciar ahora" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arguments d’exécution" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "今すぐ再起動" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "実行引数" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "立即重启" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运行参数" } } } }, - "Restore Defaults": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Restore Defaults" + "Save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Restaurar valores predeterminados" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デフォルトに戻す" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "恢复默认" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" } } } }, - "Run Arguments": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Run Arguments" + "Separate multiple arguments with semicolons (;)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Separate multiple arguments with semicolons (;)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Separa varios argumentos con punto y coma (;)" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Argumentos de ejecución" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Séparez les arguments par des points-virgules (;)" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "実行引数" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "複数の引数はセミコロン(;)で区切ってください" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "运行参数" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "多个参数用分号(;)分隔" } } } }, - "Save": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Save" + "Settings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Guardar" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保存" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "保存" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" } } } }, - "Separate multiple arguments with semicolons (;)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Separate multiple arguments with semicolons (;)" + "Settings could not be saved" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings could not be saved" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo guardar la configuración" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Separa varios argumentos con punto y coma (;)" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'enregistrer les réglages" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "複数の引数はセミコロン(;)で区切ってください" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定を保存できませんでした" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "多个参数用分号(;)分隔" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置保存失败" } } } }, - "Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" + "Settings Management" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings Management" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ajustes" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración de ajustes" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestion des réglages" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定管理" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置管理" } } } }, - "Settings Management": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings Management" + "Settings…" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Administración de ajustes" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定管理" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置管理" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置…" } } } }, - "Settings…": { - "extractionState": "stale", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings…" + "SF Symbol Name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SF Symbol Name" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ajustes…" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre del SF Symbol" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "設定…" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom du symbole SF" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "设置…" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "SF Symbol 名" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SF Symbol 名称" } } } }, - "SF Symbol Name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol Name" + "Show icon in menu bar" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show icon in menu bar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar el icono en la barra de menús" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Nombre del SF Symbol" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher l’icône dans la barre des menus" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol 名" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メニューバーにアイコンを表示" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "SF Symbol 名称" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在菜单栏显示图标" } } } }, - "Show icon in menu bar": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Show icon in menu bar" + "Template" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Template" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mostrar el icono en la barra de menús" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plantilla" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "メニューバーにアイコンを表示" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modèle" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在菜单栏显示图标" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テンプレート" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模板" } } } }, - "Template": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Template" + "The application bundle is invalid or damaged." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The application bundle is invalid or damaged." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El paquete de la aplicación no es válido o está dañado." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Plantilla" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le paquet de l’application est non valide ou endommagé." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "テンプレート" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリケーションバンドルが無効または破損しています。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "模板" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用程序包无效或损坏。" } } } }, - "The application bundle is invalid or damaged.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The application bundle is invalid or damaged." + "The application has been updated successfully. Restart the app to finish the update." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The application has been updated successfully. Restart the app to finish the update." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "El paquete de la aplicación no es válido o está dañado." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリケーションバンドルが無効または破損しています。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’application a été mise à jour avec succès. Redémarrez l’app pour terminer la mise à jour." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用程序包无效或损坏。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリケーションは正常に更新されました。更新を完了するにはアプリを再起動してください。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用程序已成功更新。需要重启应用来完成更新过程。" } } } }, - "The application has been updated successfully. Restart the app to finish the update.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The application has been updated successfully. Restart the app to finish the update." + "The current folder cannot be deleted. Please select files or subfolders instead." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The current folder cannot be deleted. Please select files or subfolders instead." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede eliminar la carpeta actual. En su lugar, selecciona archivos o subcarpetas." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le dossier actuel ne peut pas être supprimé. Sélectionnez plutôt des fichiers ou des sous-dossiers." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アプリケーションは正常に更新されました。更新を完了するにはアプリを再起動してください。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在のフォルダは削除できません。代わりにファイルまたはサブフォルダを選択してください。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "应用程序已成功更新。需要重启应用来完成更新过程。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法删除当前文件夹,请选择文件或子文件夹进行删除。" } } } }, - "The current folder cannot be deleted. Please select files or subfolders instead.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The current folder cannot be deleted. Please select files or subfolders instead." + "The current folder cannot be shared. Please select files or subfolders instead." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The current folder cannot be shared. Please select files or subfolders instead." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se puede eliminar la carpeta actual. En su lugar, selecciona archivos o subcarpetas." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede compartir la carpeta actual. En su lugar, selecciona archivos o subcarpetas." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現在のフォルダは削除できません。代わりにファイルまたはサブフォルダを選択してください。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le dossier actuel ne peut pas être partagé. Sélectionnez plutôt des fichiers ou des sous-dossiers." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法删除当前文件夹,请选择文件或子文件夹进行删除。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在のフォルダは共有できません。代わりにファイルまたはサブフォルダを選択してください。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法共享当前文件夹,请选择文件或子文件夹进行共享。" } } } }, - "The current folder cannot be shared. Please select files or subfolders instead.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The current folder cannot be shared. Please select files or subfolders instead." + "The current version is already up to date." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The current version is already up to date." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La versión actual ya está actualizada." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se puede compartir la carpeta actual. En su lugar, selecciona archivos o subcarpetas." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La version actuelle est déjà à jour." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現在のフォルダは共有できません。代わりにファイルまたはサブフォルダを選択してください。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在のバージョンはすでに最新です。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法共享当前文件夹,请选择文件或子文件夹进行共享。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前版本已是最新,无需更新。" } } } }, - "The current version is already up to date.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The current version is already up to date." + "The JSON file is malformed." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The JSON file is malformed." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "La versión actual ya está actualizada." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El archivo JSON está mal formado." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "現在のバージョンはすでに最新です。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le fichier JSON est mal formé." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "当前版本已是最新,无需更新。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON ファイルの形式が正しくありません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 文件格式有误。" } } } }, - "The selected folder is a subfolder of an already selected folder. Please choose a different folder.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The selected folder is a subfolder of an already selected folder. Please choose a different folder." + "The menu is unchanged." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The menu is unchanged." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El menú no ha cambiado." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "La carpeta seleccionada es una subcarpeta de otra que ya elegiste. Elige una carpeta diferente." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le menu reste inchangé." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "選択したフォルダは既に選択済みのフォルダのサブフォルダです。別のフォルダを選択してください。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メニューは変更されていません。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "所选文件夹是已选文件夹的子目录,请选择其他文件夹。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "菜单保持不变。" } } } }, - "The user cancelled authorization.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The user cancelled authorization." + "The selected folder is a subfolder of an already selected folder. Please choose a different folder." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The selected folder is a subfolder of an already selected folder. Please choose a different folder." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "El usuario canceló la autorización." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La carpeta seleccionada es una subcarpeta de otra que ya elegiste. Elige una carpeta diferente." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ユーザーが認証をキャンセルしました。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le dossier sélectionné est un sous-dossier d’un dossier déjà sélectionné. Veuillez choisir un autre dossier." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "用户取消了授权。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "選択したフォルダは既に選択済みのフォルダのサブフォルダです。別のフォルダを選択してください。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所选文件夹是已选文件夹的子目录,请选择其他文件夹。" } } } }, - "This will delete all custom configurations and restore the defaults. This action cannot be undone.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This will delete all custom configurations and restore the defaults. This action cannot be undone." + "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." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se eliminarán todas las configuraciones personalizadas y se restaurarán los valores predeterminados. Esta acción no se puede deshacer." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le dossier de configuration partagé est indisponible." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この操作によりすべてのカスタム設定が削除され、既定値に戻ります。元に戻すことはできません。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "共有設定フォルダを利用できません。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "此操作将删除所有自定义配置,恢复到默认状态。此操作不可逆。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "共享配置文件夹不可用。" } } } }, - "Unauthorized Folder": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unauthorized Folder" + "The user cancelled authorization." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The user cancelled authorization." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Carpeta sin autorización" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El usuario canceló la autorización." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "未承認のフォルダ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’utilisateur a annulé l’autorisation." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未授权文件夹" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユーザーが認証をキャンセルしました。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用户取消了授权。" } } } }, - "Unhide": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unhide" + "This deletes custom_menu.json and returns to the default menu. A copy is kept as custom_menu.backup.json." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This deletes custom_menu.json and returns to the default menu. A copy is kept as custom_menu.backup.json." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esto elimina custom_menu.json y vuelve al menú predeterminado. Se guarda una copia como custom_menu.backup.json." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mostrar" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cela supprime custom_menu.json et revient au menu par défaut. Une copie est conservée sous custom_menu.backup.json." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再表示" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "custom_menu.json を削除してデフォルトのメニューに戻します。コピーは custom_menu.backup.json として保存されます。" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "显示" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这将删除 custom_menu.json 并恢复默认菜单。副本会保留为 custom_menu.backup.json。" } } } }, - "Unknown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unknown" + "This will delete all custom configurations and restore the defaults. This action cannot be undone." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This will delete all custom configurations and restore the defaults. This action cannot be undone." } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Desconocido" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se eliminarán todas las configuraciones personalizadas y se restaurarán los valores predeterminados. Esta acción no se puede deshacer." } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "不明" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toutes les configurations personnalisées seront supprimées et les valeurs par défaut rétablies. Cette action est irréversible." } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未知" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この操作によりすべてのカスタム設定が削除され、既定値に戻ります。元に戻すことはできません。" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此操作将删除所有自定义配置,恢复到默认状态。此操作不可逆。" } } } }, - "Unknown error": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unknown error" + "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ú" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error desconocido" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d’ouvrir la configuration du menu" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "不明なエラー" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メニュー設定ファイルを開けません" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未知错误" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开配置文件夹" } } } }, - "Untitled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Untitled" + "Unable to Restore Default Layout" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to Restore Default Layout" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sin título" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede restaurar el diseño predeterminado" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名称未設定" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de restaurer la disposition par défaut" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未命名" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトのレイアウトに戻せません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法恢复默认布局" } } } }, - "Up to Date": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Up to Date" + "Unauthorized Folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unauthorized Folder" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta sin autorización" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Todo actualizado" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dossier non autorisé" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "最新です" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未承認のフォルダ" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已是最新版本" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未授权文件夹" } } } }, - "Update Installed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Update Installed" + "Unavailable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unavailable" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Actualización instalada" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No disponible" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アップデートがインストールされました" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indisponible" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更新安装完成" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "利用できません" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不可用" } } } }, - "Version %@": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Version %@" + "Unhide" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unhide" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Versión %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バージョン %@" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再表示" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "版本 %@" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示" } } } }, - "Version %@ (%@)": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Version %@ (%@)" + "Unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Versión %@ (%@)" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconocido" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バージョン %@ (%@)" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inconnu" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "版本 %@ (%@)" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" } } } }, - "Version %@ is ignored": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Version %@ is ignored" + "Unknown error" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown error" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error desconocido" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se ignoró la versión %@" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur inconnue" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "バージョン %@ は無視されています" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明なエラー" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已忽略版本 %@" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知错误" } } } }, - "Want to add a feature? Give feedback here.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Want to add a feature? Give feedback here." + "Untitled" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untitled" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin título" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sans titre" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "¿Quieres proponer una función? Envía tus comentarios aquí." + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称未設定" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "需要添加新功能,在这里反馈" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未命名" } } } }, - "Warning": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Warning" + "Up to Date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Up to Date" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Advertencia" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo actualizado" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "警告" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À jour" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "警告" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新です" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已是最新版本" } } } }, - "Launch at login is pending approval in System Settings": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Launch at login is pending approval in System Settings" + "Update Installed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Installed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización instalada" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Iniciar sesión automáticamente está pendiente de aprobación en Ajustes del Sistema" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise à jour installée" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログイン時に起動するにはシステム設定での承認が必要です" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートがインストールされました" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "开机启动需在系统设置中批准" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新安装完成" } } } }, - "Launch at login requires RClick to be installed in /Applications": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Launch at login requires RClick to be installed in /Applications" + "Update via Mac App Store" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update via Mac App Store" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Para iniciar sesión automáticamente, RClick debe instalarse en /Applications" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizar desde la Mac App Store" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ログイン時に起動するには RClick を /Applications にインストールする必要があります" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour via le Mac App Store" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "开机启动需要将 RClick 安装到 /Applications 目录" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac App Store からアップデート" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过 Mac App Store 更新" } } } }, - "Update via Mac App Store": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Update via Mac App Store" + "Using the default layout" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Using the default layout" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usando el diseño predeterminado" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Actualizar desde la Mac App Store" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disposition par défaut utilisée" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Mac App Store からアップデート" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトレイアウトを使用中" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "通过 Mac App Store 更新" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在使用默认布局" } } } }, - "RClick is installed from the Mac App Store. Please update it from the App Store.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick is installed from the Mac App Store. Please update it from the App Store." + "v%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick se instaló desde la Mac App Store. Actualízalo desde la App Store." + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick は Mac App Store からインストールされています。App Store からアップデートしてください。" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 是从 Mac App Store 安装的。请从 App Store 更新。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@" } } } }, - "Open App Store": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open App Store" + "Version %@" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir App Store" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "App Store を開く" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン %@" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "打开 App Store" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "版本 %@" } } } }, - "Later": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Later" + "Version %@ (%@)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@ (%@)" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Más tarde" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %@ (%@)" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "後で" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@ (%@)" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "稍后" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン %@ (%@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "版本 %@ (%@)" } } } }, - "RClick needs access to this folder to delete, create, and modify files. Choose this folder or a broader parent folder to cover its subfolders. You can manage these grants in Settings → Folder Permissions.": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "RClick needs access to this folder to delete, create, and modify files. Choose this folder or a broader parent folder to cover its subfolders. You can manage these grants in Settings → Folder Permissions." + "Version %@ is ignored" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version %@ is ignored" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ignoró la versión %@" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "RClick necesita acceso a esta carpeta para eliminar, crear y modificar archivos. Elige esta carpeta o una carpeta principal más amplia para cubrir también sus subcarpetas. Puedes administrar estos permisos en Ajustes → Permisos de carpetas." + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La version %@ est ignorée" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "RClick がこのフォルダ内のファイルを削除・作成・変更するにはアクセス権が必要です。このフォルダ、またはより上位のフォルダを選択するとサブフォルダもカバーされます。許可は設定 → フォルダ権限で管理できます。" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "バージョン %@ は無視されています" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "RClick 需要访问此文件夹以删除、创建和修改文件。请选择此文件夹或更上层的父文件夹以覆盖其子文件夹。可在 设置 → 文件夹权限 中管理这些授权。" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已忽略版本 %@" } } } }, - "Authorizes this folder and all its subfolders": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authorizes this folder and all its subfolders" + "View Details…" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View Details…" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Autoriza esta carpeta y todas sus subcarpetas" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver detalles…" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このフォルダとそのすべてのサブフォルダを許可します" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir les détails…" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "授权此文件夹及其所有子文件夹" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細を表示…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看详情…" } } } }, - "Open in a new window": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open in a new window" + "Warning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advertencia" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir en una nueva ventana" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avertissement" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいウィンドウで開く" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "警告" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在新窗口中打开" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "警告" } } } }, - "Window": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Window" + "Window" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Window" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Ventana" + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ventana" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ウィンドウ" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fenêtre" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "窗口" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ウィンドウ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "窗口" } } } }, - "For browser-like apps, launch a new instance instead of opening a new tab in the running one": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "For browser-like apps, launch a new instance instead of opening a new tab in the running one" + "XLSX" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "XLSX" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoja de cálculo de Excel" } }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Para apps tipo navegador, lanza una nueva instancia en lugar de abrir una pestaña en la ya abierta" + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feuille de calcul Excel" } }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ブラウザ系アプリの場合、起動中のインスタンスに新しいタブを開く代わりに、新しいインスタンスを起動します" + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Excel ワークシート" } }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "对浏览器类 app,改为启动新实例,而不是在已运行的实例中新开标签页" + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Excel 表格" } } } } }, - "version": "1.0" -} + "version" : "1.0" +} \ No newline at end of file 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/CustomMenuDiagnostics.swift b/RClick/Runtime/CustomMenuDiagnostics.swift new file mode 100644 index 0000000..3267e6a --- /dev/null +++ b/RClick/Runtime/CustomMenuDiagnostics.swift @@ -0,0 +1,80 @@ +// +// CustomMenuDiagnostics.swift +// RClick +// +// 把自定义菜单的加载失败翻译成用户能据以行动的一句话。 +// + +import Foundation + +/// Turns custom menu load failures into something the user can act on. +/// +/// Only the wrapper sentences are localized. The parts that carry the actual +/// diagnosis - a JSON position, or the reference that failed to match - are +/// appended verbatim: they are the precise bit, and they are also what the +/// "hand the file to an assistant" workflow in examples/README.md relies on. +enum CustomMenuDiagnostics { + + /// `DecodingError.Context.underlyingError` stores the JSON parser's line and + /// column readout under this key. There is no Swift constant for it. + private static let debugDescriptionKey = "NSDebugDescription" + + static func message(for error: any Error) -> String { + switch error { + case let error as CustomMenuError: + // Already a full, specific sentence from the layout validator. + return error.description + case let error as DecodingError: + return decodingMessage(for: error) + default: + // CocoaError and friends: the system already produces a localized, + // accurate sentence ("...you don't have permission to view it."), + // which beats anything we would hand-write here. + return error.localizedDescription + } + } + + // MARK: - Decoding failures + + private static func decodingMessage(for error: DecodingError) -> String { + switch error { + case let .keyNotFound(key, context): + return located(context) + AppLocalization.localized("Missing required field") + " \"\(key.stringValue)\"" + case let .typeMismatch(_, context), let .valueNotFound(_, context): + return located(context) + AppLocalization.localized("Invalid value") + ": " + context.debugDescription + case let .dataCorrupted(context): + // A malformed document has no coding path; the parser's line/column + // lives in the underlying error instead. + guard !context.codingPath.isEmpty else { return syntaxMessage(context) } + return located(context) + AppLocalization.localized("Invalid value") + ": " + context.debugDescription + @unknown default: + return AppLocalization.localized("The JSON file is malformed.") + } + } + + private static func syntaxMessage(_ context: DecodingError.Context) -> String { + let base = AppLocalization.localized("The JSON file is malformed.") + guard let nsError = context.underlyingError as? NSError, + let detail = nsError.userInfo[debugDescriptionKey] as? String + else { return base } + // Already reads "around line 5, column 3" and is localized by the system. + return base + " " + detail + } + + /// Renders a coding path as a 1-based menu item path. + /// + /// JSONDecoder only emits integer keys for array positions, so `intValue` + /// reliably identifies the element index and everything else is a field name. + /// `[Index 1, "title"]` becomes `[2] title `, `[Index 1, Index 0]` becomes `[2.1]`. + private static func located(_ context: DecodingError.Context) -> String { + var indices: [Int] = [] + var field: String? + for key in context.codingPath { + if let index = key.intValue { indices.append(index + 1) } else { field = key.stringValue } + } + guard !indices.isEmpty else { + return field.map { "\"\($0)\" " } ?? "" + } + return "[" + indices.map(String.init).joined(separator: ".") + "]" + (field.map { " \($0) " } ?? " ") + } +} diff --git a/RClick/Runtime/MenuService.swift b/RClick/Runtime/MenuService.swift index 656b257..9a35e7c 100644 --- a/RClick/Runtime/MenuService.swift +++ b/RClick/Runtime/MenuService.swift @@ -7,27 +7,133 @@ // import Foundation +import OSLog + +/// What the settings page reports about the custom menu. +/// +/// Two axes rather than one, because the menu and the file can disagree: an +/// external editor can change the file without it taking effect, and a change in +/// Settings can invalidate an applied file without the file itself changing. +struct CustomMenuStatus: Equatable { + /// What the Finder menu is rendering right now. + enum Applied: Equatable { + /// The App Group container is unavailable (signing or entitlement problem). + case unavailable + /// The default categorized layout. Not a problem. + case defaultLayout + /// An empty array is in effect: an intentionally empty menu. Not a problem. + case empty + /// In effect, carrying the number of top-level entries. + case custom(topLevelItems: Int) + } + + /// How the file on disk relates to what is in effect. + enum Notice: Equatable { + /// Nothing to report. + case none + /// Edited since it was applied, so the menu still shows the old layout. + case edited + /// Unusable: it does not parse, or no longer matches the enabled items. + case broken(reason: String) + } + + var applied: Applied + var notice: Notice + + /// Whether this state is worth flagging as something going wrong. + /// + /// `.edited` is deliberately not one: an edit waiting to be applied is the + /// normal working state this whole screen is built around, not a fault. + var isProblem: Bool { + if applied == .unavailable { return true } + if case .broken = notice { return true } + return false + } +} @MainActor final class MenuService { /// 菜单版本号(防重复 / 防乱序) private var menuVersion = 0 - /// 最近一次构建的配置快照(编码后,供诊断/兜底) - private var lastMenuSnapshot: Data? - var lastSnapshot: Data? { lastMenuSnapshot } + /// Bytes of the custom menu currently in effect; `nil` means the default layout. + /// + /// Raw bytes rather than resolved nodes, because `AppState` keeps moving + /// underneath: disabling an app, or turning common folders off, empties part of + /// the payload and the layout has to be re-resolved against it. Bytes also turn + /// "has the file been edited since?" into a comparison instead of a re-read. + private var appliedCustomMenuData: Data? - /// 从 AppState 实时构建菜单配置 - func buildConfig(from state: AppState) -> MenuConfigPayload { + /// Separate from the data being `nil`, because "nothing is applied" must not + /// re-read the disk on every one of the three build triggers. + private var hasLoadedAppliedCustomMenu = false + + /// Copy kept beside the live file when the custom layout is removed. + static let customMenuBackupName = "custom_menu.backup.json" + + /// The file the menu is built from. `nil` means there is no container at all, + /// so the default layout always applies. + /// + /// Explicitly injectable so tests can point at a temporary file: nothing in this + /// type may reach the real App Group container while a test runs. There is + /// deliberately no default argument, because `nil` is a meaningful value here + /// and would otherwise be indistinguishable from "use the real container". + let customMenuURL: URL? + + init(customMenuURL: URL?) { + self.customMenuURL = customMenuURL + } + + /// The real location, in the shared App Group container. + static var defaultCustomMenuURL: 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) + })) + } + 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 + } + } + + /// Builds the payload from `AppState` without touching the version counter or + /// the disk. + /// + /// Status checks use this so that merely inspecting the custom menu cannot + /// perturb the configuration that gets sent to the extension. + static func makePayload(from state: AppState, version: Int = 0) -> MenuConfigPayload { let actionMenuItems = state.actions.filter(\.enabled).map { $0.toActionMenuItem() } let appMenuItems = state.apps.map { $0.toAppMenuItem() } - let newFileMenuItems = state.newFiles.filter(\.enabled).map { NewFileMenuItem(id: $0.id, name: $0.name, ext: $0.ext, icon: $0.icon) } + let newFileMenuItems = state.newFiles.filter(\.enabled).map { NewFileMenuItem(id: $0.id, name: $0.displayName, ext: $0.ext, icon: $0.icon) } let commonDirMenuItems = state.showCommonDirs ? state.cdirs.map { CommonDirMenuItem(id: $0.id, name: $0.displayName, icon: $0.icon, url: $0.url.path) } : [] - menuVersion += 1 - - let config = MenuConfigPayload( - version: menuVersion, + return MenuConfigPayload( + version: version, actions: actionMenuItems, apps: appMenuItems, newFiles: newFileMenuItems, @@ -37,8 +143,173 @@ final class MenuService { newFilesCollapsed: state.foldNewFileMenu, commonDirsCollapsed: state.foldCommonDirMenu ) + } - lastMenuSnapshot = try? JSONEncoder().encode(config) + /// 从 AppState 实时构建菜单配置 + func buildConfig(from state: AppState) -> MenuConfigPayload { + menuVersion += 1 + var config = Self.makePayload(from: state, version: menuVersion) + config.customMenu = customMenu(for: config) return config } + + /// The custom layout to render for `config`, or `nil` for the default one. + /// + /// Takes the payload rather than reading `AppState`, so tests can drive it + /// against a temporary file instead of the real SwiftData store. + func customMenu(for config: MenuConfigPayload) -> [MenuNode]? { + guard let data = currentAppliedCustomMenuData() else { return nil } + do { + return try CustomMenu.decode(data, config: config) + } catch { + // Fall back to the default layout, but keep the reason readable in the + // log. `.private` because messages can quote user paths. + Logger(subsystem: "RClick", category: "MenuService") + .error("Invalid custom_menu.json; using default menu: \(CustomMenuDiagnostics.message(for: error), privacy: .private)") + return nil + } + } + + /// The bytes to render from, or `nil` for the default layout. + /// + /// The file is read only on first use and when the user applies it. Edits made + /// in an external editor are deliberately ignored until then, so a + /// half-written file cannot take the menu down mid-edit. Deleting the file is + /// the one exception: that is unambiguous and non-transient, so it still means + /// "back to the default layout" right away. + private func currentAppliedCustomMenuData() -> Data? { + ensureAppliedCustomMenuLoaded() + guard let url = customMenuURL, + FileManager.default.fileExists(atPath: url.path) else { return nil } + return appliedCustomMenuData + } + + // MARK: - Custom menu status + + /// Reports what the custom menu means for this installation right now. + /// + /// Takes the applied snapshot as a parameter rather than reading the property, + /// so tests can drive it without an App Group container. + static func customMenuStatus(at url: URL?, appliedData: Data?, config: MenuConfigPayload) -> CustomMenuStatus { + guard let url else { return CustomMenuStatus(applied: .unavailable, notice: .none) } + + let fileData: Data + do { + fileData = try Data(contentsOf: url) + } catch CocoaError.fileReadNoSuchFile { + // No file, so nothing is in effect and there is nothing to apply. + return CustomMenuStatus(applied: .defaultLayout, notice: .none) + } catch { + // Unreadable for some other reason - a folder where a file was expected, + // say. That is not a missing file, and sending the user off to fix the + // JSON syntax of something that has none would be a wrong turn. + return CustomMenuStatus(applied: .defaultLayout, + notice: .broken(reason: CustomMenuDiagnostics.message(for: error))) + } + + // The snapshot only counts while the file is still there: deleting the file + // is the documented way back to the default layout. + var appliedReason: String? + let applied: CustomMenuStatus.Applied + if let appliedData { + switch resolve(appliedData, config: config) { + case .unusable(let reason): + applied = .defaultLayout + appliedReason = reason + case .usable(let nodes): + applied = nodes.isEmpty ? .empty : .custom(topLevelItems: nodes.count) + } + } else { + applied = .defaultLayout + } + + switch resolve(fileData, config: config) { + case .unusable(let reason): + return CustomMenuStatus(applied: applied, notice: .broken(reason: reason)) + case .usable: + guard fileData == appliedData else { + return CustomMenuStatus(applied: applied, notice: .edited) + } + // Same bytes. If they no longer resolve, the menu fell back anyway and + // this reason is the only explanation the user is going to get. + return CustomMenuStatus(applied: applied, + notice: appliedReason.map { .broken(reason: $0) } ?? .none) + } + } + + func customMenuStatus(from state: AppState) -> CustomMenuStatus { + ensureAppliedCustomMenuLoaded() + return Self.customMenuStatus(at: customMenuURL, + appliedData: appliedCustomMenuData, + config: Self.makePayload(from: state)) + } + + /// Deliberately not named `.none`: that would shadow `Optional.none` and make + /// every use site ambiguous. + private enum Resolution { + case unusable(reason: String) + case usable([MenuNode]) + } + + private static func resolve(_ data: Data, config: MenuConfigPayload) -> Resolution { + do { + return .usable(try CustomMenu.decode(data, config: config)) + } catch { + return .unusable(reason: CustomMenuDiagnostics.message(for: error)) + } + } + + // MARK: - Custom menu application + + /// Re-reads the file, validates it against `config`, and only then makes it the + /// layout in effect. + /// + /// Throws without touching the snapshot, so a bad edit leaves the menu exactly + /// as it was instead of falling back to the default layout behind the user's back. + func applyCustomMenu(at url: URL, config: MenuConfigPayload) throws { + let data = try Data(contentsOf: url) + // Validate against the payload the extension will actually receive, so a + // reference that no longer matches enabled items is rejected here instead + // of quietly dropping out of the menu. + _ = try CustomMenu.decode(data, config: config) + appliedCustomMenuData = data + hasLoadedAppliedCustomMenu = true + } + + /// Applies whatever is at `customMenuURL`, or throws when there is no container. + func applyCustomMenu(config: MenuConfigPayload) throws { + guard let url = customMenuURL else { + throw CustomMenuError.containerUnavailable + } + try applyCustomMenu(at: url, config: config) + } + + func applyCustomMenu(from state: AppState) throws { + try applyCustomMenu(config: Self.makePayload(from: state)) + } + + /// Forgets the applied layout so a stale snapshot cannot bring it back. + func discardAppliedCustomMenu() { + appliedCustomMenuData = nil + hasLoadedAppliedCustomMenu = true + } + + private func ensureAppliedCustomMenuLoaded() { + guard !hasLoadedAppliedCustomMenu else { return } + hasLoadedAppliedCustomMenu = true + guard let url = customMenuURL else { return } + appliedCustomMenuData = try? Data(contentsOf: url) + } + + // MARK: - Custom menu removal + + /// Deletes the custom layout, keeping a copy so the action stays reversible. + static func removeCustomMenu(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path) else { return } + let backup = url.deletingLastPathComponent().appendingPathComponent(customMenuBackupName) + try? FileManager.default.removeItem(at: backup) + // A failed backup must never block a request the user explicitly made. + try? FileManager.default.copyItem(at: url, to: backup) + try FileManager.default.removeItem(at: url) + } } diff --git a/RClick/Runtime/RCRuntime.swift b/RClick/Runtime/RCRuntime.swift index 7771c6c..869efe5 100644 --- a/RClick/Runtime/RCRuntime.swift +++ b/RClick/Runtime/RCRuntime.swift @@ -29,7 +29,7 @@ final class RCRuntime { self.state = AppState.shared self.configService = state.configService self.permissionService = PermissionService(bookmarkManager: state.bookmarkManager) - self.menuService = MenuService() + self.menuService = MenuService(customMenuURL: MenuService.defaultCustomMenuURL) self.actionService = ActionService(state: state, permission: permissionService) self.messager = Messager.shared } diff --git a/RClick/Settings/AboutSettingsTabView.swift b/RClick/Settings/AboutSettingsTabView.swift index b7f315d..59453c7 100644 --- a/RClick/Settings/AboutSettingsTabView.swift +++ b/RClick/Settings/AboutSettingsTabView.swift @@ -1,64 +1,193 @@ // -// AdvancedSettingsView.swift +// AboutSettingsTabView.swift // RClick // // Created by 李旭 on 2024/4/4. // -import AppKit -import ExtensionFoundation -import ExtensionKit -import FinderSync +import Foundation import SwiftUI struct AboutSettingsTabView: View { - let messager = Messager.shared @EnvironmentObject var updateManager: UpdateManager var body: some View { Form { - Section { - VStack(spacing: 12) { - Image("Logo") - .resizable() - .frame(width: 96, height: 96) - - VStack(spacing: 4) { - Text("RClick").font(.title) - Text(String(format: AppLocalization.localized("Version %@ (%@)"), getAppVersion(), getBuildVersion())) - .foregroundColor(.secondary) - } + identitySection + updateSection + communitySection + } + .formStyle(.grouped) + // 带缓存,缓存新鲜时这里只是读一下 UserDefaults,不会真的发请求 + .task { await updateManager.loadStarCount() } + } + + // MARK: - 应用标识 + + /// 图标、名字、版本、简介同处一个居中的块。 + /// + /// 简介原先单独占一个 Section,分组样式会在两者之间画一个框、留一段空隙, + /// 加上一个居中一个左对齐,看起来就是两截互不相干的内容。 + private var identitySection: some View { + Section { + VStack(spacing: 12) { + Image("Logo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 96, height: 96) + + VStack(spacing: 4) { + Text("RClick") + .font(.title2) + .fontWeight(.semibold) + Text(String(format: AppLocalization.localized("Version %@ (%@)"), getAppVersion(), getBuildVersion())) + .font(.caption) + .foregroundStyle(.secondary) } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) - } - Section { Text(appLocalized: "RClick is a right-click menu extension that allows you to add applications for opening folders and includes some common actions.") - .font(.body) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 360) } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + } - Section { - HStack { - Button(AppLocalization.localized("Check for Updates")) { - Task { - await updateManager.checkForUpdates(force: true) - } + // MARK: - 更新 + + /// 更新状态行。 + /// + /// 沿用 General 里「状态行」的写法(见 `GeneralSettingsTabView` 的 Finder 扩展那一行): + /// 左边是带状态色的名称,右边是状态说明 + 操作按钮。原先这里只有一个光秃秃的按钮 + /// 单独占一段,上下都是空白,所以显得突兀——按钮旁边永远有说明它当前状态的文字, + /// 它才成为一句话的结尾,而不是一个孤立的控件。 + private var updateSection: some View { + Section { + LabeledContent { + HStack(spacing: 8) { + if let detail = updateStatus.detail { + Text(detail) + .foregroundStyle(.secondary) } if updateManager.isChecking { - ProgressView() - .scaleEffect(0.8) + ProgressView().controlSize(.small) + } + Button(updateStatus.actionTitle) { + switch updateStatus.action { + case .check: + Task { await updateManager.checkForUpdates(force: true) } + case .details: + // 只开弹窗看上次的结果,不重新发起检查 + updateManager.showUpdateDetails() + } + } + .disabled(updateManager.isChecking) + } + } label: { + Label(updateStatus.title, systemImage: updateStatus.icon) + .foregroundStyle(updateStatus.color) + } + } + } + + /// 更新区一行的全部推导集中在这里,视图只读结果,不在 body 里散落一串 if。 + /// + /// 判定顺序和更新弹窗保持一致:检查中 → 有新版本 → 出错 → 已是最新 → 未检查。 + private struct UpdateStatus { + enum Action { case check, details } + + let title: String + let icon: String + let color: Color + var detail: String? = nil + let actionTitle: String + let action: Action + } + + private var updateStatus: UpdateStatus { + if updateManager.isChecking { + return UpdateStatus( + title: AppLocalization.localized("Checking for updates..."), + icon: "arrow.triangle.2.circlepath", + color: .secondary, + actionTitle: AppLocalization.localized("Check for Updates"), + action: .check + ) + } + if let release = updateManager.availableUpdate { + return UpdateStatus( + title: AppLocalization.localized("New Version Available"), + icon: "arrow.down.circle.fill", + color: .blue, + detail: String(format: AppLocalization.localized("Version %@"), release.version), + actionTitle: AppLocalization.localized("View Details…"), + action: .details + ) + } + if updateManager.updateError != nil { + // 只给短句,具体原因留在弹窗里,免得长句把这一行挤变形 + return UpdateStatus( + title: AppLocalization.localized("Failed to Check for Updates"), + icon: "exclamationmark.triangle.fill", + color: .yellow, + actionTitle: AppLocalization.localized("Check for Updates"), + action: .check + ) + } + if updateManager.isUpToDate { + return UpdateStatus( + title: AppLocalization.localized("Up to Date"), + icon: "checkmark.circle.fill", + color: .green, + actionTitle: AppLocalization.localized("Check for Updates"), + action: .check + ) + } + return UpdateStatus( + title: AppLocalization.localized("Not checked yet"), + icon: "arrow.triangle.2.circlepath", + color: .secondary, + actionTitle: AppLocalization.localized("Check for Updates"), + action: .check + ) + } + + // MARK: - 项目与反馈 + + /// 两行:去项目看看 + 反馈问题。 + /// + /// 原先只有一行裸地址,既没说明项目有没有人在用,也没给任何可做的事。现在每行左边是 + /// 去处、右边是动作。星数由 `loadStarCount` 带缓存地取,取不到就只少一个数字, + /// 不占位也不报错。 + private var communitySection: some View { + Section { + LabeledContent { + HStack(spacing: 8) { + if let stars = updateManager.starCount { + // 用系统的紧凑数字格式,中文会自动变成「1.2万」而不是「1.2K」 + Text(stars.formatted(.number.notation(.compactName))) + .foregroundStyle(.secondary) + } + Button(AppLocalization.localized("Add a Star")) { + updateManager.openRepositoryPage() } } + } label: { + Label(AppLocalization.localized("RClick on GitHub"), image: "github") } - Section { - Link(destination: URL(string: "https://github.com/wflixu/RClick")!) { - Label("github.com/wflixu/RClick", image: "github") + LabeledContent { + Button(AppLocalization.localized("Open Issues")) { + updateManager.openIssuesPage() } + } label: { + Label(AppLocalization.localized("Feedback & Issues"), systemImage: "exclamationmark.bubble") } } - .formStyle(.grouped) } func getAppVersion() -> String { @@ -78,4 +207,5 @@ struct AboutSettingsTabView: View { #Preview { AboutSettingsTabView() + .environmentObject(UpdateManager(owner: "wflixu", repo: "RClick", currentVersion: "1.0.0")) } diff --git a/RClick/Settings/CommonDirsSettingTabView.swift b/RClick/Settings/CommonDirsSettingTabView.swift index 7eb72f0..faebf6b 100644 --- a/RClick/Settings/CommonDirsSettingTabView.swift +++ b/RClick/Settings/CommonDirsSettingTabView.swift @@ -31,7 +31,7 @@ struct CommonDirsSettingTabView: View { NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) } Toggle(isOn: $store.foldCommonDirMenu) { - Text(appLocalized: "Collapse menu") + Text(appLocalized: "Collapse common folders menu") } .disabled(!store.showCommonDirs) .onChange(of: store.foldCommonDirMenu) { diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index a345a61..3964d6a 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -7,6 +7,7 @@ import AppKit import Cocoa +import Combine import FinderSync import Foundation import OSLog @@ -24,6 +25,10 @@ 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 menuConfigErrorTitle = "Unable to Open Menu Config" + @State private var showMenuConfigError = false + @State private var customMenuStatus = CustomMenuStatus(applied: .defaultLayout, notice: .none) @State private var showDirImporter = false @State private var wrongFold = false @@ -35,13 +40,13 @@ struct GeneralSettingsTabView: View { Form { // MARK: - 第一组:主要控制 Section { - Toggle(isOn: $showMenuBarExtra) { - Text(appLocalized: "Show icon in menu bar") - } - LaunchAtLogin.Toggle { Text(appLocalized: "Launch at login") } + + Toggle(isOn: $showMenuBarExtra) { + Text(appLocalized: "Show icon in menu bar") + } } header: { Text(appLocalized: "Main Controls") } @@ -82,7 +87,7 @@ struct GeneralSettingsTabView: View { } } } label: { - Label(AppLocalization.localized("Folder Permissions"), systemImage: "folder.badge.person.crop") + Label(AppLocalization.localized("Folder Permissions"), systemImage: "folder.badge.gearshape") } } header: { Text(appLocalized: "Permissions") @@ -92,6 +97,69 @@ struct GeneralSettingsTabView: View { .fixedSize(horizontal: false, vertical: true) } + Section { + LabeledContent { + Text(customMenuStatusDetail) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } label: { + Label(customMenuStatusTitle, systemImage: customMenuStatusIcon) + .foregroundStyle(customMenuStatusColor) + } + + switch customMenuStatus.notice { + case .none: + EmptyView() + case .edited: + Text(appLocalized: "Changed since it was applied, so the menu still shows the previous layout.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + case .broken(let reason): + Label { + Text(reason) + .textSelection(.enabled) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Button(AppLocalization.localized("Edit Menu Config…")) { + revealMenuConfig() + } + .help(AppLocalization.localized("Locates custom_menu.json in Finder so you can open it with the editor you prefer. Creates it first if it does not exist yet.")) + + Button(AppLocalization.localized("Apply Custom Menu")) { + applyCustomMenu() + } + .disabled(!customMenuHasPendingChanges) + .help(AppLocalization.localized("Reads the file again and puts it into effect. If it has errors the menu is left as it was.")) + + Spacer() + + Button(AppLocalization.localized("Restore Default Layout")) { + restoreDefaultLayout() + } + .disabled(!customMenuFileExists) + .help(AppLocalization.localized("Deletes the file and returns to the default layout, keeping a backup.")) + } + } header: { + Text(appLocalized: "Custom Menu") + } footer: { + VStack(alignment: .leading, spacing: 6) { + if customMenuStatus.applied == .defaultLayout { + 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: "Editing the file changes nothing until you apply it.") + } + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + // MARK: - 第三组:设置管理 Section { // 备份 @@ -135,9 +203,18 @@ struct GeneralSettingsTabView: View { .formStyle(.grouped) .onAppear { updatePermissionStatus() + refreshCustomMenuStatus() } .onForeground { updatePermissionStatus() + refreshCustomMenuStatus() + } + // Keeps the Apply button's enabled state honest. App activation covers the + // normal "edit elsewhere, switch back" path; this covers a writer that never + // takes activation away - a script, a sync tool, another process. Without + // it a stale status would leave the button disabled and unclickable. + .onReceive(Timer.publish(every: 2, on: .main, in: .common).autoconnect()) { _ in + refreshCustomMenuStatus() } .alert( Text(appLocalized: "Invalid Folder"), @@ -159,11 +236,163 @@ struct GeneralSettingsTabView: View { } message: { Text(appLocalized: "Folder access permission is required to use this feature.") } + .alert(menuConfigErrorTitle, isPresented: $showMenuConfigError) { + Button(AppLocalization.localized("OK"), role: .cancel) {} + } message: { + Text(menuConfigError ?? "") + } .sheet(isPresented: $showFolderPermissionsSheet) { FolderPermissionsSheetView(bookmarkManager: bookmarkManager) } } + /// Locates the file in Finder so the user can open it with an editor they chose. + /// + /// Deliberately does not open it: the system default editor is whatever happens + /// to be installed, which is not a choice the user made. A missing file is + /// seeded first so there is something to reveal - and that no longer changes + /// the menu, which now waits for Apply. + private func revealMenuConfig() { + guard let url = RCRuntime.shared.menuService.customMenuURL else { + presentConfigError( + title: "Unable to Open Menu Config", + message: AppLocalization.localized("The shared configuration folder is unavailable.") + ) + return + } + do { + try MenuService.prepareCustomMenu(at: url, config: MenuService.makePayload(from: store)) + } catch { + presentConfigError(title: "Unable to Open Menu Config", message: CustomMenuDiagnostics.message(for: error)) + } + NSWorkspace.shared.activateFileViewerSelecting([url]) + refreshCustomMenuStatus() + } + + // MARK: - 自定义菜单 + + private var customMenuFileExists: Bool { + guard let url = RCRuntime.shared.menuService.customMenuURL else { return false } + return FileManager.default.fileExists(atPath: url.path) + } + + /// Whether the file differs from what is in effect. Also what makes the button + /// going grey a form of confirmation that applying worked. + private var customMenuHasPendingChanges: Bool { + switch customMenuStatus.notice { + case .edited, .broken: true + case .none: false + } + } + + private var customMenuIsBroken: Bool { + if case .broken = customMenuStatus.notice { return true } + return false + } + + private var customMenuStatusTitle: String { + switch customMenuStatus.applied { + case .unavailable: + AppLocalization.localized("Unavailable") + case .defaultLayout: + customMenuIsBroken + ? AppLocalization.localized("Configuration invalid") + : AppLocalization.localized("Not enabled") + case .empty, .custom: + AppLocalization.localized("Enabled") + } + } + + private var customMenuStatusDetail: String { + switch customMenuStatus.applied { + case .unavailable: "" + case .defaultLayout: AppLocalization.localized("Using the default layout") + case .empty: AppLocalization.localized("Empty menu") + case .custom(let count): String(format: AppLocalization.localized("%lld items"), count) + } + } + + private var customMenuStatusIcon: String { + switch customMenuStatus.applied { + case .unavailable: "exclamationmark.triangle.fill" + case .defaultLayout: customMenuIsBroken ? "exclamationmark.triangle.fill" : "circle.dashed" + case .empty, .custom: "checkmark.circle.fill" + } + } + + private var customMenuStatusColor: Color { + switch customMenuStatus.applied { + case .unavailable: .orange + case .defaultLayout: customMenuIsBroken ? .orange : .secondary + case .empty, .custom: .green + } + } + + /// Guarded by an equality check so the polling timer does not dirty the view on + /// every tick. + private func refreshCustomMenuStatus() { + let fresh = RCRuntime.shared.menuService.customMenuStatus(from: store) + if fresh != customMenuStatus { + customMenuStatus = fresh + } + } + + private func presentConfigError(title: String, message: String) { + menuConfigErrorTitle = AppLocalization.localized(title) + menuConfigError = message + showMenuConfigError = true + } + + /// Re-reads the file and only then commits it. + /// + /// Nothing is pushed unless the file validated, so a bad edit leaves the live + /// menu exactly as it was instead of taking it down behind the user's back. + private func applyCustomMenu() { + do { + try RCRuntime.shared.menuService.applyCustomMenu(from: store) + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } catch { + presentConfigError( + title: "Configuration Not Applied", + message: CustomMenuDiagnostics.message(for: error) + + "\n\n" + AppLocalization.localized("The menu is unchanged.") + ) + } + refreshCustomMenuStatus() + } + + private func restoreDefaultLayout() { + guard let url = RCRuntime.shared.menuService.customMenuURL else { + presentConfigError( + title: "Unable to Restore Default Layout", + message: AppLocalization.localized("The shared configuration folder is unavailable.") + ) + return + } + + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Restore the default layout?") + alert.informativeText = AppLocalization.localized("This deletes custom_menu.json and returns to the default menu. A copy is kept as custom_menu.backup.json.") + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("Restore")) + alert.addButton(withTitle: AppLocalization.localized("Cancel")) + alert.buttons[0].hasDestructiveAction = true + + guard alert.runModal() == .alertFirstButtonReturn else { return } + + do { + try MenuService.removeCustomMenu(at: url) + // Forgetting the snapshot matters: without it, recreating the file with + // the same bytes (restoring the backup, say) would silently bring the + // custom layout back without an Apply. + RCRuntime.shared.menuService.discardAppliedCustomMenu() + NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) + } catch { + presentConfigError(title: "Unable to Restore Default Layout", message: CustomMenuDiagnostics.message(for: error)) + } + refreshCustomMenuStatus() + } + // MARK: - 权限状态检测 private func updatePermissionStatus() { diff --git a/RClick/Settings/NewFileSettingsTabView.swift b/RClick/Settings/NewFileSettingsTabView.swift index dd8b87e..de69fb9 100644 --- a/RClick/Settings/NewFileSettingsTabView.swift +++ b/RClick/Settings/NewFileSettingsTabView.swift @@ -94,7 +94,7 @@ struct NewFileSettingsTabView: View { } VStack(alignment: .leading, spacing: 2) { - Text(item.name) + Text(item.displayName) Text(String(format: AppLocalization.localized("Extension: %@"), item.ext)) .font(.caption) .foregroundColor(.secondary) @@ -130,7 +130,7 @@ struct NewFileSettingsTabView: View { fileToDelete = nil } } message: { file in - Text(String(format: AppLocalization.localized("Are you sure you want to delete \"%@\" (%@)? This action cannot be undone."), file.name, file.ext)) + Text(String(format: AppLocalization.localized("Are you sure you want to delete \"%@\" (%@)? This action cannot be undone."), file.displayName, file.ext)) } } diff --git a/RClick/Settings/SettingsView.swift b/RClick/Settings/SettingsView.swift index 7a19fbc..a343895 100644 --- a/RClick/Settings/SettingsView.swift +++ b/RClick/Settings/SettingsView.swift @@ -146,6 +146,19 @@ struct SettingsView: View { } .navigationTitle(AppLocalization.localized(selectedTab.rawValue)) .background(Color(NSColor.windowBackgroundColor)) + // A failed write used to be invisible: the change stayed on screen but was + // never stored, so the only way to notice was losing it on the next launch. + .alert( + Text(appLocalized: "Settings could not be saved"), + isPresented: Binding( + get: { appState.lastSaveError != nil }, + set: { if !$0 { appState.clearSaveError() } } + ) + ) { + Button(AppLocalization.localized("OK"), role: .cancel) { appState.clearSaveError() } + } message: { + Text(appState.lastSaveError ?? "") + } } func getAppVersion() -> String { diff --git a/RClick/Shared/PermissionChecker.swift b/RClick/Shared/PermissionChecker.swift index be001ab..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.systemsettings:com.apple.settings.PrivacySecurity.extension?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/RClick/Shared/Updater.swift b/RClick/Shared/Updater.swift index 010b0c8..badbb0b 100644 --- a/RClick/Shared/Updater.swift +++ b/RClick/Shared/Updater.swift @@ -93,6 +93,18 @@ class UpdatePreferences: ObservableObject { // MARK: - GitHub API 服务 +/// 一次更新检查的结果。 +/// +/// 以前 `checkForUpdate` 返回 `GitHubRelease?`,那一个 `nil` 同时背着三种含义: +/// 网络失败、最新 release 是草稿/预发布、确实已是最新。而 `catch` 又把真实的网络 +/// 错误吞成一句 `print`,上层拿到 `nil` 只能一律当成「已是最新」——所以断网会被 +/// 报成「已是最新」。三种情况不拆开,界面就没法说实话。 +enum UpdateCheckResult { + case updateAvailable(GitHubRelease) + case upToDate + case failed(String) +} + class GitHubReleaseChecker { private let owner: String private let repo: String @@ -119,29 +131,48 @@ class GitHubReleaseChecker { decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(GitHubRelease.self, from: data) } - - // 检查是否需要更新 - func checkForUpdate(currentVersion: String, includePrereleases: Bool = false) async -> GitHubRelease? { - print(currentVersion) + + /// 拉仓库的星标数。走 `/repos/{owner}/{repo}`,和 release 是两个端点。 + func fetchStarCount() async throws -> Int { + let url = URL(string: "https://api.github.com/repos/\(owner)/\(repo)")! + var request = URLRequest(url: url) + request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + struct Repository: Decodable { + let stargazersCount: Int + enum CodingKeys: String, CodingKey { case stargazersCount = "stargazers_count" } + } + return try JSONDecoder().decode(Repository.self, from: data).stargazersCount + } + + // 检查是否需要更新。 + // 返回枚举而不是 `GitHubRelease?`:那个 `nil` 原来同时背着「网络失败」 + // 「最新 release 是草稿或预发布」「确实已是最新」三种含义,而 `catch` 又把 + // 真实的网络错误吞成一句 `print`,上层只能一律当成「已是最新」—— + // 于是断网会被报成「已是最新」。三种情况分开,界面才可能说实话。 + func checkForUpdate(currentVersion: String, includePrereleases: Bool = false) async -> UpdateCheckResult { do { let latestRelease = try await fetchLatestRelease() - - // 跳过草稿版和预发布版(除非明确包含) + + // 草稿版和预发布版不算可更新版本,但这是一次成功的检查 if latestRelease.draft || (!includePrereleases && latestRelease.prerelease) { - return nil + return .upToDate } - - // 比较版本 + if compareVersions(currentVersion, latestRelease.version) == .orderedAscending { - return latestRelease - } else { - print("the last verison \(latestRelease.version)") + return .updateAvailable(latestRelease) } + + return .upToDate } catch { - print("检查更新失败: \(error)") + return .failed(error.localizedDescription) } - - return nil } // 语义化版本比较 @@ -176,13 +207,24 @@ class UpdateManager: ObservableObject { @Published var availableUpdate: GitHubRelease? @Published var isChecking = false @Published var updateError: String? + /// 上一次检查的结论是「已是最新」。 + /// + /// 单独一个字段,不再借用 `updateError`:借用会让界面无法区分「检查成功且已是最新」 + /// 和「检查失败」,弹窗里那张绿色「Up to Date」卡片也因此永远走不到。 + @Published var isUpToDate = false @Published var isDownloading = false @Published var downloadProgress: Double = 0 @Published var showUpdateSheet = false + + /// 仓库星标数。nil 表示还没拿到——界面就不显示数字,不占位、不报错。 + @Published private(set) var starCount: Int? private let githubChecker: GitHubReleaseChecker private let preferences: UpdatePreferences private let currentVersion: String + /// 存下来只为拼仓库/Issues 地址,别处不再硬编码 URL。 + private let owner: String + private let repo: String /// 是否来自 Mac App Store(通过 `Contents/_MASReceipt/receipt` 是否存在判断)。 /// App Store 构建禁止自更新(MAS 审核要求),改为引导用户去 App Store 更新。 @@ -195,37 +237,81 @@ class UpdateManager: ObservableObject { self.githubChecker = GitHubReleaseChecker(owner: owner, repo: repo) self.preferences = UpdatePreferences() self.currentVersion = currentVersion + self.owner = owner + self.repo = repo + } + + // MARK: - 星标数 + + private enum StarCache { + static let count = "githubStarCount" + static let fetchedAt = "githubStarCountFetchedAt" + /// 一天拉一次。星数不是实时指标,没必要每次打开「关于」都请求一次。 + static let lifetime: TimeInterval = 24 * 60 * 60 + } + + /// 取星标数:优先用缓存,过期或没有才请求。 + /// + /// 失败不打扰用户——有旧值就继续用(别让数字忽然消失),没有就保持 nil, + /// 「关于」页那一行只会少一个数字,不会多一条错误。 + func loadStarCount() async { + let defaults = UserDefaults.group + let cachedAt = defaults.object(forKey: StarCache.fetchedAt) as? Date + let cached = defaults.object(forKey: StarCache.count) as? Int + + if let cachedAt, let cached, Date().timeIntervalSince(cachedAt) < StarCache.lifetime { + starCount = cached + return + } + + if let fresh = try? await githubChecker.fetchStarCount() { + starCount = fresh + defaults.set(fresh, forKey: StarCache.count) + defaults.set(Date(), forKey: StarCache.fetchedAt) + } else if let cached { + starCount = cached + } } - // 关闭更新提示 + // 关闭更新提示。 + // 只关窗,**不清空检查结果**:清空会让「关于」页的状态行在关窗后退回「尚未检查」, + // 把用户刚拿到的信息抹掉;再用「查看详情…」打开时也会看到一张空白的卡片。 func dismissUpdateSheet() { showUpdateSheet = false - availableUpdate = nil - updateError = nil + } + + /// 打开更新弹窗展示上次的检查结果,不重新发起检查。 + func showUpdateDetails() { + showUpdateSheet = true } // 检查更新 func checkForUpdates(force: Bool = false) async { isChecking = true updateError = nil + isUpToDate = false showUpdateSheet = true - + defer { isChecking = false } - - guard let release = await githubChecker.checkForUpdate(currentVersion: currentVersion) else { - print("not release") - updateError = AppLocalization.localized("The current version is already up to date.") - return - } - - // 检查用户是否忽略了此版本 - if !force && preferences.isVersionIgnored(release.version) { - print("忽略这个版本") - updateError = String(format: AppLocalization.localized("Version %@ is ignored"), release.version) - return + + switch await githubChecker.checkForUpdate(currentVersion: currentVersion) { + case .failed(let reason): + // 真失败。从此不再和「已是最新」共用 updateError,弹窗标题才对得上内容。 + updateError = reason + + case .upToDate: + // 单独置位。updateError 保持 nil,弹窗因此会走到它原本走不到的 else 分支, + // 显示作者早就写好、却从未显示过的绿色「Up to Date」卡片。 + isUpToDate = true + + case .updateAvailable(let release): + // 检查用户是否忽略了此版本 + if !force && preferences.isVersionIgnored(release.version) { + updateError = String(format: AppLocalization.localized("Version %@ is ignored"), release.version) + return + } + availableUpdate = release } - - availableUpdate = release } // MARK: - 下载和安装方法 @@ -482,12 +568,20 @@ class UpdateManager: ObservableObject { } } - // 打开GitHub发布页面 - func openReleasesPage() { - if let url = URL(string: "https://github.com/wflixu/RClick/releases") { - NSWorkspace.shared.open(url) - } + /// 统一的仓库地址拼接:owner/repo 来自 init,不在各处再硬编码一遍。 + private func openGitHub(_ path: String = "") { + guard let url = URL(string: "https://github.com/\(owner)/\(repo)\(path)") else { return } + NSWorkspace.shared.open(url) } + + // 打开GitHub发布页面 + func openReleasesPage() { openGitHub("/releases") } + + /// 打开仓库主页(「关于」页的「加星」走这里)。 + func openRepositoryPage() { openGitHub() } + + /// 打开 Issues 列表(「关于」页的「反馈问题」走这里)。 + func openIssuesPage() { openGitHub("/issues") } // MARK: - 错误类型 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/AppStateDefaultsMigrationTests.swift b/RClickTests/AppStateDefaultsMigrationTests.swift new file mode 100644 index 0000000..9241bae --- /dev/null +++ b/RClickTests/AppStateDefaultsMigrationTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import RClick + +/// Covers the one-time move of the layout toggles out of the app's own defaults and +/// into the shared group. +/// +/// Both stores are throwaway suites. The real ones - and the real App Group - must +/// never be touched by a test. +@MainActor +struct AppStateDefaultsMigrationTests { + private struct ScratchStore { + let name: String + let defaults: UserDefaults + + static func make() throws -> ScratchStore { + let name = "rclick-migration-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + return ScratchStore(name: name, defaults: defaults) + } + + func cleanUp() { + defaults.removePersistentDomain(forName: name) + } + } + + private func withStores(_ body: (UserDefaults, UserDefaults) throws -> Void) throws { + let old = try ScratchStore.make() + let new = try ScratchStore.make() + defer { + old.cleanUp() + new.cleanUp() + } + try body(old.defaults, new.defaults) + } + + @Test func existingValuesSurviveTheMove() throws { + try withStores { old, new in + // The value that matters: leaving it behind would turn common folders + // off, which takes any custom menu referencing one down with it. + old.set(true, forKey: "showCommonDirs") + old.set(false, forKey: "foldAppsMenu") + + AppState.migrateLayoutToggles(from: old, to: new) + + #expect(new.bool(forKey: "showCommonDirs")) + #expect(new.object(forKey: "foldAppsMenu") as? Bool == false) + } + } + + @Test func theOldCopyIsRemovedSoThereIsOnlyOneSourceOfTruth() throws { + try withStores { old, new in + old.set(true, forKey: "showCommonDirs") + + AppState.migrateLayoutToggles(from: old, to: new) + + #expect(old.object(forKey: "showCommonDirs") == nil) + } + } + + @Test func aValueAlreadyInTheNewStoreWins() throws { + try withStores { old, new in + old.set(true, forKey: "showCommonDirs") + new.set(false, forKey: "showCommonDirs") + + AppState.migrateLayoutToggles(from: old, to: new) + + #expect(new.object(forKey: "showCommonDirs") as? Bool == false) + } + } + + @Test func untouchedSwitchesStayAbsent() throws { + try withStores { old, new in + // Nothing stored anywhere. The migration must not materialise a value, + // or every switch would be pinned to `false` instead of falling back to + // its declared default - `foldNewFileMenu` and `foldCommonDirMenu` + // default to true. + AppState.migrateLayoutToggles(from: old, to: new) + + for key in AppState.layoutToggleKeys { + #expect(new.object(forKey: key) == nil, "\(key) should not have been written") + } + } + } + + @Test func runningItAgainIsHarmless() throws { + try withStores { old, new in + old.set(true, forKey: "showCommonDirs") + AppState.migrateLayoutToggles(from: old, to: new) + + // Second launch: the old store is empty and the new one already has the + // value, so nothing moves and nothing is lost. + AppState.migrateLayoutToggles(from: old, to: new) + + #expect(new.bool(forKey: "showCommonDirs")) + } + } + + @Test func everyDeclaredToggleKeyIsCovered() { + // A new layout switch added without joining this list would silently keep + // whatever the old store held. + #expect(AppState.layoutToggleKeys.contains("showCommonDirs")) + #expect(AppState.layoutToggleKeys.contains("foldCommonDirMenu")) + #expect(AppState.layoutToggleKeys.count == 5) + } +} diff --git a/RClickTests/ConfigServiceSaveTests.swift b/RClickTests/ConfigServiceSaveTests.swift new file mode 100644 index 0000000..4430b3c --- /dev/null +++ b/RClickTests/ConfigServiceSaveTests.swift @@ -0,0 +1,74 @@ +import Foundation +import SwiftData +import Testing + +@testable import RClick + +/// `ConfigService.save` replaces the whole store: it deletes every row of each +/// entity and inserts the in-memory arrays back. +/// +/// Every entity carries `@Attribute(.unique)` on its `id`, and the ids survive the +/// round trip (`OpenWithApp(id: entity.id, ...)`), so the second save re-inserts +/// ids the context is still holding as pending deletions. These tests use an +/// in-memory container - the real store in the App Group must not be touched. +@MainActor +struct ConfigServiceSaveTests { + /// Backed by a real file, not `isStoredInMemoryOnly`: the failure this is + /// chasing only shows up against actual SQLite, and a temp file is as close to + /// the App Group store as a test may get. + private func makeService() throws -> (ConfigService, ModelContext) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-config-\(UUID()).sqlite") + let configuration = ModelConfiguration(url: url, allowsSave: true, cloudKitDatabase: .none) + let container = try ModelContainer( + for: AppEntity.self, + ActionEntity.self, + NewFileTypeEntity.self, + CommonDirEntity.self, + BookmarkEntity.self, + DataVersion.self, + configurations: configuration + ) + let context = ModelContext(container) + return (ConfigService(modelContext: context), context) + } + + private var payload: AppConfigData { + AppConfigData( + apps: [OpenWithApp(id: "zed-id", appURL: URL(fileURLWithPath: "/Applications/Zed.app"))], + actions: [RCAction(id: "copy-path", name: "Copy Path", enabled: true, idx: 0, icon: "doc")], + newFiles: [NewFile(ext: ".txt", name: "TXT", enabled: true, idx: 0, icon: "doc", id: "txt-id")], + commonDirs: [CommonDir(id: "desktop", name: "Desktop", + url: URL(fileURLWithPath: "/Users/x/Desktop"), icon: "folder")] + ) + } + + @Test func theFirstSaveStoresEverything() throws { + let (service, context) = try makeService() + + try service.save(payload) + + #expect(try context.fetch(FetchDescriptor()).count == 1) + #expect(try context.fetch(FetchDescriptor()).count == 1) + } + + @Test func savingAgainWithTheSameIDsDoesNotThrow() throws { + let (service, _) = try makeService() + try service.save(payload) + + // This is what every mutation in AppState does to an already-populated store. + try service.save(payload) + } + + @Test func savingAfterAddingAnAppKeepsBoth() throws { + let (service, context) = try makeService() + try service.save(payload) + + var grown = payload + grown.apps.append(OpenWithApp(id: "ghostty-id", + appURL: URL(fileURLWithPath: "/Applications/Ghostty.app"))) + try service.save(grown) + + #expect(try context.fetch(FetchDescriptor()).count == 2) + } +} diff --git a/RClickTests/CustomMenuApplyTests.swift b/RClickTests/CustomMenuApplyTests.swift new file mode 100644 index 0000000..65d4858 --- /dev/null +++ b/RClickTests/CustomMenuApplyTests.swift @@ -0,0 +1,160 @@ +import AppKit +import Testing + +@testable import RClick + +/// Covers the explicit-apply model: the file on disk is a draft, and only +/// `applyCustomMenu` commits it. +/// +/// Every service here is built over a temporary URL. The default URL resolves to +/// the real App Group container, which tests must never touch. +@MainActor +struct CustomMenuApplyTests { + private var catalog: MenuConfigPayload { + MenuConfigPayload( + actions: [ActionMenuItem(id: "copy-path", name: "Copy Path", icon: "doc", tag: 1)], + commonDirs: [ + CommonDirMenuItem(id: "desktop", name: "Desktop", icon: "folder", url: "/Users/x/Desktop") + ] + ) + } + + /// `MenuNode` is deliberately not `Equatable`, so compare counts. + private func nodeCount(_ nodes: [MenuNode]?) -> Int? { + nodes?.count + } + + private func temporaryFile(_ json: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-apply-\(UUID()).json") + try Data(json.utf8).write(to: url) + return url + } + + // MARK: - The core of the change + + @Test func anEditOnDiskStaysInvisibleUntilItIsApplied() throws { + let url = try temporaryFile(#"[{"type":"separator"}]"#) + defer { try? FileManager.default.removeItem(at: url) } + let service = MenuService(customMenuURL: url) + + try service.applyCustomMenu(at: url, config: catalog) + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + + // The user saves a longer draft. The extension heartbeat fires every ten + // seconds and rebuilds the config every time; not one of those may pick + // this up, which is the entire point of the change. + try Data(#"[{"type":"separator"},{"type":"separator"}]"#.utf8).write(to: url) + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + + // Only an explicit apply moves the menu. + try service.applyCustomMenu(at: url, config: catalog) + #expect(nodeCount(service.customMenu(for: catalog)) == 2) + } + + @Test func anInvalidEditThrowsAndLeavesTheMenuAlone() throws { + let url = try temporaryFile(#"[{"type":"separator"}]"#) + defer { try? FileManager.default.removeItem(at: url) } + let service = MenuService(customMenuURL: url) + try service.applyCustomMenu(at: url, config: catalog) + + try Data("not JSON".utf8).write(to: url) + + let thrown = #expect(throws: (any Error).self) { + try service.applyCustomMenu(at: url, config: catalog) + } + #expect(thrown != nil) + // The live menu must not have collapsed to the default layout. + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + } + + @Test func applyingTheSameFileTwiceIsHarmless() throws { + let url = try temporaryFile(#"[{"type":"separator"}]"#) + defer { try? FileManager.default.removeItem(at: url) } + let service = MenuService(customMenuURL: url) + + try service.applyCustomMenu(at: url, config: catalog) + try service.applyCustomMenu(at: url, config: catalog) + + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + } + + @Test func aFileThatNoLongerMatchesTheSettingsIsRejectedAtApplyTime() throws { + // Applying with common folders off must fail loudly rather than commit a + // layout whose only entry would silently drop out of the menu. + let url = try temporaryFile(#"[{"type":"item","itemType":"common-dir","id":"desktop"}]"#) + defer { try? FileManager.default.removeItem(at: url) } + let service = MenuService(customMenuURL: url) + try service.applyCustomMenu(at: url, config: catalog) + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + + let thrown = #expect(throws: (any Error).self) { + try service.applyCustomMenu(at: url, config: MenuConfigPayload()) + } + let message = CustomMenuDiagnostics.message(for: try #require(thrown)) + #expect(message.contains("matched 0 enabled items")) + // The layout that was already in effect survived the rejection. + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + } + + // MARK: - Going back to the default layout + + @Test func deletingTheFileClearsTheMenuOnTheNextBuild() throws { + let url = try temporaryFile(#"[{"type":"separator"}]"#) + let service = MenuService(customMenuURL: url) + try service.applyCustomMenu(at: url, config: catalog) + #expect(nodeCount(service.customMenu(for: catalog)) == 1) + + try FileManager.default.removeItem(at: url) + + // Deleting the file is unambiguous and non-transient, so unlike an edit it + // does not wait for an apply. + #expect(nodeCount(service.customMenu(for: catalog)) == nil) + } + + @Test func discardingForgetsTheLayoutEvenIfTheSameFileComesBack() throws { + // Restoring the default layout must not be undone by reinstating the file + // from the backup: that would bring the custom menu back with no Apply. + let json = #"[{"type":"separator"}]"# + let url = try temporaryFile(json) + defer { try? FileManager.default.removeItem(at: url) } + let service = MenuService(customMenuURL: url) + try service.applyCustomMenu(at: url, config: catalog) + + service.discardAppliedCustomMenu() + #expect(nodeCount(service.customMenu(for: catalog)) == nil) + + try Data(json.utf8).write(to: url) + #expect(nodeCount(service.customMenu(for: catalog)) == nil) + } + + // MARK: - Degenerate containers + + @Test func noContainerMeansTheDefaultLayout() { + let service = MenuService(customMenuURL: nil) + #expect(nodeCount(service.customMenu(for: catalog)) == nil) + } + + @Test func noContainerMakesApplyingFail() throws { + let service = MenuService(customMenuURL: nil) + + let thrown = #expect(throws: (any Error).self) { + try service.applyCustomMenu(config: catalog) + } + #expect(thrown is CustomMenuError) + } + + // MARK: - The empty array keeps its meaning + + @Test func anAppliedEmptyArrayRendersNoItemsRatherThanTheDefaultLayout() throws { + // `[]` must stay distinguishable from "no file": the extension renders an + // empty menu for the former and the categorized layout for the latter. + let url = try temporaryFile("[]") + defer { try? FileManager.default.removeItem(at: url) } + let service = MenuService(customMenuURL: url) + + try service.applyCustomMenu(at: url, config: catalog) + + #expect(nodeCount(service.customMenu(for: catalog)) == 0) + } +} diff --git a/RClickTests/CustomMenuStatusTests.swift b/RClickTests/CustomMenuStatusTests.swift new file mode 100644 index 0000000..16d55ca --- /dev/null +++ b/RClickTests/CustomMenuStatusTests.swift @@ -0,0 +1,250 @@ +import AppKit +import Testing + +@testable import RClick + +/// Covers what the custom menu file currently *means*, and how failures are +/// explained. Every case passes an explicit URL: under the test host the default +/// URL resolves to the real App Group container, which tests must never touch. +@MainActor +struct CustomMenuStatusTests { + private var catalog: MenuConfigPayload { + MenuConfigPayload( + actions: [ActionMenuItem(id: "copy-path", name: "Copy Path", icon: "doc", tag: 1)], + apps: [ + AppMenuItem(id: "zed-id", name: "Zed", icon: "app", tag: 2, + appURL: "/Applications/Zed.app") + ], + newFiles: [ + NewFileMenuItem(id: "txt-id", name: "TXT", ext: ".txt", icon: "doc") + ] + ) + } + + private func temporaryFile(_ json: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-status-\(UUID()).json") + try Data(json.utf8).write(to: url) + return url + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-status-\(UUID())") + } + + /// The settled state: the file on disk is exactly what is in effect. + private func appliedStatus(of json: String, config: MenuConfigPayload? = nil) throws -> CustomMenuStatus { + let url = try temporaryFile(json) + defer { try? FileManager.default.removeItem(at: url) } + return MenuService.customMenuStatus(at: url, appliedData: Data(json.utf8), + config: config ?? catalog) + } + + /// The file exists but was never applied, so nothing of it is in effect. + private func draftStatus(of json: String, config: MenuConfigPayload? = nil) throws -> CustomMenuStatus { + let url = try temporaryFile(json) + defer { try? FileManager.default.removeItem(at: url) } + return MenuService.customMenuStatus(at: url, appliedData: nil, config: config ?? catalog) + } + + /// Loads `json` expecting rejection, and hands back the error it raised. + private func rejection(of json: String) throws -> any Error { + let url = try temporaryFile(json) + defer { try? FileManager.default.removeItem(at: url) } + let thrown = #expect(throws: (any Error).self) { + _ = try CustomMenu.load(from: url, config: catalog) + } + return try #require(thrown) + } + + // MARK: - What the menu is rendering + + @Test func noFileMeansDefaultLayout() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-absent-\(UUID()).json") + #expect(MenuService.customMenuStatus(at: url, appliedData: nil, config: catalog) + == CustomMenuStatus(applied: .defaultLayout, notice: .none)) + } + + @Test func noContainerMeansUnavailable() { + // Previously indistinguishable from "no file": both produced a nil payload. + #expect(MenuService.customMenuStatus(at: nil, appliedData: nil, config: catalog) + == CustomMenuStatus(applied: .unavailable, notice: .none)) + } + + @Test func emptyArrayIsEmptyNotInvalid() throws { + // An empty array is a deliberate request for an empty menu, not a failure. + #expect(try appliedStatus(of: "[]") == CustomMenuStatus(applied: .empty, notice: .none)) + } + + @Test func appliedLayoutReportsTopLevelCount() throws { + let json = #"[{"type":"item","itemType":"action","id":"copy-path"},{"type":"separator"}]"# + #expect(try appliedStatus(of: json) + == CustomMenuStatus(applied: .custom(topLevelItems: 2), notice: .none)) + } + + // MARK: - The file relative to what is applied + + @Test func aFileThatWasNeverAppliedIsPendingAndChangesNothing() throws { + // Generating the file no longer switches the layout, so this is what a + // first-time user sees: a file, and a menu that has not moved. + let status = try draftStatus(of: #"[{"type":"separator"}]"#) + #expect(status == CustomMenuStatus(applied: .defaultLayout, notice: .edited)) + } + + @Test func editingAnAppliedFileIsPendingUntilItIsAppliedAgain() throws { + let url = try temporaryFile(#"[{"type":"separator"}]"#) + defer { try? FileManager.default.removeItem(at: url) } + let applied = try Data(contentsOf: url) + + try Data(#"[{"type":"separator"},{"type":"separator"}]"#.utf8).write(to: url) + + // Editing the file is not enough: the applied bytes still describe one item. + #expect(MenuService.customMenuStatus(at: url, appliedData: applied, config: catalog) + == CustomMenuStatus(applied: .custom(topLevelItems: 1), notice: .edited)) + // Once the new bytes are applied, the menu catches up and nothing is pending. + let reapplied = try Data(contentsOf: url) + #expect(MenuService.customMenuStatus(at: url, appliedData: reapplied, config: catalog) + == CustomMenuStatus(applied: .custom(topLevelItems: 2), notice: .none)) + } + + @Test func deletingTheFileReturnsToTheDefaultLayout() throws { + // The documented escape hatch, and it must survive explicit-apply: a + // snapshot that outlives its file would strand the user on a layout they + // cannot get rid of. + let url = temporaryDirectory().appendingPathComponent("custom_menu.json") + let applied = Data(#"[{"type":"separator"}]"#.utf8) + #expect(MenuService.customMenuStatus(at: url, appliedData: applied, config: catalog) + == CustomMenuStatus(applied: .defaultLayout, notice: .none)) + } + + @Test func malformedFileIsBrokenWithAReason() throws { + guard case .broken(let reason) = try draftStatus(of: "not JSON").notice else { + Issue.record("expected .broken") + return + } + #expect(!reason.isEmpty) + } + + @Test func unreadablePathIsNotReportedAsAMalformedDocument() throws { + // A folder where a file was expected. The syntax of that file is not the + // problem, so the user must not be sent to fix its JSON. + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + guard case .broken(let reason) = MenuService.customMenuStatus( + at: directory, appliedData: nil, config: catalog + ).notice else { + Issue.record("expected .broken") + return + } + #expect(reason != AppLocalization.localized("The JSON file is malformed.")) + } + + // MARK: - isProblem + + @Test(arguments: [ + CustomMenuStatus(applied: .unavailable, notice: .none), + CustomMenuStatus(applied: .defaultLayout, notice: .broken(reason: "boom")), + CustomMenuStatus(applied: .custom(topLevelItems: 1), notice: .broken(reason: "boom")) + ]) + func problemStatesAreFlagged(status: CustomMenuStatus) { + #expect(status.isProblem) + } + + @Test(arguments: [ + CustomMenuStatus(applied: .defaultLayout, notice: .none), + CustomMenuStatus(applied: .empty, notice: .none), + CustomMenuStatus(applied: .custom(topLevelItems: 1), notice: .none), + // An edit waiting to be applied is the normal working state, not a problem. + CustomMenuStatus(applied: .custom(topLevelItems: 1), notice: .edited) + ]) + func normalStatesAreNotFlagged(status: CustomMenuStatus) { + #expect(!status.isProblem) + } + + // MARK: - Messages must say what is actually wrong + + @Test func layoutErrorExposesItsReasonThroughLocalizedDescription() { + // Regression: CustomMenuError conformed only to CustomStringConvertible, so + // the settings alert rendered the generic system sentence instead of this. + #expect(CustomMenuError.invalid("boom").localizedDescription == "boom") + } + + @Test func nestedReferenceErrorsPointAtTheMenuItemPath() throws { + let json = #"[{"type":"submenu","title":"Outer","children":[{"type":"item","itemType":"app","id":"missing"}]}]"# + let message = CustomMenuDiagnostics.message(for: try rejection(of: json)) + #expect(message.contains("[1.1]")) + } + + @Test func decodingErrorsPointAtTheOffendingElement() throws { + let json = #"[{"type":"item","itemType":"action","id":"copy-path"},{"type":"nope"}]"# + let message = CustomMenuDiagnostics.message(for: try rejection(of: json)) + #expect(message.contains("[2]")) + } + + @Test func syntaxErrorsAppendAParserPosition() throws { + let base = AppLocalization.localized("The JSON file is malformed.") + let message = CustomMenuDiagnostics.message(for: try rejection(of: "not JSON at all")) + #expect(message.hasPrefix(base)) + // The line/column readout is appended on top of the generic sentence. + #expect(message != base) + } + + // MARK: - Removing the custom layout + + @Test func removingKeepsABackupAndDeletesTheLiveFile() throws { + let directory = temporaryDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let live = directory.appendingPathComponent("custom_menu.json") + let contents = #"[{"type":"separator"}]"# + try Data(contents.utf8).write(to: live) + + try MenuService.removeCustomMenu(at: live) + + #expect(!FileManager.default.fileExists(atPath: live.path)) + let backup = directory.appendingPathComponent(MenuService.customMenuBackupName) + #expect(try Data(contentsOf: backup) == Data(contents.utf8)) + } + + @Test func removingWhenThereIsNoFileIsNotAnError() throws { + let url = temporaryDirectory().appendingPathComponent("custom_menu.json") + try MenuService.removeCustomMenu(at: url) + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + + // MARK: - A settings change can invalidate an applied file + + @Test func hidingCommonFoldersTakesDownAConfigThatReferencesThem() throws { + // "Enable common folders" is not a pure layout switch: MenuService only + // fills payload.commonDirs when it is on. A custom menu referencing a + // common dir therefore stops resolving once the switch is turned off, and + // the whole custom layout falls back - not just the common dir entries. + // + // The file itself is untouched, which is why the reason has to be surfaced: + // otherwise the menu would simply change with nothing to explain it. + let json = #"[{"type":"item","itemType":"common-dir","id":"desktop"}]"# + let url = try temporaryFile(json) + defer { try? FileManager.default.removeItem(at: url) } + let applied = Data(json.utf8) + + let shown = MenuConfigPayload(commonDirs: [ + CommonDirMenuItem(id: "desktop", name: "Desktop", icon: "folder", url: "/Users/x/Desktop") + ]) + #expect(MenuService.customMenuStatus(at: url, appliedData: applied, config: shown) + == CustomMenuStatus(applied: .custom(topLevelItems: 1), notice: .none)) + + let hidden = MenuConfigPayload(commonDirs: []) + let status = MenuService.customMenuStatus(at: url, appliedData: applied, config: hidden) + #expect(status.applied == .defaultLayout) + guard case .broken(let reason) = status.notice else { + Issue.record("expected .broken once common folders are hidden") + return + } + #expect(reason.contains("matched 0 enabled items")) + } +} diff --git a/RClickTests/CustomMenuTests.swift b/RClickTests/CustomMenuTests.swift new file mode 100644 index 0000000..5003d1a --- /dev/null +++ b/RClickTests/CustomMenuTests.swift @@ -0,0 +1,202 @@ +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"]) + // No separator before "More": it drew a stray blank line above a single + // trailing submenu, which grouped nothing and read as a glitch. + #expect(nodes.count == 4) + #expect(nodes[3].type == .submenu) + 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/RClickTests/MountedVolumeObserverTests.swift b/RClickTests/MountedVolumeObserverTests.swift new file mode 100644 index 0000000..76e54ae --- /dev/null +++ b/RClickTests/MountedVolumeObserverTests.swift @@ -0,0 +1,93 @@ +import AppKit +import Testing +@testable import RClick + +@MainActor +struct MountedVolumeObserverTests { + @Test func tracksStartupMountRenameAndUnmountWithoutRestart() { + let center = NotificationCenter() + let root = URL(fileURLWithPath: "/") + let disk = URL(fileURLWithPath: "/Volumes/External Disk") + let nas = URL(fileURLWithPath: "/Volumes/NAS") + let dmg = URL(fileURLWithPath: "/Volumes/Installer") + let renamed = URL(fileURLWithPath: "/Volumes/Renamed Disk") + let custom = URL(fileURLWithPath: "/mnt/nfs") + var volumes: [URL]? = [root, disk, nas, custom] + var updates: [Set] = [] + let observer = MountedVolumeObserver(center: center, mountedVolumes: { volumes }) { + updates.append($0) + } + withExtendedLifetime(observer) { + #expect(updates.last == [root, disk, nas, custom]) + volumes?.append(dmg) + center.post(name: NSWorkspace.didMountNotification, object: nil) + #expect(updates.last == [root, disk, nas, custom, dmg]) + volumes = [root, renamed, nas, custom, dmg] + center.post(name: NSWorkspace.didRenameVolumeNotification, object: nil) + #expect(updates.last == [root, renamed, nas, custom, dmg]) + volumes = [root, nas, custom] + center.post(name: NSWorkspace.didUnmountNotification, object: nil) + #expect(updates.last == [root, nas, custom]) + #expect(updates.count == 4) + center.post(name: NSWorkspace.didMountNotification, object: nil) + #expect(updates.count == 4) + volumes = nil + center.post(name: NSWorkspace.didUnmountNotification, object: nil) + #expect(updates.last == [root, nas, custom]) + #expect(updates.count == 4) + volumes = [] + center.post(name: NSWorkspace.didUnmountNotification, object: nil) + #expect(updates.last == [root]) + } + } + + @Test func failedInitialEnumerationRecoversAndObserversAreReleased() { + let center = NotificationCenter() + var volumes: [URL]? + var reads = 0 + var updates: [Set] = [] + var observer: MountedVolumeObserver? = MountedVolumeObserver(center: center, mountedVolumes: { + reads += 1 + return volumes + }, update: { updates.append($0) }) + weak var weakObserver = observer + #expect(reads == 1) + #expect(updates.last == [URL(fileURLWithPath: "/")]) + let nas = URL(fileURLWithPath: "/Volumes/Reconnected NAS") + let existing = URL(fileURLWithPath: "/Volumes/Already Mounted") + volumes = [nas, existing] + center.post(name: NSWorkspace.didMountNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: nas]) + #expect(reads == 2) + #expect(updates.last == [URL(fileURLWithPath: "/"), nas, existing]) + observer = nil + #expect(weakObserver == nil) + center.post(name: NSWorkspace.didMountNotification, object: nil) + #expect(reads == 2) + } + @Test func notificationPathsOverrideStaleVolumeEnumeration() { + let center = NotificationCenter() + let root = URL(fileURLWithPath: "/") + let old = URL(fileURLWithPath: "/Volumes/Old") + let new = URL(fileURLWithPath: "/Volumes/New") + var snapshot = [old] + var latest: Set = [] + let observer = MountedVolumeObserver(center: center, mountedVolumes: { snapshot }) { latest = $0 } + withExtendedLifetime(observer) { + // Real NSWorkspace unmount notifications can precede the enumeration update. + center.post(name: NSWorkspace.didUnmountNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: old]) + #expect(latest == [root]) + snapshot = [] + center.post(name: NSWorkspace.didMountNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: old]) + #expect(latest == [root, old]) + snapshot = [old] + center.post(name: NSWorkspace.didRenameVolumeNotification, object: nil, + userInfo: [NSWorkspace.volumeURLUserInfoKey: new, + NSWorkspace.oldVolumeURLUserInfoKey: old]) + #expect(latest == [root, new]) + } + } + +} diff --git a/README.md b/README.md index a63ba01..b2d39f9 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,15 @@ xcodebuild -project RClick.xcodeproj -scheme RClick -configuration Release ### 🌐 Localization -RClick supports three languages with the following priority: +RClick supports five languages with the following priority: | Language | Code | Notes | |----------|------|-------| | **English** | `en` | Default/base language | | **Simplified Chinese** | `zh-Hans` | Primary localization target | | **Japanese** | `ja` | Activated when system language is Japanese | +| **Spanish** | `es` | Activated when system language is Spanish | +| **French** | `fr` | Activated when system language is French | **Principles:** - Default language is English (also the fallback for unsupported system languages) @@ -114,6 +116,16 @@ Thanks to all the people who have contributed to RClick! +Recent contributions: + +- **[@Juns-g](https://github.com/Juns-g)** — declarative hierarchical tree menus ([#151](https://github.com/wflixu/RClick/pull/151)), and watching mounted volumes so external and network drives show the menu ([#152](https://github.com/wflixu/RClick/pull/152)) +- **[@Dam0rt](https://github.com/Dam0rt)** — French localization ([#149](https://github.com/wflixu/RClick/pull/149)) +- **[@stors789](https://github.com/stors789)** — App Groups provisioning in the release pipeline ([#141](https://github.com/wflixu/RClick/pull/141)) +- **[@EliasMatDev](https://github.com/EliasMatDev)** — Latin American Spanish localization ([#138](https://github.com/wflixu/RClick/pull/138)) +- **[@shoal-rat](https://github.com/shoal-rat)** — Finder observation fix, so the menu appears in every folder ([#126](https://github.com/wflixu/RClick/pull/126)) +- **[@P013onEr](https://github.com/P013onEr)** — string catalogs and trilingual localization ([#120](https://github.com/wflixu/RClick/pull/120)), draggable menu ordering ([#121](https://github.com/wflixu/RClick/pull/121)) +- **[@chunyang-wen](https://github.com/chunyang-wen)** — removed a needless `await` ([#21](https://github.com/wflixu/RClick/pull/21)) + ## Similar Projects - [SzContext](https://github.com/RoadToDream/SzContext) @@ -131,3 +143,11 @@ 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. + +## Hierarchical Custom Menus + +Top-level items can be mixed with arbitrarily nested submenus, separators, and custom titles and icons. + +Drop a declarative `custom_menu.json` into the App Group container, then go to **Settings → General → Custom Menu** and press "Apply" for it to take effect. Editing the file on its own changes nothing, so an edit that is still half-written cannot break your menu. With no file present the default layout is used; with an invalid one the menu stays exactly as it was and Settings shows which element failed to parse. + +See [configuration examples and usage](examples/README.md). diff --git a/Shared/AppLocalization.swift b/Shared/AppLocalization.swift index 16b1ab2..54f80e1 100644 --- a/Shared/AppLocalization.swift +++ b/Shared/AppLocalization.swift @@ -8,7 +8,10 @@ import Foundation import SwiftUI -enum AppLocalization { +// `nonisolated` because lookup is thread-safe and the default main-actor isolation +// makes it unreachable from `LocalizedError.errorDescription`, which has to stay +// nonisolated. +nonisolated enum AppLocalization { static let tableName = "Localizable" static func localized(_ key: String) -> String { diff --git a/Shared/CustomMenu.swift b/Shared/CustomMenu.swift new file mode 100644 index 0000000..388ab8b --- /dev/null +++ b/Shared/CustomMenu.swift @@ -0,0 +1,148 @@ +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. + /// + /// `path` is a 1-based menu item path such as "3" or "2.1". It is attached to + /// validation errors so the settings UI can point at the offending entry. + func resolved(using config: MenuConfigPayload, path: String = "") throws -> MenuNode { + do { + return try resolveBody(using: config, path: path) + } catch let error as CustomMenuError { + // Children already carry their full path, so only wrap the innermost message. + guard case .invalid(let message) = error, !path.isEmpty else { throw error } + throw CustomMenuError.invalidAt(path: path, message: message) + } + } + + private func resolveBody(using config: MenuConfigPayload, path: String) 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.enumerated().map { index, child in + try child.resolved( + using: config, + path: path.isEmpty ? "\(index + 1)" : "\(path).\(index + 1)" + ) + } + 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, LocalizedError { + case invalid(String) + /// A validation failure pinned to a 1-based menu item path, e.g. "3" or "2.1". + case invalidAt(path: String, message: String) + /// The App Group container is missing, so there is nothing to read or write. + case containerUnavailable + + var description: String { + switch self { + case .invalid(let message): return message + case .invalidAt(let path, let message): return "[\(path)] \(message)" + case .containerUnavailable: + return AppLocalization.localized("The shared configuration folder is unavailable.") + } + } + + /// Without this, `error.localizedDescription` degrades to the generic + /// "The operation couldn't be completed." and the real reason is lost. + var errorDescription: String? { description } +} + +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 + } + return try decode(data, config: config) + } + + /// In-memory counterpart of `load`, so callers that already hold the bytes + /// (validation, transfer) do not read the file twice. + static func decode(_ data: Data, config: MenuConfigPayload) throws -> [MenuNode] { + let nodes = try JSONDecoder().decode([MenuNode].self, from: data) + return try nodes.enumerated().map { index, node in + try node.resolved(using: config, path: "\(index + 1)") + } + } + + /// 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/Shared/MountedVolumeObserver.swift b/Shared/MountedVolumeObserver.swift new file mode 100644 index 0000000..5d31d4a --- /dev/null +++ b/Shared/MountedVolumeObserver.swift @@ -0,0 +1,65 @@ +import AppKit + +/// Keeps Finder's observation roots in sync without traversing mounted filesystems. +@MainActor +final class MountedVolumeObserver { + private let center: NotificationCenter + private var observers: [NSObjectProtocol] = [] + private let mountedVolumes: () -> [URL]? + private let update: (Set) -> Void + private var directories: Set = [] + private var hasVolumeSnapshot = false + + init(center: NotificationCenter = NSWorkspace.shared.notificationCenter, + mountedVolumes: @escaping () -> [URL]? = { + FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: []) + }, + update: @escaping (Set) -> Void) { + self.center = center + self.mountedVolumes = mountedVolumes + self.update = update + // Subscribe before the initial snapshot so a mount during startup is not missed. + for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification, + NSWorkspace.didRenameVolumeNotification] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] notification in + let name = notification.name + let volume = notification.userInfo?[NSWorkspace.volumeURLUserInfoKey] as? URL + let oldVolume = notification.userInfo?[NSWorkspace.oldVolumeURLUserInfoKey] as? URL + MainActor.assumeIsolated { self?.refresh(name: name, volume: volume, oldVolume: oldVolume) } + }) + } + refresh() + } + + private func refresh(name: Notification.Name? = nil, volume: URL? = nil, oldVolume: URL? = nil) { + var next: Set = [URL(fileURLWithPath: "/")] + if volume != nil && hasVolumeSnapshot { + next.formUnion(directories) + } else if let volumes = mountedVolumes() { + // Include network, hidden and custom mount locations. Retry a failed + // initial snapshot on the next event, even if that event carries a URL. + next.formUnion(volumes) + hasVolumeSnapshot = true + } else { + // A failed enumeration is not evidence that all volumes were unmounted. + next.formUnion(directories) + } + if let volume { + // Enumeration can still contain an ejected volume when didUnmount fires. + // Apply the event last so stale enumeration cannot override its paths. + if name == NSWorkspace.didUnmountNotification { + next.remove(volume) + } else { + if let oldVolume { next.remove(oldVolume) } + next.insert(volume) + } + } + guard next != directories else { return } + directories = next + update(next) + } + + isolated deinit { + for observer in observers { center.removeObserver(observer) } + } +} diff --git a/Shared/RCBase.swift b/Shared/RCBase.swift index b7868f5..eeb03f8 100644 --- a/Shared/RCBase.swift +++ b/Shared/RCBase.swift @@ -241,16 +241,46 @@ struct NewFile: @MainActor RCBase { extension NewFile { static let all: [NewFile] = [.txt, .md, .json, .docx, .pptx, .xlsx, .pages, .key, .numbers] + // `id` 必须逐字对上 `NewFileTypeEntity.createDefaultFileTypes()`。 + // 这几个字面量原本不传 id,于是全都取了 init 的默认值 `UUID()`——「恢复默认」 + // 写回数据库的就是一排随机 id,和种子数据对不上,`displayName` 查译文也就没了依据。 + // // icon 字段为 SF Symbol 名称,作为 NSWorkspace 获取失败时的 fallback - static let json = NewFile(ext: ".json", name: "JSON", enabled: false, idx: 0, icon: "curlybraces") - static let txt = NewFile(ext: ".txt", name: "TXT", idx: 1, icon: "doc.text") - static let md = NewFile(ext: ".md", name: "Markdown", idx: 2, icon: "doc.richtext") - static let docx = NewFile(ext: ".docx", name: "DOCX", idx: 3, icon: "doc.richtext.fill") - static let pptx = NewFile(ext: ".pptx", name: "PPTX", idx: 4, icon: "rectangle.on.rectangle.fill") - static let xlsx = NewFile(ext: ".xlsx", name: "XLSX", idx: 5, icon: "tablecells") - static let pages = NewFile(ext: ".pages", name: "Pages", idx: 6, icon: "doc.richtext") - static let key = NewFile(ext: ".key", name: "Keynote", idx: 7, icon: "rectangle.on.rectangle") - static let numbers = NewFile(ext: ".numbers", name: "Numbers", idx: 8, icon: "tablecells") + static let json = NewFile(ext: ".json", name: "JSON", enabled: false, idx: 0, icon: "curlybraces", id: "json") + static let txt = NewFile(ext: ".txt", name: "TXT", idx: 1, icon: "doc.text", id: "txt") + static let md = NewFile(ext: ".md", name: "Markdown", idx: 2, icon: "doc.richtext", id: "md") + static let docx = NewFile(ext: ".docx", name: "DOCX", idx: 3, icon: "doc.richtext.fill", id: "docx") + static let pptx = NewFile(ext: ".pptx", name: "PPTX", idx: 4, icon: "rectangle.on.rectangle.fill", id: "pptx") + static let xlsx = NewFile(ext: ".xlsx", name: "XLSX", idx: 5, icon: "tablecells", id: "xlsx") + static let pages = NewFile(ext: ".pages", name: "Pages", idx: 6, icon: "doc.richtext", id: "pages") + static let key = NewFile(ext: ".key", name: "Keynote", idx: 7, icon: "rectangle.on.rectangle", id: "key") + static let numbers = NewFile(ext: ".numbers", name: "Numbers", idx: 8, icon: "tablecells", id: "numbers") +} + +extension NewFile { + /// 本地化显示名称。 + /// + /// 内置类型按 id 取译文;用户自己添加的类型走 `name`,用户填的名字不该被翻译。 + /// 和 `RCAction.displayName`、`CommonDir.displayName` 是同一个模式——NewFile + /// 此前是唯一没有这个属性的模型,所以「新建文件」子菜单在每个语言下都是英文原文。 + /// + /// JSON / TXT / Markdown / Pages / Keynote / Numbers 刻意不进字符串目录:它们是 + /// 格式缩写和 Apple 产品名,各语言都不翻译,`localized` 查不到就回落到 key 本身, + /// 正好是想要的显示文本。只有三个 Office 格式有各语言通用的描述性叫法。 + var displayName: String { + switch id { + case "json": return AppLocalization.localized("JSON") + case "txt": return AppLocalization.localized("TXT") + case "md": return AppLocalization.localized("Markdown") + case "docx": return AppLocalization.localized("DOCX") + case "pptx": return AppLocalization.localized("PPTX") + case "xlsx": return AppLocalization.localized("XLSX") + case "pages": return AppLocalization.localized("Pages") + case "key": return AppLocalization.localized("Keynote") + case "numbers": return AppLocalization.localized("Numbers") + default: return name + } + } } // MARK: - Menu Item Models for Extension Communication diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f81b038 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,78 @@ +# 自定义 Finder 菜单 + +`custom_menu.json` 是菜单根节点数组;顺序就是显示顺序。示例将 VS Code、Warp 平铺,次要动作放在“更多”,TXT / Markdown 放在第三级“新建文件”。 + +## 使用 + +**设置 → 通用 → 自定义菜单** 下有三个按钮: + +| 按钮 | 行为 | +| --- | --- | +| 编辑菜单配置… | 在访达中定位 `custom_menu.json`,由你用自己的编辑器打开。文件不存在时先基于当前已配置项目生成一份。**不改动已有文件,也不改变菜单** | +| 应用配置 | 重读文件并生效——**这是菜单发生变化的唯一途径**。文件有错误时会提示具体原因,菜单保持原样 | +| 恢复默认… | 删除 `custom_menu.json` 回到默认布局,删除前把副本保留为 `custom_menu.backup.json`。会先弹确认 | + +生成不会覆盖已有文件(包括尚未编辑完成的 JSON)。 + +### 为什么不再自动生效 + +早期版本会在最多 10 秒内自动应用文件改动。问题是它**无法分辨"改完了"和"正打到一半"**——你在编辑器里敲到一半,菜单就被换成一份残缺的半成品,而且没有任何提示。现在文件是草稿,「应用配置」才是提交:改坏了点一下就会告诉你哪里错了,菜单不动。 + +生成时不会在「更多」之前插入分隔线(旧版本会,容易被看成多余的空白行)。想要分组可以自己在 JSON 里加 `{"type": "separator"}`。 + +> ⚠️ **关掉「启用常用文件夹」会让整份自定义菜单回退。** 这个开关不只是布局开关:它在 `MenuService` 里决定 payload 到底有没有 `commonDirs` 数组。一旦关掉,配置里所有 `common-dir` 引用都会匹配到 0 个,按规则整份回退到默认布局(设置页会提示具体是哪一项)。要保留自定义菜单,就得让这个开关保持开启。 + +设置页顶部会显示两件事:**菜单现在是什么**(未启用 / 已启用 / 配置无效)和**文件相对它有什么变化**(已修改未应用 / 有错误)。配置有问题时**会列出具体哪一项出错**,例如 `[2.1] app reference '/Applications/Warp.app' matched 0 enabled items` —— 方括号是菜单项路径,`[2.1]` 指第 2 项里的第 1 项。 + +「应用配置」在文件与已生效内容一致时会变灰,所以它变灰本身就是一次"应用成功"的回执。 + +示例将当前应用和动作平铺,文件模板与常用目录放入“更多”下的子菜单。可直接把 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. 回到 **设置 → 通用 → 自定义菜单** 点「应用配置」。菜单只在点它之后才改变;只把文件放进去不会生效。 +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`);省略时叶子沿用原图标,无效图标不会阻止菜单构建。 + +> ⚠️ **截图里的文案是自定义标题,不是 App 内置文案。** `screenshots/` 预览中的“隐藏选中文件”“显示隐藏文件”“直接删除 (绕过废纸篓)”等字样,是作者在 JSON 里用 `title` 字段手写的说明性标题。不写 `title` 时显示的是 App 的实际文案:隐藏 / 显示 / 复制路径 / 直接删除 / AirDrop。若以为是内置文案而照抄,会得到一份看起来“官方”实则自造的菜单。 + +配置只控制布局:未列出的项目不显示,原有折叠开关在自定义布局生效时不参与排列。应用参数、文件模板、权限处理继续由主程序现有配置与执行链负责,JSON 不定义命令或修改参数执行语义。 + +## 实现与验证 + +`Shared/CustomMenu.swift` 定义共享 `Codable` 模型、引用校验与递归 NSMenu 构建;`MenuService` 持有一份「已应用」的字节快照,每次推送配置时用它重新解析为稳定 ID 后放入可选 `MenuConfigPayload.customMenu`。快照只在 App 启动首次用到时、以及点「应用配置」时读盘——心跳不会重读,这正是"改动不自动生效"的实现方式。扩展复用旧菜单的四类叶子工厂和点击处理器,保留 ID、类型、Finder 目标路径及触发来源。此模型可作为以后树形编辑 UI 的存储格式,无需迁移现有业务实体。 + +`RClickTests/CustomMenuTests.swift` 使用同一示例验证解析、三级 NSMenu 结构与叶子绑定;`CustomMenuStatusTests` 验证状态两轴(菜单现在是什么 / 文件相对它怎样)、错误消息定位(`[1.1]`)与删除时保留备份;`CustomMenuApplyTests` 验证"改盘不生效,直到调用应用"、无效配置被拒且菜单不变、以及删除文件与显式丢弃快照都回到默认布局;`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 diff --git a/specs/contracts/communication-protocol.md b/specs/contracts/communication-protocol.md deleted file mode 100644 index c4e7559..0000000 --- a/specs/contracts/communication-protocol.md +++ /dev/null @@ -1,551 +0,0 @@ -# RClick 通信协议设计 - -> 最后更新:2026-03-21 -> 状态:设计规范 -> 基于:DistributedNotificationCenter + Codable 协议层 - ---- - -## 一、概述 - -RClick 的主应用程序(Main App)和 FinderSync 扩展(Extension)之间通过 `DistributedNotificationCenter` 进行进程间通信(IPC)。本协议定义了类型安全的消息格式和传输机制。 - -### 1.1 通信模型 - -```mermaid -graph LR - Main["Main App
发送配置"] - Ext["Extension
发送事件"] - - Main -->|"MenuConfig"| Ext - Ext -->|"ClickEvent"| Main - Ext -->|"Heartbeat"| Main -``` - -### 1.2 设计原则 - -| 原则 | 说明 | -|------|------| -| **类型安全** | 使用枚举替代字符串,编译时检查 | -| **Codable 协议** | 所有消息体实现 Codable,支持 JSON 编解码 | -| **消息 ID** | 每个消息带 UUID,支持追踪和去重 | -| **单向传输** | Main→Extension 和 Extension→Main 独立通道 | - ---- - -## 二、消息类型定义 - -### 2.1 主程序 → Extension 消息 - -```swift -/// 主程序发送给 Extension 的消息类型 -enum MainToExtensionAction: String, Codable { - /// 发送完整菜单配置 - case menuConfig = "menu-config" - - /// 主程序启动通知 - case running = "running" - - /// 主程序退出通知 - case quit = "quit" -} -``` - -### 2.2 Extension → 主程序消息 - -```swift -/// Extension 发送给主程序的消息类型 -enum ExtensionToMainAction: String, Codable { - /// 菜单点击事件 - case click = "click" - - /// 心跳消息 - case heartbeat = "heartbeat" - - /// 请求菜单配置(Extension 启动或缓存失效时) - case requestConfig = "request-config" -} -``` - ---- - -## 三、消息载体结构 - -### 3.1 主程序 → Extension 消息 - -```swift -/// 主程序发送给 Extension 的消息 -struct MainToExtensionMessage: Codable { - /// 消息 ID,用于追踪和去重 - let id: UUID - - /// 消息类型 - let action: MainToExtensionAction - - /// JSON 编码的载荷数据 - let data: Data? - - init(id: UUID = UUID(), - action: MainToExtensionAction, - data: Codable? = nil) { - self.id = id - self.action = action - self.data = try? JSONEncoder().encode(data) - } -} -``` - -### 3.2 Extension → 主程序消息 - -```swift -/// Extension 发送给主程序的消息 -struct ExtensionToMainMessage: Codable { - /// 消息 ID,用于追踪和去重 - let id: UUID - - /// 消息类型 - let action: ExtensionToMainAction - - /// JSON 编码的载荷数据 - let data: Data? - - init(id: UUID = UUID(), - action: ExtensionToMainAction, - data: Codable? = nil) { - self.id = id - self.action = action - self.data = try? JSONEncoder().encode(data) - } -} -``` - ---- - -## 四、消息载荷定义 - -### 4.1 菜单配置载荷(Main → Extension) - -```swift -/// 菜单配置消息载荷 -struct MenuConfigPayload: Codable { - /// 动作菜单项列表 - let actions: [ActionMenuItem] - - /// 应用菜单项列表 - let apps: [AppMenuItem] - - /// 新建文件菜单项列表 - let newFiles: [NewFileMenuItem] - - /// 常用目录菜单项列表 - let commonDirs: [CommonDirMenuItem] - - /// 脚本菜单项列表(新增) - let scripts: [ScriptMenuItem] - - /// 菜单版本号(用于去重和防乱序) - let version: Int -} -``` - -#### 4.1.1 动作菜单项 - -```swift -/// 动作菜单项(复制路径、删除等) -struct ActionMenuItem: Codable { - /// 菜单项唯一标识 - let id: String - - /// 菜单项显示名称 - let name: String - - /// SF Symbol 图标名称(如 "doc.on.doc") - let icon: String - - /// 是否启用 - let enabled: Bool -} -``` - -**图标策略**:使用 SF Symbol,Extension 直接用 `NSImage(systemSymbolName:)` 加载 - -#### 4.1.2 应用菜单项 - -```swift -/// 应用菜单项(用外部应用打开) -struct AppMenuItem: Codable { - /// 菜单项唯一标识 - let id: String - - /// 菜单项显示名称 - let name: String - - /// 应用程序路径(Extension 据此加载图标) - let appURL: URL -} -``` - -**图标策略**: -- Extension 用 `NSWorkspace.shared.icon(forFile:)` 加载 -- 系统自动缓存,无需主程序传输图标数据 - -#### 4.1.3 新建文件菜单项 - -```swift -/// 新建文件菜单项 -struct NewFileMenuItem: Codable { - /// 菜单项唯一标识 - let id: String - - /// 菜单项显示名称 - let name: String - - /// 文件扩展名 - let ext: String - - /// SF Symbol 图标名称(如 "doc.text") - let icon: String -} -``` - -#### 4.1.4 常用目录菜单项 - -```swift -/// 常用目录菜单项 -struct CommonDirMenuItem: Codable { - /// 菜单项唯一标识 - let id: String - - /// 菜单项显示名称 - let name: String - - /// SF Symbol 图标名称(如 "folder") - let icon: String -} -``` - -#### 4.1.5 脚本菜单项 - -```swift -/// 脚本菜单项(执行自定义脚本) -struct ScriptMenuItem: Codable { - /// 菜单项唯一标识 - let id: String - - /// 菜单项显示名称 - let name: String - - /// SF Symbol 图标名称(如 "terminal") - let icon: String - - /// 脚本类型 - let scriptType: String // "shell" | "python" | "applescript" -} -``` - -### 图标传输策略总结 - -| 菜单类型 | 图标来源 | 传输内容 | 理由 | -|----------|----------|----------|------| -| 动作菜单 | SF Symbol | `icon: String` | 数据量小,系统图标 | -| 应用菜单 | App 路径 | `appURL: URL` | 系统自动缓存应用图标 | -| 新建文件 | SF Symbol | `icon: String` | 数据量小,系统图标 | -| 常用目录 | SF Symbol | `icon: String` | 数据量小,系统图标 | -| 脚本菜单 | SF Symbol | `icon: String` | 数据量小,系统图标 | - -**为什么不传输图标 Data?** - -1. **消息大小**:菜单配置可能包含 20-50 个项,每个图标 Data 约 2-10KB -2. **IPC 开销**:`DistributedNotificationCenter` 传输大数据效率低 -3. **内存占用**:Extension 需要解码所有 Data,内存压力大 -4. **系统缓存**:`NSWorkspace.shared.icon(forFile:)` 自带缓存机制 - -### 4.2 点击事件载荷(Extension → Main) - -```swift -/// 点击事件消息载荷 -struct ClickEventPayload: Codable { - /// 触发场景(从 FIMenuKind 转换) - let scene: MenuScene - - /// 当前目录路径 - let currentDirectory: String - - /// 选中的文件/目录路径列表 - let selectedItems: [String] - - /// 点击的菜单项信息 - let menuItem: MenuItemInfo -} - -/// 菜单项信息 -struct MenuItemInfo: Codable { - /// 菜单项类型 - let type: MenuItemType - - /// 菜单项 ID - let id: String - - /// 菜单项 tag(用于快速查找) - let tag: Int -} -``` - -#### 4.2.1 菜单项类型 - -```swift -enum MenuItemType: String, Codable { - case action = "action" // 动作菜单 - case app = "app" // 应用菜单 - case newFile = "new-file" // 新建文件 - case commonDir = "common-dir" // 常用目录 - case script = "script" // 执行脚本 -} -``` - -#### 4.2.2 触发场景 - -```swift -enum MenuScene: String, Codable { - /// 选中文件/文件夹右键 - case contextualMenuForItems = "ctx-items" - - /// 空白处右键 - case contextualMenuForContainer = "ctx-container" - - /// 侧边栏右键 - case contextualMenuForSidebar = "ctx-sidebar" - - /// 工具栏 - case toolbarItemMenu = "toolbar" -} -``` - -#### 4.2.3 项目类型 - -```swift -enum ItemType: String, Codable { - case file = "file" - case folder = "folder" - case unknown = "unknown" -} -``` - ---- - -## 五、Messager 类设计 - -### 5.1 核心实现 - -```swift -import Foundation - -class Messager { - static let shared = Messager() - - private let center: DistributedNotificationCenter = .default() - - // 消息处理器存储 - private var mainToExtensionHandlers: [MainToExtensionAction: (Data?) -> Void] = [:] - private var extensionToMainHandlers: [ExtensionToMainAction: (Data?) -> Void] = [:] - - // 通知名称 - static let mainToExtensionNotification = "RClick.MainToExtension" - static let extensionToMainNotification = "RClick.ExtensionToMain" - - private init() { - // 注册通知监听 - setupNotificationObservers() - } - - // MARK: - 发送消息 - - /// 主程序发送消息给 Extension - func sendToExtension(_ action: MainToExtensionAction, data: Codable? = nil) { - let message = MainToExtensionMessage(action: action, data: data) - sendMessage(message, via: Self.mainToExtensionNotification) - } - - /// Extension 发送消息给主程序 - func sendToMain(_ action: ExtensionToMainAction, data: Codable? = nil) { - let message = ExtensionToMainMessage(action: action, data: data) - sendMessage(message, via: Self.extensionToMainNotification) - } - - // MARK: - 注册处理器 - - /// Extension 注册主程序消息处理器 - func onMainMessage(_ action: MainToExtensionAction, handler: @escaping (Data?) -> Void) { - mainToExtensionHandlers[action] = handler - } - - /// 主程序注册 Extension 消息处理器 - func onExtensionMessage(_ action: ExtensionToMainAction, handler: @escaping (Data?) -> Void) { - extensionToMainHandlers[action] = handler - } - - // MARK: - 便捷方法 - - /// 主程序发送菜单配置给 Extension - func sendMenuConfig(_ config: MenuConfigPayload) { - sendToExtension(.menuConfig, data: config) - } - - /// Extension 发送点击事件给主程序 - func sendClickEvent(_ event: ClickEventPayload) { - sendToMain(.click, data: event) - } - - /// 发送心跳 - func sendHeartbeat() { - sendToMain(.heartbeat) - } - - /// 请求菜单配置 - func requestMenuConfig() { - sendToMain(.requestConfig) - } -} -``` - -### 5.2 使用示例 - -#### 主程序端 - -```swift -// 注册点击事件处理器 -Messager.shared.onExtensionMessage(.click) { data in - guard let event: ClickEventPayload = Messager.shared.decode(data) else { return } - // 处理点击事件 - handleClickEvent(event) -} - -// 发送菜单配置 -let config = MenuConfigPayload(actions: actions, apps: apps, ...) -Messager.shared.sendMenuConfig(config) -``` - -#### Extension 端 - -```swift -// 注册菜单配置处理器 -Messager.shared.onMainMessage(.menuConfig) { data in - guard let config: MenuConfigPayload = Messager.shared.decode(data) else { return } - // 更新菜单配置 - self.menuConfig = config -} - -// 发送点击事件 -let event = ClickEventPayload(...) -Messager.shared.sendClickEvent(event) -``` - ---- - -## 六、通知中心配置 - -### 6.1 通知名称 - -| 方向 | 通知名称 | 用途 | -|------|---------|------| -| Main → Extension | `RClick.MainToExtension` | 主程序发送配置和指令 | -| Extension → Main | `RClick.ExtensionToMain` | Extension 发送事件和状态 | - -### 6.2 消息格式 - -``` -Notification.Object: JSON 字符串 -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "action": "menu-config", - "data": { ... } // 可选,JSON 编码的载荷 -} -``` - ---- - -## 七、错误处理 - -### 7.1 解码失败 - -```swift -func decode(_ data: Data?) -> T? { - guard let data = data else { return nil } - return try? JSONDecoder().decode(T.self, from: data) -} -``` - -### 7.2 心跳机制 - -心跳消息用于 Extension 感知主程序是否运行,确保用户点击菜单时有效果。 - -**心跳流程**: - -```mermaid -sequenceDiagram - participant Main as Main App - participant Ext as Extension - - Main->>Ext: 1. 启动时发送 running 消息 - Main->>Ext: 2. 定期发送心跳 - Main->>Ext: 3. 发送 menuConfig,保存快照 - Ext->>Main: 4. 用户点击菜单,发送 click 事件 - Main->>Main: 执行操作 - Ext->>Main: 5. 定期发送心跳(感知主程序存活) - Main->>Ext: 6. 收到心跳,确认 Extension 在线 -``` - -**设计要点**: - -| 方向 | 消息类型 | 频率 | 用途 | -|------|----------|------|------| -| Main → Extension | `running` | 启动时 + 重试 6 次 | 通知 Extension 主程序已启动 | -| Main → Extension | `menuConfig` | 配置变更时 | 发送菜单配置 | -| Extension → Main | `heartbeat` | 每 5-10 秒 | 让主程序知道 Extension 存活 | -| Extension → Main | `click` | 用户点击时 | 发送点击事件 | -| Extension → Main | `requestConfig` | 缓存为空时 | 主动请求菜单配置 | - -**心跳超时处理**: - -- 主程序超过 15 秒未收到心跳,标记 Extension 为离线 -- 主程序菜单栏显示"扩展未响应"提示 -- 用户可点击"重新加载扩展"按钮触发 `menu.request` 消息 - -**Extension 缓存为空处理**: - -- Extension 启动时,如果 `menuConfig` 缓存为空,发送 `requestConfig` 消息 -- 主程序收到后,发送完整的 `menuConfig` 响应 -- Extension 收到后保存到内存缓存,用于后续菜单渲染 - ---- - -## 八、向后兼容性 - -### 8.1 旧消息格式支持 - -为了兼容旧版本的 Extension,消息结构应保持向后兼容: - -- 新增字段设为可选 -- 枚举的 `rawValue` 保持与原有字符串相同 -- 支持未知消息类型的降级处理 - -### 8.2 版本协商 - -未来可扩展消息结构,添加 `version` 字段: - -```swift -struct MainToExtensionMessage: Codable { - let version: Int // 协议版本 - let id: UUID - let action: MainToExtensionAction - let data: Data? -} -``` - ---- - -## 九、参考文档 - -- **重构设计**: [../重构设计.md](../重构设计.md) -- **数据模型**: [data-models.md](data-models.md) -- **Messager 实现**: [../../RClick/Shared/Messager.swift](../../RClick/Shared/Messager.swift) diff --git a/specs/contracts/data-models.md b/specs/contracts/data-models.md deleted file mode 100644 index 09a5972..0000000 --- a/specs/contracts/data-models.md +++ /dev/null @@ -1,726 +0,0 @@ -# RClick 数据模型设计 - -> 最后更新:2026-03-21 -> 状态:设计规范 -> 基于:SwiftData + 瘦 Extension 架构 - ---- - -## 一、概述 - -### 1.1 架构调整:完全磁盘访问权限 - -由于采用**完全磁盘访问权限**(用户手动授权),不再需要 Bookmark 缓存机制。 - -| 之前的设计 | 当前设计 | -|-----------|---------| -| PermissiveDir + Bookmark 缓存 | 不需要,依赖完全磁盘访问权限 | -| 按需请求权限 | 一次性授权,全盘访问 | - -### 1.2 模型分类 - -| 模型类别 | 用途 | 存储方式 | -|---------|------|---------| -| **应用模型** | 外部应用程序配置 | SwiftData | -| **动作模型** | 右键菜单动作配置 | SwiftData | -| **文件类型模型** | 新建文件模板配置 | SwiftData | -| **目录模型** | 常用目录配置 | SwiftData | -| **脚本模型** | 用户自定义脚本配置 | SwiftData | -| **内存模型** | 运行时数据表示 | 纯 Swift 结构 | -| **消息模型** | 进程间通信数据传输 | Codable 协议 | - -### 1.3 设计原则 - -1. **SwiftData 模型**:只存储配置数据,@Model 标记 -2. **内存模型**:RCBase 协议,运行时使用 -3. **消息模型**:Codable 协议,IPC 通信使用 -4. **模型转换**:SwiftData ↔ 内存模型 ↔ 消息模型 - -### 1.4 模型关系图 - -```mermaid -graph LR - subgraph Persistent["SwiftData 模型
持久化存储"] - PE1[AppEntity] - PE2[ActionEntity] - PE3[NewFileTypeEntity] - PE4[CommonDirEntity] - PE5[ScriptEntity] - end - - subgraph Memory["内存模型 (RCBase)
运行时使用"] - M1[OpenWithApp] - M2[RCAction] - M3[NewFile] - M4[CommonDir] - M5[RCScript] - end - - subgraph Message["消息模型
IPC 通信"] - S1[AppMenuItem] - S2[ActionMenuItem] - S3[NewFileMenuItem] - S4[CommonDirMenuItem] - S5[ScriptMenuItem] - end - - Persistent <--> Memory - Memory <--> Message -``` - ---- - -## 二、SwiftData 持久化模型 - -### 简化设计原则 - -1. **移除 Data 字段**:直接用 SwiftData 的 @Relationship 或 @Attribute 存储 -2. **移除时间戳**:配置数据不需要 createdAt/updatedAt -3. **移除 PermissiveDir**:完全磁盘访问权限无需 Bookmark - -### 模型简化对比 - -| 属性 | 之前设计 | 简化后设计 | -|------|---------|-----------| -| 复杂数据 | `argumentsData: Data?` | `arguments: [String]` (SwiftData 6.0+ 支持) | -| 时间戳 | `createdAt`, `updatedAt` | 移除(配置数据不需要) | -| Bookmark | `PermissiveDir` | 移除(完全磁盘访问权限) | - -### 2.3 新建文件类型实体(NewFileTypeEntity) - -存储新建文件类型配置。 - -```swift -import SwiftData -import Foundation - -/// 新建文件类型实体 - 用于存储新建文件类型配置 -@Model -final class NewFileTypeEntity { - @Attribute(.unique) var id: String - var fileExtension: String - var name: String - var icon: String - var isEnabled: Bool - var sortOrder: Int - var templatePath: String? // 模板文件路径(可选) - var openAppPath: String? // 默认打开应用路径(可选) - - init(id: String = UUID().uuidString, - fileExtension: String, - name: String, - icon: String = "doc", - isEnabled: Bool = true, - sortOrder: Int = 0, - templatePath: String? = nil, - openAppPath: String? = nil) { - self.id = id - self.fileExtension = fileExtension - self.name = name - self.icon = icon - self.isEnabled = isEnabled - self.sortOrder = sortOrder - self.templatePath = templatePath - self.openAppPath = openAppPath - } -} -``` - -#### 预定义文件类型 - -```swift -extension NewFileTypeEntity { - static func createDefaultFileTypes() -> [NewFileTypeEntity] { - return [ - NewFileTypeEntity(id: "txt", fileExtension: ".txt", name: "TXT", icon: "icon-file-txt", sortOrder: 0), - NewFileTypeEntity(id: "md", fileExtension: ".md", name: "Markdown", icon: "icon-file-md", sortOrder: 1), - NewFileTypeEntity(id: "json", fileExtension: ".json", name: "JSON", icon: "icon-file-json", sortOrder: 2), - NewFileTypeEntity(id: "docx", fileExtension: ".docx", name: "DOCX", icon: "icon-file-docx", sortOrder: 3), - NewFileTypeEntity(id: "xlsx", fileExtension: ".xlsx", name: "XLSX", icon: "icon-file-xlsx", sortOrder: 4), - NewFileTypeEntity(id: "pptx", fileExtension: ".pptx", name: "PPTX", icon: "icon-file-pptx", sortOrder: 5), - ] - } -} -``` - ---- - -### 2.4 常用目录实体(CommonDirEntity) - -存储快速访问目录配置。 - -```swift -import SwiftData -import Foundation - -/// 常用目录实体 - 用于存储快速访问目录 -@Model -final class CommonDirEntity { - @Attribute(.unique) var id: String - var name: String - var pathString: String - var icon: String - var sortOrder: Int - var isEnabled: Bool - - init(id: String = UUID().uuidString, - name: String, - path: URL, - icon: String = "folder", - sortOrder: Int = 0, - isEnabled: Bool = true) { - self.id = id - self.name = name - self.pathString = path.path() - self.icon = icon - self.sortOrder = sortOrder - self.isEnabled = isEnabled - } - - var path: URL { - get { URL(fileURLWithPath: pathString) } - set { pathString = newValue.path() } - } -} -``` - -#### 预定义常用目录 - -```swift -extension CommonDirEntity { - static func createDefaultCommonDirs() -> [CommonDirEntity] { - let fileManager = FileManager.default - var dirs: [CommonDirEntity] = [] - - let homeDir = fileManager.homeDirectoryForCurrentUser - let desktop = homeDir.appendingPathComponent("Desktop") - let documents = homeDir.appendingPathComponent("Documents") - let downloads = homeDir.appendingPathComponent("Downloads") - - dirs.append(CommonDirEntity(id: "desktop", name: "桌面", path: desktop, icon: "desktopcomputer", sortOrder: 0)) - dirs.append(CommonDirEntity(id: "documents", name: "文档", path: documents, icon: "doc.folder", sortOrder: 1)) - dirs.append(CommonDirEntity(id: "downloads", name: "下载", path: downloads, icon: "arrow.down.circle", sortOrder: 2)) - dirs.append(CommonDirEntity(id: "applications", name: "应用程序", path: URL(fileURLWithPath: "/Applications"), icon: "app", sortOrder: 3)) - dirs.append(CommonDirEntity(id: "home", name: "用户主目录", path: homeDir, icon: "house", sortOrder: 4)) - - return dirs - } -} -``` - ---- - -### 2.5 脚本实体(ScriptEntity) - -存储用户自定义脚本配置,支持 Shell、Python、AppleScript 三种类型。 - -```swift -import SwiftData -import Foundation - -/// 脚本实体 - 用于存储用户自定义脚本配置 -@Model -final class ScriptEntity { - @Attribute(.unique) var id: String - var name: String // 菜单显示名称 - var scriptType: String // "shell" | "python" | "applescript" - var scriptContent: String // 脚本内容 - var icon: String // SF Symbol 图标 - var sortOrder: Int - var isEnabled: Bool - - init(id: String = UUID().uuidString, - name: String, - scriptType: String, - scriptContent: String, - icon: String = "terminal", - sortOrder: Int = 0, - isEnabled: Bool = true) { - self.id = id - self.name = name - self.scriptType = scriptType - self.scriptContent = scriptContent - self.icon = icon - self.sortOrder = sortOrder - self.isEnabled = isEnabled - } -} -``` - -#### 预定义脚本模板(可选) - -```swift -extension ScriptEntity { - static func createDefaultScripts() -> [ScriptEntity] { - return [ - ScriptEntity( - name: "显示文件信息", - scriptType: "shell", - scriptContent: "mdls \"$1\"", - icon: "info.circle", - sortOrder: 0 - ), - ScriptEntity( - name: "在 VSCode 打开", - scriptType: "shell", - scriptContent: "open -a \"Visual Studio Code\" \"$@\"", - icon: "terminal", - sortOrder: 1 - ), - ScriptEntity( - name: "获取文件哈希", - scriptType: "shell", - scriptContent: "shasum -a 256 \"$@\"", - icon: "hash", - sortOrder: 2 - ), - ] - } -} -``` - ---- - -## 三、基础协议和模型(RCBase) - -### 3.1 基础协议 - -```swift -/// 所有数据模型的基础协议 -protocol RCBase: Hashable, Identifiable, Codable { - var id: String { get } -} -``` - -### 3.2 打开应用(OpenWithApp) - -用于内存中的应用表示,可转换为 AppEntity。 - -```swift -struct OpenWithApp: RCBase { - var id: String - var url: URL - var itemName: String - var inheritFromGlobalArguments: Bool - var inheritFromGlobalEnvironment: Bool - var arguments: [String] - var environment: [String: String] - - var appName: String { - FileManager.default.displayName(atPath: url.path) - } - - var name: String { - itemName.isEmpty ? appName : itemName - } - - init(id: String = UUID().uuidString, appURL url: URL) { - self.id = id - self.url = url - self.itemName = url.deletingPathExtension().lastPathComponent - } -} - -extension OpenWithApp { - init?(bundleIdentifier identifier: String) { - guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: identifier) else { - return nil - } - self.init(appURL: url) - } - - static let vscode = OpenWithApp(bundleIdentifier: "com.microsoft.VSCode") - static let terminal = OpenWithApp(bundleIdentifier: "com.apple.Terminal") - static var defaultApps: [OpenWithApp] { [.terminal, .vscode].compactMap { $0 } } -} -``` - -### 3.3 动作(RCAction) - -```swift -struct RCAction: RCBase { - var id: String - var name: String - var enabled: Bool - var idx: Int - var icon: String - - init(id: String, name: String, enabled: Bool = true, idx: Int, icon: String) { - self.id = id - self.name = name - self.enabled = enabled - self.idx = idx - self.icon = icon - } -} - -extension RCAction { - static let copyPath = RCAction(id: "copy-path", name: "Copy Path", idx: 0, icon: "doc.on.doc") - static let deleteDirect = RCAction(id: "delete-direct", name: "Delete Direct", idx: 1, icon: "trash") - static let hideFileDir = RCAction(id: "hide", name: "Hide", idx: 2, icon: "eye.slash") - static let unhideFileDir = RCAction(id: "unhide", name: "Unhide", idx: 3, icon: "eye") - static let airdrop = RCAction(id: "airdrop", name: "AirDrop", idx: 4, icon: "paperplane") - - static var all: [RCAction] = [.copyPath, .deleteDirect, .airdrop, .hideFileDir, .unhideFileDir] -} -``` - -### 3.4 新建文件(NewFile) - -```swift -struct NewFile: RCBase { - var ext: String - var name: String - var enabled: Bool - var idx: Int - var icon: String - var id: String - var openApp: URL? - var template: URL? - - init(ext: String, name: String, enabled: Bool = true, idx: Int, icon: String = "document", id: String = UUID().uuidString) { - self.ext = ext - self.name = name - self.enabled = enabled - self.idx = idx - self.icon = icon - self.id = id - } -} - -extension NewFile { - static var all: [NewFile] = [.txt, .md, .json, .docx, .pptx, .xlsx] - - static let json = NewFile(ext: ".json", name: "JSON", idx: 0, icon: "icon-file-json") - static let txt = NewFile(ext: ".txt", name: "TXT", idx: 1, icon: "icon-file-txt") - static let md = NewFile(ext: ".md", name: "Markdown", idx: 2, icon: "icon-file-md") - static let docx = NewFile(ext: ".docx", name: "DOCX", idx: 3, icon: "icon-file-docx") - static let pptx = NewFile(ext: ".pptx", name: "PPTX", idx: 4, icon: "icon-file-pptx") - static let xlsx = NewFile(ext: ".xlsx", name: "XLSX", idx: 5, icon: "icon-file-xlsx") -} -``` - -### 3.5 常用目录(CommonDir) - -```swift -struct CommonDir: RCBase { - var id: String - var name: String - var url: URL - var icon: String - - init(id: String, name: String, url: URL, icon: String) { - self.id = id - self.name = name - self.url = url - self.icon = icon - } -} -``` - -### 3.6 脚本(RCScript) - -```swift -struct RCScript: RCBase { - var id: String - var name: String - var scriptType: ScriptType - var scriptContent: String - var icon: String - var enabled: Bool - var idx: Int - - enum ScriptType: String, Codable { - case shell = "shell" - case python = "python" - case applescript = "applescript" - } -} -``` - ---- - -## 四、消息传输模型(Codable) - -这些模型用于主程序和 Extension 之间的通信,不持久化。 - -### 4.1 动作菜单项(ActionMenuItem) - -```swift -/// Menu item for custom actions (copy path, delete, etc.) -struct ActionMenuItem: Codable { - let id: String - let name: String - let icon: String - let enabled: Bool - - init(id: String, name: String, icon: String, enabled: Bool = true) { - self.id = id - self.name = name - self.icon = icon - self.enabled = enabled - } -} -``` - -### 4.2 应用菜单项(AppMenuItem) - -```swift -/// Menu item for opening files with external applications -struct AppMenuItem: Codable { - let id: String - let name: String - let appURL: URL // Extension 根据路径加载图标 - - init(id: String, name: String, appURL: URL) { - self.id = id - self.name = name - self.appURL = appURL - } -} -``` - -### 4.3 新建文件菜单项(NewFileMenuItem) - -```swift -/// Menu item for creating new files -struct NewFileMenuItem: Codable { - let id: String - let name: String - let ext: String - let icon: String - - init(id: String, name: String, ext: String, icon: String) { - self.id = id - self.name = name - self.ext = ext - self.icon = icon - } -} -``` - -### 4.4 常用目录菜单项(CommonDirMenuItem) - -```swift -/// Menu item for common directories -struct CommonDirMenuItem: Codable { - let id: String - let name: String - let icon: String - - init(id: String, name: String, icon: String) { - self.id = id - self.name = name - self.icon = icon - } -} -``` - -### 4.5 脚本菜单项(ScriptMenuItem) - -```swift -/// Menu item for custom scripts -struct ScriptMenuItem: Codable { - let id: String - let name: String - let icon: String - let scriptType: String // "shell" | "python" | "applescript" - - init(id: String, name: String, icon: String, scriptType: String) { - self.id = id - self.name = name - self.icon = icon - self.scriptType = scriptType - } -} -``` - ---- - -## 五、模型转换 - -### 5.1 SwiftData → Codable(用于发送菜单配置) - -```swift -extension ActionEntity { - func toMenuItem() -> ActionMenuItem { - ActionMenuItem( - id: id, - name: name, - icon: icon, - enabled: isEnabled - ) - } -} - -extension AppEntity { - func toMenuItem() -> AppMenuItem { - AppMenuItem( - id: id, - name: itemName.isEmpty ? url.lastPathComponent : itemName, - appURL: url - ) - } -} - -extension NewFileTypeEntity { - func toMenuItem() -> NewFileMenuItem { - NewFileMenuItem( - id: id, - name: name, - ext: fileExtension, - icon: icon - ) - } -} - -extension CommonDirEntity { - func toMenuItem() -> CommonDirMenuItem { - CommonDirMenuItem( - id: id, - name: name, - icon: icon - ) - } -} - -extension ScriptEntity { - func toMenuItem() -> ScriptMenuItem { - ScriptMenuItem( - id: id, - name: name, - icon: icon, - scriptType: scriptType - ) - } -} -``` - -### 5.2 RCBase → SwiftData(用于保存配置) - -```swift -extension AppEntity { - convenience init(from openWithApp: OpenWithApp, sortOrder: Int = 0) { - self.init( - id: openWithApp.id, - url: openWithApp.url, - itemName: openWithApp.name, - inheritFromGlobalArguments: openWithApp.inheritFromGlobalArguments, - inheritFromGlobalEnvironment: openWithApp.inheritFromGlobalEnvironment, - arguments: openWithApp.arguments, - environment: openWithApp.environment, - sortOrder: sortOrder - ) - } -} - -extension ActionEntity { - convenience init(from action: RCAction) { - self.init( - id: action.id, - name: action.name, - icon: action.icon, - isEnabled: action.enabled, - sortOrder: action.idx - ) - } -} - -extension NewFileTypeEntity { - convenience init(from newFile: NewFile) { - self.init( - id: newFile.id, - fileExtension: newFile.ext, - name: newFile.name, - icon: newFile.icon, - isEnabled: newFile.enabled, - sortOrder: newFile.idx - ) - } -} - -extension CommonDirEntity { - convenience init(from commonDir: CommonDir) { - self.init( - id: commonDir.id, - name: commonDir.name, - path: commonDir.url, - icon: commonDir.icon - ) - } -} - -extension ScriptEntity { - convenience init(from script: RCScript, sortOrder: Int = 0) { - self.init( - id: script.id, - name: script.name, - scriptType: script.scriptType.rawValue, - scriptContent: script.scriptContent, - icon: script.icon, - sortOrder: sortOrder, - isEnabled: script.enabled - ) - } -} -``` - ---- - -## 六、数据持久化配置 - -### 6.1 SwiftData 容器 - -```swift -import SwiftData - -final class SharedDataManager { - static let shared = SharedDataManager() - - let sharedModelContainer: ModelContainer = { - let schema = Schema([ - AppEntity.self, - ActionEntity.self, - NewFileTypeEntity.self, - CommonDirEntity.self, - ScriptEntity.self - ]) - - let modelConfiguration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: false, - cloudKitDatabase: .none - ) - - do { - return try ModelContainer( - for: schema, - configurations: [modelConfiguration] - ) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() -} -``` - -### 6.2 应用组配置 - -为了让 Extension 也能访问数据(虽然在本架构中 Extension 不直接读取),使用应用组共享容器: - -```swift -let modelConfiguration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: false, - containerIdentifier: "group.cn.wflixu.RClick" // App Group ID -) -``` - ---- - -## 七、参考文档 - -- **重构设计**: [../重构设计.md](../重构设计.md) -- **通信协议**: [communication-protocol.md](communication-protocol.md) -- **RCBase 实现**: [../../RClick/Model/RCBase.swift](../../RClick/Model/RCBase.swift) -- **SwiftData 实体**: [../../RClick/Model/](../../RClick/Model/) diff --git a/specs/contracts/extension-reconnect.md b/specs/contracts/extension-reconnect.md deleted file mode 100644 index cbd671f..0000000 --- a/specs/contracts/extension-reconnect.md +++ /dev/null @@ -1,520 +0,0 @@ -# FinderSync Extension 重连方案设计 - -> 创建时间:2026-03-21 -> 最后更新:2026-03-21 -> 状态:设计 v2(基于专家评审优化) -> 架构原则:**推 + 拉混合模式** - ---- - -## 一、问题背景 - -### 1.1 问题描述 - -用户反馈:**主 App 已打开,但 FinderSync Extension 没有加载**,导致右键菜单不显示。 - -### 1.2 FinderSync Extension 生命周期 - -| 阶段 | 说明 | 控制方 | -|------|------|--------| -| **加载** | 系统决定何时加载 Extension | macOS | -| **初始化** | `init()` 方法执行,注册消息处理器 | Extension | -| **监听** | `beginObservingDirectory(at:)` 被调用 | Extension | -| **菜单构建** | `menu(for:)` 每次右键时调用 | Extension | -| **卸载** | 系统资源回收时终止 | macOS | - -### 1.3 问题原因分析 - -1. **系统未激活 Extension**:Finder 可能尚未需要加载 Extension -2. **Extension 进程被终止**:内存压力或长时间未使用 -3. **消息不同步**: - - 主 App 启动了,但 Extension 还未收到 `running` 消息 - - Extension 的 `isHostAppOpen` 标志仍为 `false` -4. **心跳机制失效**:Extension 心跳发送了,但主 App 未正确响应 - ---- - -## 二、核心设计原则 - -### 2.1 四条基本原则(必须遵守) - -| 原则 | 说明 | 违反后果 | -|------|------|----------| -| **Notification ≠ 状态** | 通知只能当"信号",不能当"真相" | 丢消息=状态错乱 | -| **必须存在状态快照** | 否则无法恢复一致性 | 重启后数据丢失 | -| **Extension 必须支持主动拉取** | 不能只靠推送 | 无法自愈 | -| **所有通信必须幂等** | 重复消息不能出错 | 重复执行风险 | - -### 2.2 架构升级:从"纯推"到"推 + 拉混合" - -```mermaid -graph LR - subgraph Before["纯推送模式"] - A1[Main App] -->|发送消息| A2[Extension] - A2 -.->|靠运气接收| A1 - end - - subgraph After["推 + 拉混合模式"] - B1[Main App] <-->|发送/请求| B2[Extension] - B2 -.->|可主动拉取| B1 - end -``` - ---- - -## 三、设计方案 v2 - -### 3.1 四层防护策略 - -| 层级 | 机制 | 作用 | 状态 | -|------|------|------|------| -| L1 | **状态快照** | 主 App 保存最后菜单状态 | ✅ 新增 | -| L2 | **心跳重试** | 主 App 启动后定期发送 running | ✅ 保留 | -| L3 | **主动请求** | Extension 启动/过期时主动请求 | ✅ 新增 | -| L4 | **自动重连** | 心跳超时后自动恢复 | ✅ 保留 | -| L5 | **手动刷新** | 用户菜单触发 | ✅ 保留 | - -### 3.2 架构图 - -```mermaid -graph TB - subgraph MainApp["Main App"] - Snapshot["状态源(唯一真相)
- MenuSnapshot 缓存
- 版本计数器
- Action Handler"] - end - - MainApp -->|推送
信号| Push - MainApp -->|请求/响应
拉取补偿| Pull - MainApp -->|心跳检测
可选| Heartbeat - - subgraph Extension["FinderSync Extension"] - Cache["内存缓存(menu snapshot)"] - Render["UI 渲染(只读缓存)"] - Click["用户点击 → 发消息"] - Request["缓存过期 → 发送 request"] - end - - Push --> Cache - Pull --> Cache - Cache --> Render - Click --> MainApp - Request --> Pull -``` - ---- - -## 四、详细设计 - -### 4.1 状态快照机制(核心) - -#### 主 App 端 - -**设计要点**: -- 主 App 保存最后发送的菜单配置(内存缓存) -- 每次数据变更时更新快照和版本号 -- 响应 Extension 的 `requestConfig` 消息 -- `running` 消息携带 `MenuSnapshot` - -**实现方案**: -```swift -// RClick/RClickApp.swift -class RClickApp: NSObject, NSApplicationDelegate { - // 状态快照(真相源) - private var lastMenuSnapshot: Data? - private var menuVersion: Int = 0 - - // 状态变更时 - func updateMenuConfig() { - menuVersion += 1 - let snapshot = MenuSnapshot(version: menuVersion, config: buildMenuConfig()) - lastMenuSnapshot = try? JSONEncoder().encode(snapshot) - // 发送 running 消息时携带 snapshot - sendRunningMessage(snapshot) - } - - // 响应 Extension 的 config 请求 - func handleConfigRequest() { - if let data = lastMenuSnapshot { - Messager.shared.sendToExtension(.menuConfig, data: data) - } - } -} - -struct MenuSnapshot: Codable { - let version: Int - let config: MenuConfigPayload -} - -// 发送 running 消息 -func sendRunningMessage(_ snapshot: MenuSnapshot) { - Messager.shared.sendToExtension(.running, data: snapshot) -} -``` - -#### Extension 端 - -**设计要点**: -- 启动时主动发送 `menu.request` -- 接收 `menu.update` 时检查版本号(防重复、防乱序) -- `menu(for:)` 只读内存缓存,不做任何 IO - -**实现方案**: -```swift -// FinderSyncExt/FinderSyncExt.swift -class FinderSyncExt: FIFinderSync { - private var currentVersion: Int = 0 - private var cachedMenuConfig: MenuConfigPayload? - - override init() { - super.init() - // 启动时主动请求 - sendMenuRequest() - setupMessageHandlers() - } - - private func handleMenuUpdate(_ data: Data) { - guard let snapshot = decode(data) else { return } - - // 版本检查:只接受更新的版本 - guard snapshot.version > currentVersion else { return } - - currentVersion = snapshot.version - cachedMenuConfig = snapshot.config - } - - // 菜单构建:只读缓存 - override func menu(for menuKind: FIMenuKind) -> NSMenu { - guard let config = cachedMenuConfig else { - // 缓存为空,触发请求 - sendMenuRequest() - return NSMenu(title: "RClick (loading...)") - } - // 使用缓存构建菜单... - } -} -``` - ---- - -### 4.2 心跳重试机制(保留) - -**设计思路**: -- 主 App 启动后发送 `running` 消息(携带 `MenuSnapshot`) -- 每 5 秒重试一次,持续 30 秒(6 次) -- 确保 Extension 加载后能收到消息 - -**修改要点**: -- `running` 消息携带 `MenuSnapshot`,而非单独发送菜单配置 -- Extension 收到 `running` 后进行版本检查,更新缓存 - ---- - -### 4.3 主动请求机制(新增) - -#### 触发时机 - -| 时机 | 说明 | -|------|------| -| Extension 启动时 | `init()` 中发送 `requestConfig` | -| 缓存为空时 | `menu(for:)` 发现缓存为空 | -| 缓存过期时 | 超时未收到更新(可选:30 秒) | -| 版本不匹配时 | 检测到数据不一致 | - -#### 请求流程 - -```mermaid -sequenceDiagram - participant Ext as Extension - participant App as Main App - - Ext->>App: requestConfig - App->>App: 查找 lastMenuSnapshot - App->>Ext: menuConfig (携带配置数据) - Ext->>Ext: 版本检查 - Ext->>Ext: 缓存更新 -``` - -#### 实现方案 - -```swift -// Extension 端 -class FinderSyncExt: FIFinderSync { - private var currentVersion: Int = 0 - private var cachedMenuConfig: MenuConfigPayload? - - override init() { - super.init() - setupMessageHandlers() - // 启动时主动请求配置 - sendMenuRequest() - } - - private func handleRunning(_ data: Data?) { - guard let snapshot = decode(data) else { return } - // 版本检查:只接受更新的版本 - guard snapshot.version > currentVersion else { return } - currentVersion = snapshot.version - cachedMenuConfig = snapshot.config - } - - private func handleMenuConfig(_ data: Data?) { - guard let config = decode(data) else { return } - cachedMenuConfig = config - } - - // 菜单构建:只读缓存 - override func menu(for menuKind: FIMenuKind) -> NSMenu { - guard let config = cachedMenuConfig else { - // 缓存为空,触发请求 - sendMenuRequest() - return NSMenu(title: "RClick (loading...)") - } - // 使用缓存构建菜单... - } -} -``` - ---- - -### 4.4 自动重连机制(优化) - -**设计要点**: -- 主 App 端添加心跳超时检测(15 秒) -- 超时后标记 Extension 为离线状态 -- 用户可点击"刷新 Extension"按钮触发重连 - -**实现方案**: -```swift -// Main App 端 -private var heartbeatTimeoutTimer: Timer? -private var isExtensionOnline: Bool = false - -func handleHeartbeat() { - isExtensionOnline = true - // 重置超时定时器 - heartbeatTimeoutTimer?.invalidate() - heartbeatTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in - self?.handleHeartbeatTimeout() - } -} - -func handleHeartbeatTimeout() { - isExtensionOnline = false - // 菜单栏显示"扩展未响应" - // 用户可点击"刷新 Extension"按钮 -} - -// 用户点击"刷新 Extension"时 -func refreshExtension() { - if let snapshot = lastMenuSnapshot { - Messager.shared.sendToExtension(.running, data: snapshot) - } -} -``` - ---- - -### 4.5 手动刷新机制(保留) - -**设计要点**: -- 用户点击"刷新 Extension"按钮 -- 触发主 App 重新发送快照 -- 同时通知用户结果 - ---- - -## 五、消息协议设计 - -### 5.1 消息类型定义 - -**基于通信协议**: - -| 消息类型 | 方向 | 枚举值 | 说明 | -|---------|------|--------|------| -| `requestConfig` | Ext→App | `ExtensionToMainAction.requestConfig` | Extension 请求菜单配置 | -| `menuConfig` | App→Ext | `MainToExtensionAction.menuConfig` | 主 App 推送菜单配置 | -| `running` | App→Ext | `MainToExtensionAction.running` | 主 App 启动通知(携带 MenuSnapshot) | -| `click` | Ext→App | `ExtensionToMainAction.click` | 用户点击菜单项 | -| `heartbeat` | Ext→App | `ExtensionToMainAction.heartbeat` | 心跳消息 | - -### 5.2 菜单数据结构 - -```swift -struct MenuSnapshot: Codable { - let version: Int // 版本号,用于防重防乱序 - let config: MenuConfigPayload -} - -// running 消息携带 MenuSnapshot -func sendRunningMessage() { - let snapshot = MenuSnapshot(version: menuVersion, config: buildMenuConfig()) - Messager.shared.sendToExtension(.running, data: snapshot) -} -``` - ---- - -## 六、实施步骤 - -### Phase 1: 状态快照(P0)⭐ 核心 - -**目标**:建立"真相源" - -**步骤**: -1. 在 `RClickApp.swift` 添加 `lastMenuSnapshot` 和 `menuVersion` -2. 每次菜单配置变更时更新快照 -3. 注册 `requestConfig` 消息处理器 -4. `running` 消息携带 `MenuSnapshot` - -**验收标准**: -- [ ] 主 App 保存最后菜单状态 -- [ ] `running` 消息携带 `MenuSnapshot` -- [ ] 响应 `requestConfig` 请求时发送快照 - ---- - -### Phase 2: 主动请求(P0)⭐ 核心 - -**目标**:Extension 支持主动拉取 - -**步骤**: -1. 在 `FinderSyncExt.swift` 添加 `sendMenuRequest()` 方法 -2. `init()` 中启动时发送请求 -3. `menu(for:)` 中缓存为空时触发请求 -4. 接收更新时检查版本号 - -**验收标准**: -- [ ] Extension 启动后主动请求菜单 -- [ ] 版本检查正常工作 -- [ ] 缓存为空时自动请求 - ---- - -### Phase 3: 心跳重试(P1) - -**目标**:覆盖"刚启动"场景 - -**步骤**: -1. 在 `RClickApp.swift` 添加重试定时器 -2. 每 5 秒发送 `running` 消息(携带 `MenuSnapshot`),持续 30 秒 - -**验收标准**: -- [ ] 主 App 启动后发送 6 次 `running` 消息 - ---- - -### Phase 4: 心跳检测(P1) - -**目标**:检测 Extension 存活状态 - -**步骤**: -1. 添加心跳超时检测(15 秒) -2. 超时后标记 Extension 离线 -3. 用户可手动"刷新 Extension" - -**验收标准**: -- [ ] Extension 每 10 秒发送 `heartbeat` -- [ ] 主 App 15 秒未收到心跳,标记离线 -- [ ] 用户可点击"刷新 Extension"按钮 - ---- - -### Phase 5: 手动刷新(P2) - -**目标**:用户兜底控制 - -**步骤**: -1. 菜单栏添加"刷新 Extension"按钮 -2. 实现 `refreshExtension()` 方法 - -**验收标准**: -- [ ] 点击按钮后菜单更新 -``` - -## 七、预期效果 - -### 7.1 场景覆盖 - -| 场景 | 行为 | 结果 | -|------|------|------| -| 主 App 启动 | 发送 `running` × 6 + 保存快照 | Extension 加载后收到消息 | -| Extension 重启 | 主动发送 `requestConfig` | 立即获取最新状态 | -| 缓存为空 | `menu(for:)` 触发请求 | 自愈恢复 | -| 消息丢失 | 下次请求获取快照 | 状态一致 | -| Extension 超时 | 15 秒未收到 `heartbeat` | 标记离线,用户可刷新 | -| 用户手动刷新 | 点击菜单按钮 | 立即刷新 | - -### 7.2 能力评估 - -| 能力 | 之前 | 升级后 | -|------|------|--------| -| Extension 随机重启 | ❌ | ✅ | -| Notification 丢失 | ❌ | ✅ | -| 主 App 先启动 | ⚠️ | ✅ | -| Extension 后启动 | ⚠️ | ✅ | -| Finder 延迟加载 | ❌ | ✅ | -| 用户频繁点击 | ✅ | ✅ | -| 状态一致性 | 🟡 弱 | 🟢 接近强一致 | - ---- - -## 八、验证方法 - -### 8.1 日志检查 - -```bash -# 查看主 App 日志 -log show --predicate 'subsystem == "cn.wflixu.RClick"' --last 5m - -# 查看 Extension 日志 -log show --predicate 'subsystem == "cn.wflixu.RClick" AND category == "FinderOpen"' --last 5m -``` - -### 8.2 功能测试 - -1. **测试状态快照**: - - 启动主 App,修改菜单配置 - - 终止 Extension - - 重启 Extension,验证收到最新配置 - -2. **测试主动请求**: - - 终止 Extension - - 重启 Extension - - 观察日志中 `requestConfig` 和 `menuConfig` 消息 - -3. **测试版本检查**: - - 快速多次修改配置 - - 验证 Extension 只接受最新版本 - -4. **测试心跳检测**: - - 启动主 App 和 Extension - - 使用 `killall FinderSyncExt` 终止 Extension - - 观察主 App 是否在 15 秒后标记离线 - ---- - -## 九、注意事项 - -### 9.1 设计约束 - -1. **Notification 只是信号**:通知只能当"信号",不能当"真相" -2. **状态快照是唯一真相**:主 App 保存 `lastMenuSnapshot` 作为真相源 -3. **版本号必须单调递增**:防止乱序问题 -4. **消息处理必须幂等**:重复消息不能产生副作用 -5. **双向心跳机制**: - - Main → Ext: `running`(启动时 + 重试 6 次) - - Ext → Main: `heartbeat`(每 10 秒) - -### 9.2 工程建议 - -1. **日志记录**:所有关键操作都应记录日志 -2. **版本检查**:接收更新时检查版本号 -3. **去重机制**:使用消息 ID 防止重复处理 -4. **防抖处理**:频繁更新时 debounce(100-300ms) - ---- - -## 十、参考文档 - -- **communication-protocol.md**:通信协议设计 -- **重构设计.md**:整体架构设计 -- **重构计划.md**:实施计划 diff --git a/specs/external-volumes-observation.md b/specs/external-volumes-observation.md new file mode 100644 index 0000000..6cef98b --- /dev/null +++ b/specs/external-volumes-observation.md @@ -0,0 +1,51 @@ +# External and network volume observation (#148, #150) + +Finder observation and file-operation authorization are separate. Registering a +security-scoped bookmark in the app does not add a Finder Sync observation root. +The old extension registered only `/`, once at startup. It neither enumerated +mounted volumes nor handled volume lifecycle changes. + +Apple documents recursive subdirectory observation, but does not explicitly +promise traversal across volume boundaries. Adding `/Volumes` alone still relies +on that assumption and misses volumes mounted elsewhere. Register `/` plus all +URLs from `FileManager.mountedVolumeURLs(includingResourceValuesForKeys:nil, +options:[])`, without filtering out network, hidden, or non-removable volumes. +No directory traversal or volume resource-value queries are needed. + +Subscribe to `NSWorkspace.shared.notificationCenter` before taking the initial +snapshot. Apply mount/unmount/rename notification URLs directly to the current +set. A real temporary HFS+ DMG probe demonstrated that, during didUnmount, volume +enumeration can still return the ejected volume. Blindly rescanning on every +notification therefore leaves stale roots. Re-enumerate only when notification +URLs are unavailable or the initial snapshot has not succeeded; preserve the +previous set on enumeration failure and apply event paths after any retry. Assign +`directoryURLs` only when the set changes. Observer lifetime follows the extension; +UI-framework interaction and observer cleanup use the main actor. + +## Validation + +- Release build and the complete existing test suite passed with + `CODE_SIGNING_ALLOWED=NO` under Swift 6. +- Regression test `notificationPathsOverrideStaleVolumeEnumeration` failed with + rescan-only handling and passed after applying notification URLs. +- Tests cover startup volumes, simulated external/NAS/custom mount paths, + mount/unmount/rename, duplicate events, failed enumeration and cleanup. +- A temporary DMG using the production observer and real NSWorkspace notifications + produced `probe-volume-present=false → true → false` without restarting the + observer. The DMG was ejected after the probe. +- Normal signing remains blocked by missing upstream provisioning profiles. + This verifies observation-root management, not a signed Finder installation or + physical USB / SMB / NFS / AFP menu appearance and action execution. + +After signing and enabling the extension, check a volume mounted before launch, +a USB drive and NAS mounted while running, a mounted DMG, rename/eject/remount, +and a normal local folder. Check menus inside each volume as well as on its root; +then verify an innocuous action such as Copy Path. File Provider-managed locations +and sandbox permissions are separate concerns; this change does not bypass them. + +Sources: +- https://developer.apple.com/documentation/findersync/fifindersynccontroller/directoryurls +- https://developer.apple.com/documentation/foundation/filemanager/mountedvolumeurls(includingresourcevaluesforkeys:options:) +- https://developer.apple.com/documentation/appkit/nsworkspace/didmountnotification +- https://developer.apple.com/documentation/appkit/nsworkspace/didunmountnotification +- https://developer.apple.com/documentation/appkit/nsworkspace/didrenamevolumenotification diff --git "a/specs/v2.2.0-\351\207\215\346\236\204\346\203\263\346\263\225.md" "b/specs/v2.2.0-\351\207\215\346\236\204\346\203\263\346\263\225.md" deleted file mode 100644 index 90cc50b..0000000 --- "a/specs/v2.2.0-\351\207\215\346\236\204\346\203\263\346\263\225.md" +++ /dev/null @@ -1,388 +0,0 @@ -对,如果确定采用 **Finder Extension + Main App + Helper 进程**,我建议架构不要搞成三个进程互相平等通信,而是明确一个中心: - -> **Helper 是核心服务,Main App 是控制面,Finder Extension 是 Finder 适配层。** - -这样职责最清楚。 - -## 推荐架构 - -```text - ┌──────────────────────┐ - │ Main App │ - │ Settings / UI │ - │ │ - │ • 菜单编辑 │ - │ • Action 配置 │ - │ • 脚本配置 │ - │ • 偏好设置 │ - │ • Runtime 管理 │ - └──────────┬───────────┘ - │ - ActorLink IPC - LocalSocket - │ - ▼ -┌──────────────────────┐ ┌──────────────────────┐ -│ Finder Extension │ │ Helper │ -│ │ │ RClick Runtime │ -│ • 获取 Finder 上下文 │ │ │ -│ • 请求菜单 │◀│ • MenuService │ -│ • 创建 Finder 菜单 │ │ • ActionService │ -│ • 用户点击动作 │▶│ • ScriptService │ -│ │ │ • ConfigService │ -└──────────┬───────────┘ │ • PluginService │ - │ │ • IPC Server │ - │ └──────────────────────┘ - │ - ▼ - Finder -``` - -核心原则是: - -> **所有业务逻辑尽量只存在 Helper 一份。** - ---- - -# 1. Main App:主要是 Settings,但不只是“一个配置文件编辑器” - -你的理解基本对。Main App 应该是 **Settings / Control Center**。 - -它不应该负责: - -* 生成 Finder 菜单 -* 执行 Shell -* 执行具体 Action -* 常驻后台提供业务服务 - -它主要负责让用户**管理 RClick**。 - -### Main App 的职责 - -#### 配置 - -例如: - -* 创建菜单 -* 调整菜单顺序 -* 创建 Action -* 编辑 Shell Script -* 设置图标 -* 配置快捷参数 -* 启用/禁用菜单项 - -```text -用户 - ↓ -Main App UI - ↓ -Config API - ↓ -RClick Runtime -``` - -这里我建议**配置的最终所有权也在 Helper**。 - -Main App 不直接成为“配置数据库中心”,而是: - -```swift -await runtime.updateConfig(config) -``` - -Runtime 负责验证、保存和重新加载。 - -这样以后即使 Main App 不启动,Runtime 仍然拥有完整状态。 - ---- - -#### 管理 - -Main App 还可以负责: - -* 启动/停止 Runtime -* 显示 Runtime 状态 -* 查看日志 -* 检查版本 -* 管理插件 -* 导入/导出配置 -* 恢复默认配置 - -所以更准确地说: - -> **Main App = RClick 的 Settings + Management UI。** - -而不仅仅是 Settings。 - ---- - -# 2. Helper:真正的 RClick 核心 - -我建议代码和架构中叫: - -```text -RClickRuntime -``` - -部署层面它是 Helper / Background Process。 - -但从职责来说,它是 Runtime。 - -它应该拥有所有核心能力: - -```text -RClickRuntime -│ -├── IPC Server -│ -├── ConfigService -│ -├── MenuService -│ -├── ActionService -│ -├── ScriptService -│ -├── PluginService -│ -├── PermissionService -│ -└── Logger -``` - -例如 Finder Extension 问: - -> 当前选中了这三个文件,应该显示什么菜单? - -Extension 不应该自己判断: - -```swift -if file.isImage { - ... -} -``` - -而是发送: - -```text -MenuContext - ↓ -RClickRuntime - ↓ -MenuService - ↓ -返回 MenuDefinition -``` - -Finder Extension 根据结果创建 Finder 菜单。 - -用户点击: - -```text -Finder - ↓ -Finder Extension - ↓ -ExecuteActionRequest - ↓ -RClickRuntime - ↓ -ActionService - ↓ -执行 -``` - -这样业务逻辑全部集中在一个地方。 - ---- - -# 3. Finder Extension:应该尽可能薄 - -这是三个组件中最应该“克制”的一个。 - -它只负责 Finder API。 - -可以理解为: - -> **Finder ↔ RClick Runtime 的 Adapter。** - -它负责两类事情。 - -### 获取上下文 - -例如: - -* 当前选中的文件 -* 文件 URL -* 当前目录 -* 是否多选 -* 文件类型 - -然后转换成 RClick 的通用数据: - -```swift -let context = SelectionContext(...) -``` - -发送给 Runtime。 - -### 渲染菜单 - -Runtime 返回: - -```text -MenuDefinition -├── Open Terminal -├── Copy Path -├── Git -│ ├── Commit -│ └── Push -└── Custom Script -``` - -Finder Extension 把它转换成: - -```text -NSMenu -``` - -因此 Finder Extension **知道 NSMenu,但 Runtime 不需要知道 NSMenu**。 - -这是非常重要的边界。 - -Runtime 最好只返回自己的模型: - -```swift -struct MenuItemDefinition: Codable { - let id: String - let title: String - let children: [MenuItemDefinition] -} -``` - -Extension 负责: - -```swift -MenuItemDefinition → NSMenu -``` - ---- - -# 三者的责任边界 - -| 功能 | Main App | Finder Extension | Runtime | -| -------------- | -------- | ---------------- | ------- | -| SwiftUI 设置界面 | ✅ | ❌ | ❌ | -| 编辑菜单 | ✅ UI | ❌ | ✅ 保存/验证 | -| 生成业务菜单 | ❌ | ❌ | ✅ | -| NSMenu 创建 | ❌ | ✅ | ❌ | -| 获取 Finder 选中文件 | ❌ | ✅ | ❌ | -| 判断哪些菜单可用 | ❌ | ❌ | ✅ | -| 执行 Action | ❌ | ❌ | ✅ | -| 执行 Shell | ❌ | ❌ | ✅ | -| 管理配置 | UI | ❌ | ✅ | -| 插件加载 | UI 管理 | ❌ | ✅ | -| IPC | Client | Client | Server | - -这张表基本就是整个架构的核心。 - ---- - -# Main App 到底应该直接读写配置文件吗? - -**我的建议是不直接读写。** - -例如不要这样: - -```text -Main App ──────写 JSON──────► Config File - ▲ - │ -Runtime ───────────────────────────┘ -``` - -这会产生两个问题: - -1. 谁才是配置的真正拥有者? -2. Runtime 正在读取配置时,Main App 修改怎么办? - -更好的方式: - -```text -Main App - │ - │ ActorLink - ▼ -ConfigService - │ - ▼ -Config Storage -``` - -也就是说: - -> **Runtime 是唯一的业务状态 Owner。** - -Main App 只是客户端。 - -当然 Runtime 可以把配置保存成 JSON、SQLite 或其他方式,但这是 Runtime 内部实现。 - ---- - -# 最终我推荐的职责定义 - -### Main App - -> **Configuration & Management UI** - -用户操作的控制台。 - -### Finder Extension - -> **Finder Integration Adapter** - -负责接入 Finder,不负责业务。 - -### RClick Runtime - -> **Core Business Runtime** - -真正的 RClick。 - -### ActorLink - -> **IPC Runtime** - -负责进程间通信,本身不知道 RClick 的业务。 - ---- - -所以回答你最初的问题: - -> **Main App 只配置吗?** - -**大体上是,但我会说得更准确一点:Main App 负责“配置和管理”,Helper 负责“运行和执行”。** - -这条边界非常适合 RClick: - -```text -Main App -配置、管理、观察 - -Finder Extension -接入、获取上下文、显示菜单 - -RClick Runtime -判断、处理、执行、保存状态 -``` - -我认为这比“Main App 是核心,Helper 只是辅助进程”的架构更适合 RClick。尤其未来你增加 **Quick Action、CLI 或其他入口**时,它们都只需要变成新的 Adapter: - -```text -Settings ──────┐ -FinderSync ────┤ -Quick Action ──┼── ActorLink ──► RClick Runtime -CLI ───────────┤ -未来入口 ──────┘ -``` - -这样三进程架构的核心不会变,扩展入口也不会导致业务逻辑重复。 diff --git "a/specs/v2.2.0-\351\207\215\346\236\204\350\256\241\345\210\222.md" "b/specs/v2.2.0-\351\207\215\346\236\204\350\256\241\345\210\222.md" deleted file mode 100644 index 0c6606e..0000000 --- "a/specs/v2.2.0-\351\207\215\346\236\204\350\256\241\345\210\222.md" +++ /dev/null @@ -1,189 +0,0 @@ -# RClick v2.2.0 重构计划(沙盒 App Store 方向 · 双进程 + 进程内 RCRuntime) - -> 状态:已通过两轮评审(apple-ecosystem-expert + 架构评审);**A–E 实施完成**(Phase A 分层 / B 回归 / C 沙盒 / D 授权打磨),剩余交互式 Finder 回归待手动验证 -> 来源:开发者头脑风暴 + [v2.2.0-重构想法.md](v2.2.0-重构想法.md) 建议评估 -> 日期:2026-08-23 - -## 最重要的前提(写给未来所有 Agent) - -> **双进程 = Main App + Finder Extension。RCRuntime 是 Main App 进程内部的架构层,不是独立进程 / Helper。** -> 不要把它理解成 `RClickRuntime.app`。绝不因此引入第三进程(V2 不解决"Main App 退出后仍工作"的需求,无需求无抽象)。 - ---- - -## Context - -- **上架目标 = Mac App Store** → 主 app 与扩展**必须全沙盒**,security-scoped bookmark 是唯一文件夹授权途径(已实现,只打磨不推翻)。 -- **否决三进程 Helper/Runtime 架构**:MAS 下所有进程必须沙盒,"非沙盒 helper"不可能;且"用户主动退出 app 后右键仍可用"非需求。 -- **采纳"双进程 + 进程内 RCRuntime"**:Main App 内部按 Runtime 服务层组织,业务逻辑单一所有者,Extension 保持薄 Finder 适配层。 -- 语言切换早已删除(`22dd287`);登录启动失效是 bug(`796d07f`)。 - -## 最终架构 - -```mermaid -graph TB - subgraph MainApp["RClick.app"] - UI["Settings UI"] - subgraph RT["RCRuntime(进程内,非独立进程)"] - AS["AppState
运行状态:isRunning / menuConfig / connectionState"] - CS["ConfigService
配置读写/持久化:load/save/update/reset"] - MS["MenuService"] - AcS["ActionService"] - PS["PermissionService"] - BM["BookmarkManager"] - IPC["Messenger / IPC"] - end - UI --> AS - UI --> CS - CS --> MS - MS --> AS - AcS --> PS - PS --> BM - AcS --> MS - end - IPC <-->|"DistributedNotificationCenter + HMAC"| EXT["Finder Extension
Finder 适配层 / 菜单渲染 / 事件转发"] -``` - -## 评审采纳清单 - -| # | 意见 | 采纳 | -|---|------|------| -| 1 | 明确 RCRuntime 是进程内 Runtime,非独立 Helper | ✅ 见上方"最重要的前提" | -| 2 | heartbeat 不是可靠性机制;真存活判断 = 能否完成一次请求 | ✅ 重构 C 块(见 C) | -| 3 | BookmarkManager 上再加 PermissionService 层 | ✅ E3 | -| 4 | ActionService 避免变成第二个 God Object,但不过度拆分 | ✅ E2 | -| 5 | AppState 与 ConfigService 分离 | ✅ E4 | -| 6 | 删掉 ScriptService 占位(无需求无抽象) | ✅ 已移除 | -| 7 | 沙盒单独作为高风险 Phase,不混合重构 | ✅ 执行顺序 | -| 8 | 增加完整 Bookmark 回归测试矩阵 | ✅ 验证节 | -| 9 | 补 `network.client` entitlement | ✅ D1 | -| 10 | Updater 与 MAS 冲突,MAS 版删自更新 | ✅ D4 | -| 11 | `.quit` 立即置离线 + 线程安全 | ✅ C | -| 12 | 常用目录添加时即存 bookmark(根因修复) | ✅ B4 | - ---- - -## A. 修复登录启动 - -**现状**:工作区已恢复 `LaunchAtLogin.Toggle` + `migrateLegacyLaunchAtLoginSetting()`(方向对)。 - -**改动**: -1. `LaunchAtLogin.swift` — 补警告 UI:处理 `SMAppService.mainApp.status` 全部非 `.enabled` 情形(`.requiresApproval`/`.notRegistered`/`.notFound`),覆盖"需装 /Applications"与"需用户批准"两种文案。 -2. `GeneralSettingsTabView.swift` — 登录项 footer:开关开且 status 非 `.enabled` 时显示提示。 -3. Debug 处理:`Bundle.main.bundlePath.hasPrefix("/Applications")` 快速短路 + status 兜底,避免 DerivedData 下假装成功。 - ---- - -## B. 打磨 bookmark 授权体验 - -1. `BookmarkManager.restoreBookmarks` — 区分失效:**resolve 抛错** → 删 `BookmarkEntity`;**`startAccessing` 返回 false** → 不立即删(外置盘未挂载等临时失败),重试一次或跳过。 -2. `promptForPermission` — 优化 NSOpenPanel 文案(为何需要 / 可选更上层覆盖更多 / 可在设置管理)。同步 `Localizable.xcstrings`(en/zh-Hans/ja/es)。 -3. `FolderPermissionsSheetView` — 行显示文件夹名 + 路径 + "包含所有子文件夹";改进空状态。 -4. **常用目录根因修复** — `CommonDirsSettingTabView` 添加目录时**即 `saveBookmark`**,避免沙盒下 openCommonDirs 每次点击都弹授权。 - ---- - -## C. 扩展离线 → 连接状态 UX + 操作失败明确提示 - -**原则**:**"进程活着 ≠ IPC 可用"**。heartbeat/超时**不是可靠性机制**,只是"连接状态缓存 + UX 优化";真正的存活判断 = **能否完成一次请求**。 - -**改动**(`FinderSyncExt/FinderSyncExt.swift`): -1. **连接状态缓存(UX 层)**:`lastMainActivity` 在收到 `.menuConfig`/`.running` 时更新;`connectionState = Date().timeIntervalSince(lastMainActivity) < 30` 时视为"在线"。菜单降级基于此缓存(离线 → 单个置灰项"RClick is not running" + 副标题"启动 RClick 后菜单自动恢复",本地化,优先级高于 "loading…")。这只决定**菜单展示**,不决定操作成败。 -2. **点击时的真实判断**:用户点击动作时—— - - 若 `connectionState == offline` → 直接本地 Alert "RClick 未运行,无法执行该操作"(不静默失败)。 - - 若在线但实际无响应 → 加轻量 **ack**:主 app 收到 `.click` 后回 `.actionAck`;扩展发 click 后设短超时(~3s),未收到 ack → Alert "操作未执行,RClick 无响应"。⚠️ 需在 `Messager` 协议加 `actionAck` 消息(主 app → 扩展)。 -3. **`.quit` 处理器立即置 offline**(消除"退出后 30s 假活",现在 `.quit` 只记日志)。 -4. **线程安全**:`lastMainActivity` 统一主线程读写(或加锁),Swift 6 下 DNC 回调线程写、`menu(for:)` 主线程读必须显式同步。 -5. 主 app 恢复后 ~10s 内菜单自动恢复。 - ---- - -## D. App Store 合规 + 清理 - -### D0. 开启主 app 沙盒(独立高风险 Phase,见执行顺序) -- `project.pbxproj` — 主 app Debug & Release `ENABLE_APP_SANDBOX = YES`。 -- ⚠️ 回归重点:沙盒下 bookmark 是唯一访问通道。`openApp`/`openCommonDirs` 未做门控 → E2 一并补(showAirDrop 已门控)。`openApp` 的 WezTerm 硬编码 hack 沙盒下失效,删除。 - -### D1. entitlements 清理 -- `RClick.entitlements` — 加 `com.apple.security.app-sandbox = true` 和 **`com.apple.security.network.client`**(Updater GitHub API 必需);**删** `temporary-exception.files.home-relative-path.read-write` 与 `temporary-exception.apple-events`(已验证无 Apple Events 发送代码)。保留 app-groups、bookmarks.app-scope、user-selected.read-write。 -- `RClickDebug.entitlements` — 与 Release 对齐(当前缺 `user-selected.read-write`,沙盒 Debug 授权会失效)。 -- `project.pbxproj` — 移除 `ENABLE_USER_SELECTED_FILES = readonly`(冲突)。 -- 更新 `design-bookmark-permission.md`"保持不变"一节。 - -### D2. 死代码清理 -- `MessageSecurity.swift` — 删 `SenderValidator`(死代码、bundle ID 错、DNC 无可靠发送者标识)。保留 HMAC。 -- `Messager.swift` — 删未接线的 `respondMenuConfigRequest`;**加 `actionAck` 消息**(配合 C2)。 - -### D3. 过期文档更新 -- `CLAUDE.md` — 修正 PermDir→BookmarkEntity、Models.swift→各实体、`RClick.MessageFromFinder/MessageFromApp`→`RClick.MainToExtension`/`RClick.ExtensionToMain`、MenuItemClickable.swift→已删、目录结构。 -- `重构设计.md` — 标注 FDA 方向已被 `design-bookmark-permission.md` 取代。 -- `README.md`/`SECURITY.md` — FDA→文件夹访问 bookmark 措辞。 - -### D4. Updater 与 MAS 冲突 -- `Updater.swift` 用 `Process(unzip)` + 自替换 /Applications bundle,MAS 审核违规(app 只能经 App Store 更新)。 -- **MAS 版**:删自更新,改"打开 Mac App Store 页面";**Developer ID 版**:保留。 -- 用 build config / 编译宏隔离(如 `#if !APPSTORE`),不简单删代码。 - -### D5. Info.plist 审核准备 -- 加 `LSApplicationCategoryType`。 -- 不设 `NSSupportsSuddenTermination`(避免猝死导致 `.quit` 不投递,破坏 C 块)。 - -### D6. MAS 审核说明文案准备 -给审核员准备两点说明(提交时附上): -1. **全盘监听**:扩展通过 `FIFinderSyncController.directoryURLs = ["/"]` 监听所有可达目录,是为了让 RClick 右键菜单出现在任意文件夹/磁盘(含外接盘)。FinderSync 扩展只能被 Finder 调用、不能后台常驻,不涉及用户隐私数据收集。 -2. **辅助功能 + 模拟按键**:`createFile` 后使用 Accessibility API + `CGEvent` 模拟一次回车触发 Finder 重命名,仅在新文件创建那一刻生效,需要用户在"隐私与安全性 → 辅助功能"中授权,无持续监听。 - ---- - -## E. 进程内 RCRuntime 分层(Phase A 主体) - -**改动**(新文件放 `RClick/Runtime/`,经 FileSystemSynchronizedRootGroup 自动进主 app target): - -1. **E1 `MenuService`** — 抽 `RClickApp.swift:210-239` 菜单配置构建为 `MenuService.buildConfig(state:) -> MenuConfigPayload`。 -2. **E2 `ActionService`** — 抽 `actionHandler`/`copyPath`/`deleteFoldorFile`/`hideFilesAndDirs`/`unhideFilesAndDirs`/`showAirDrop`/`createFile`/`openApp`/`openCommonDirs` 等。**避免变成 God Object**:第一版用"Service + 私有方法 + `// MARK:` 分组(FileActions / AppActions / FinderActions / SystemActions)",**不建十几个 Protocol、不拆细类**。依赖 `AppState`(只读状态)+ `PermissionService`(经协议注入)。openApp 门控:先尝试 `NSWorkspace.open`、completion 报错再弹授权重试;顺带修 `URL(fileURLWithPath: isDirectory: true)` 把文件当目录的 pre-existing bug。 -3. **E3 `PermissionService` 层(评审必改)** — ActionService 不直接依赖 `BookmarkManager`。新增 `PermissionService`(暴露 `hasAccess`/`promptForPermission`/`saveBookmark`),内部委托 `BookmarkManager`。抽协议 `PermissionProviding`,注入 mock 以可测。**理由**:今天实现是 Security-Scoped Bookmark,明天可能是 User Selected/Container,V3 非沙盒可能是 Direct File Access —— ActionService 不应感知实现。 -4. **E4 `ConfigService` 与 `AppState` 分离(评审必改)** — `AppState` = 运行状态(isRunning / menuConfig / connectionState);`ConfigService` = 配置读写/持久化(load/save/update/reset,含 SwiftData 操作)。把现 `AppState` 里的 load/save/reindex 逻辑迁到 `ConfigService`。 -5. **E5 `RCRuntime` 聚合** — 只做依赖容器/惰性初始化,持有 AppState / ConfigService / MenuService / ActionService / PermissionService / Messenger。**不是新的 God Object**,AppDelegate 按需取。 -6. **E6 `ScriptService`:不做(评审必改)** — 脚本功能未实现,不建占位。等真正做脚本时再加。保持"没有需求就没有抽象"。 -7. **E7 `Messager` 数据竞争修复** — handler 字典 `nonisolated(unsafe)` 可变字典,主线程写、DNC 回调线程读,Swift 6 下加锁或 mailbox。 - -**可测性**:`PermissionProviding` + `ActionStateProviding`(getAppItem/getActionItem/getFileType)协议注入 mock;文件操作用临时目录 fixture;`hasAccess` 前缀匹配单独单测。 - ---- - -## 执行顺序(沙盒单独成 Phase) - -1. **Phase A — 纯代码分层(不动沙盒)**:E(MenuService/ActionService/PermissionService/ConfigService/RCRuntime/Messager 数据竞争)→ A(登录)→ D2/D3(死代码 + 文档)。此阶段**非沙盒行为必须完全不变**。 -2. **Phase B — 验证非沙盒行为不变**:现有全部功能回归(文件操作、菜单、授权、登录)。 -3. **Phase C — 建立沙盒 build(高风险,单独)**:D0(开沙盒)→ D1(entitlements 含 network.client)→ D4(Updater 隔离)→ D5(Info.plist)→ D6(审核说明)。 -4. **Phase D — 沙盒下逐项验证文件操作**:跑测试矩阵 + B(授权打磨 + 常用目录根因)。 - ---- - -## 验证(端到端) - -**Bookmark 回归测试矩阵**(沙盒构建,Phase D 逐项跑): - -| 场景 \ 状态 | 未授权 | 已授权 | 重启后 | -|---|---|---|---| -| Desktop(TCC 保护) | ❌ 弹授权 | ✅ | ✅ | -| Documents | ❌ 弹授权 | ✅ | ✅ | -| Downloads | ❌ 弹授权 | ✅ | ✅ | -| 自定义文件夹 | ❌ 弹授权 | ✅ | ✅ | -| 子目录(继承父授权) | 继承 | ✅ | ✅ | -| 多选文件 | ❌ 弹授权 | ✅ | ✅ | -| 删除 | ❌ | ✅ | ✅ | -| 新建 | ❌ | ✅ | ✅ | -| 隐藏/显示 | ❌ | ✅ | ✅ | -| 打开 App(openApp 门控) | ❌ | ✅ | ✅ | -| 常用目录打开 | 添加时即授权 | ✅ | ✅ | - -> 重点:**bookmark 重启后是否仍有效**是沙盒架构最关键的单测。 - -其他验证: -- **构建**:`xcodebuild -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS'`(沙盒配置,无警告/错误)。 -- **登录**:装 /Applications → 开登录项 → 重启生效;Xcode 跑 → 提示"需装 /Applications / 需批准"。 -- **降级**:退出主 app → **立即**置灰 + 点击弹"RClick 未运行";杀主 app → ≤30s 置灰;在线但 ack 超时 → "操作未执行"提示;重启 → ~10s 恢复。 -- **Updater**:MAS 版无自更新、跳 App Store;Developer ID 版自更新可用(network.client 下)。 -- **分层**:ActionService 单测(mock PermissionProviding/ActionStateProviding)通过;Phase A 后 AppDelegate 瘦身、非沙盒行为零回归。 -- **合规**:Archive 沙盒配置跑 App Store 校验(无 temporary-exception、含 network.client)。 diff --git "a/specs/\346\236\266\346\236\204\351\207\215\346\236\204\350\256\276\350\256\241\345\217\212\350\256\241\345\210\222.md" "b/specs/\346\236\266\346\236\204\351\207\215\346\236\204\350\256\276\350\256\241\345\217\212\350\256\241\345\210\222.md" deleted file mode 100644 index 15635ea..0000000 --- "a/specs/\346\236\266\346\236\204\351\207\215\346\236\204\350\256\276\350\256\241\345\217\212\350\256\241\345\210\222.md" +++ /dev/null @@ -1,116 +0,0 @@ -# RClick 架构重构设计及计划 - -> 最后更新:2026-04-06 -> 状态:设计文档已拆分 -> 架构方向:**瘦 Extension**(Slim Extension)+ **全盘监听** + **完全磁盘访问权限** - ---- - -## 文档导航 - -本重构设计已拆分为以下独立文档: - -| 文档 | 路径 | 说明 | -|------|------|------| -| **重构设计** | [重构设计.md](重构设计.md) | 整体架构设计、问题分析、设计目标、架构图 | -| **通信协议** | [contracts/communication-protocol.md](contracts/communication-protocol.md) | 详细的通信协议设计、消息类型、Messager 类 | -| **数据模型** | [contracts/data-models.md](contracts/data-models.md) | SwiftData 模型、消息传输模型、脚本执行模型 | -| **Extension 重连** | [contracts/extension-reconnect.md](contracts/extension-reconnect.md) | 状态快照、心跳机制、主动请求机制 | -| **实施计划** | [重构计划.md](重构计划.md) | 分阶段实施步骤、验证计划、时间估算 | -| **设置界面重构** | [设置界面重构设计.md](设置界面重构设计.md) | 设置界面 UI 重构设计、分组布局、折叠开关 | - ---- - -## 执行摘要 - -本重构计划的核心目标是构建**瘦 Extension 架构**: - -| 职责 | Extension | Main App | -|------|-----------|----------| -| 监听范围 | ✅ 全盘(/Users/ + 外接磁盘) | - | -| 数据读取 | ❌ 不读取 | ✅ SwiftData | -| 菜单构建 | ❌ 只渲染 | ✅ 构建配置 | -| 业务逻辑 | ❌ 无 | ✅ 全部 | -| 完全磁盘访问权限 | ❌ 不处理 | ✅ 引导用户授权 | -| 图标加载 | ✅ 根据路径 | ✅ 预加载缓存 | - -**核心原则**:Extension 只做 3 件事——监听文件目录、渲染菜单、发送事件。 - -### 预期收益 - -- Extension 内存使用 < 20MB(无 SwiftData 依赖) -- 菜单渲染时间 < 50ms -- 代码行数减少约 30% -- 维护成本降低约 40% -- 用户体验提升:一次授权全盘访问,无需手动配置许可目录 -- 新增脚本执行功能:支持 Shell/Python/AppleScript 三种脚本类型 - ---- - -## 设计原则 - -1. **单一真相源**:主 App 保存 MenuSnapshot 作为状态真相源 -2. **推拉混合模式**:主 App 推送配置,Extension 可主动请求 -3. **版本检查**:防止重复和乱序消息 -4. **幂等处理**:重复消息不产生副作用 -5. **完全磁盘访问权限**:用户手动授权,无需 Bookmark 缓存 - ---- - -## 快速开始 - -### 1. 阅读设计文档 - -首先阅读 [重构设计.md](重构设计.md) 了解整体架构和问题分析。 - -### 2. 查看实施计划 - -[重构计划.md](重构计划.md) 包含详细的实施步骤和验收标准。 - -### 3. 参考协议和模型 - -- [通信协议设计](contracts/communication-protocol.md) - 消息类型和传输格式 -- [数据模型设计](contracts/data-models.md) - SwiftData 模型和 Codable 模型 - ---- - -## 核心改进项 - -| 优先级 | 改进项 | 预计时间 | 状态 | -|--------|--------|----------|------| -| P0 | 类型安全通信协议 | 1-2 小时 | 待实施 | -| P0 | 瘦 Extension 架构 | 2-3 小时 | 待实施 | -| P0 | Extension 重连机制 | 1-2 小时 | 待实施 | -| P1 | 优化 Tag 管理 | 30 分钟 | 待实施 | -| P1 | 统一 Icon 缓存 | 1 小时 | 待实施 | -| P1 | 完全磁盘访问权限 | 30 分钟 | 待实施 | -| P1 | 脚本执行功能 | 3-4 小时 | 待实施 | -| P2 | 清理和验证 | 1 小时 | 待实施 | - -**总计**: 约 11-15 小时 - ---- - -## 关键文件 - -### 需要创建 - -- `RClick/Shared/CommunicationProtocol.swift` - 类型安全的通信协议 -- `RClick/Shared/IconCache.swift` - 统一 Icon 缓存 -- `RClick/Model/ScriptEntity.swift` - 脚本 SwiftData 实体 -- `RClick/Settings/ScriptsSettingsTabView.swift` - 脚本设置界面 - -### 需要修改 - -- `FinderSyncExt/FinderSyncExt.swift` - 精简为纯渲染层 -- `RClick/Shared/Messager.swift` - 支持类型安全消息 -- `RClick/AppState.swift` - 添加菜单配置构建和脚本管理 -- `RClick/RClickApp.swift` - 处理点击事件和脚本执行 -- `RClick/Settings/GeneralSettingsTabView.swift` - 完全磁盘访问权限引导 - ---- - -## 参考链接 - -- [CLAUDE.md](../CLAUDE.md) - 项目指南 -- [Git 分支策略](contracts/git-branch-strategy.md) - 版本管理 diff --git "a/specs/\350\256\276\347\275\256\347\225\214\351\235\242\351\207\215\346\236\204\350\256\276\350\256\241.md" "b/specs/\350\256\276\347\275\256\347\225\214\351\235\242\351\207\215\346\236\204\350\256\276\350\256\241.md" deleted file mode 100644 index 80d3c11..0000000 --- "a/specs/\350\256\276\347\275\256\347\225\214\351\235\242\351\207\215\346\236\204\350\256\276\350\256\241.md" +++ /dev/null @@ -1,200 +0,0 @@ -# RClick 设置界面重构设计 - -> 最后更新:2026-04-06 -> 状态:设计文档 -> 架构:完全磁盘访问权限 + 瘦 Extension -> 风格:macOS 系统设置风格(分组卡片式布局) - ---- - -## 一、设计目标 - -重新设计设置界面的"通用设置"部分,采用 macOS 系统设置风格(分组卡片式布局),并将"折叠上下文菜单"开关从通用设置移到每个分类里面。 - -**设计参考**: 用户提供的截图显示了 macOS 系统设置风格: -- 分组卡片式布局 -- 每组有标题和说明文字 -- 开关在右侧对齐 -- 底部有危险操作(红色文字按钮) - ---- - -## 二、通用设置新布局 - -通用设置采用三组卡片式布局: - -### 2.1 第一组:主要控制 - -| 项目 | 类型 | 说明 | -|------|------|------| -| 启用「RClick」 | Toggle 开关 | 主开关,控制扩展启用/禁用 | -| 登录时启动 | Toggle 开关 | 系统登录时自动启动 RClick | - -**注意**: -- "RClick"文案替换为"RClick" -- "登录时启动"移到"启用"之后 - -### 2.2 第二组:权限 - -| 项目 | 类型 | 说明 | -|------|------|------| -| Finder 扩展 | 状态显示 | 只读,带绿色勾选图标表示已启用 | -| 完全磁盘访问权限 | 按钮 | 点击跳转到系统设置请求权限 | -| 辅助功能 | 按钮 | 点击跳转到系统设置请求权限 | - -### 2.3 第三组:设置管理 - -| 项目 | 类型 | 说明 | -|------|------|------| -| 备份 | 按钮组 | 导出设置 / 导入设置 | -| 日志 | 按钮 | 导出日志 | -| 重置所有设置 | 文字按钮 | 红色,底部右对齐,危险操作 | - ---- - -## 三、各分类折叠开关 - -将"折叠上下文菜单"从通用设置移除,改为每个分类独立控制: - -| 分类 | 开关名称 | 存储键 | 位置 | -|------|----------|--------|------| -| Apps | 折叠应用菜单 | `foldAppsMenu` | 列表顶部 | -| Actions | 折叠动作菜单 | `foldActionsMenu` | 列表顶部 | -| New File | 折叠新建文件菜单 | `foldNewFileMenu` | 列表顶部 | -| Common Dir | 折叠常用文件夹菜单 | `foldCommonDirMenu` | 列表顶部 | -| Scripts | 折叠脚本菜单 | `foldScriptsMenu` | 列表顶部 | - -### 3.1 开关行为 - -- 默认状态:关闭(不折叠) -- 开启后:该分类的菜单项归入「RClick」子菜单 -- 状态持久化:使用 `@AppStorage` 存储 - ---- - -## 四、实施文件清单 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `GeneralSettingsTabView.swift` | 重写 | 采用新分组卡片风格 | -| `AppsSettingsTabView.swift` | 修改 | 添加"折叠应用菜单"开关 | -| `ActionSettingsTabView.swift` | 修改 | 添加"折叠动作菜单"开关 | -| `NewFileSettingsTabView.swift` | 修改 | 添加"折叠新建文件菜单"开关 | -| `CommonDirsSettingTabView.swift` | 修改 | 添加"折叠常用文件夹菜单"开关 | -| `ScriptsSettingsTabView.swift` | 新建 | 脚本配置界面 + 折叠开关 | -| `AppState.swift` | 修改 | 添加 5 个新的 `@AppStorage` 属性 | - ---- - -## 五、设计规范 - -### 5.1 分组风格 - -- 使用 `Form` + `Section` 结构 -- 每组有独立的标题和背景 -- 组与组之间有明显分隔 - -### 5.2 图标状态 - -- 使用 SF Symbols 系统图标 -- `checkmark.circle.fill` (绿色) - 表示已授权/已启用 -- `circle` (灰色) - 表示未授权/未启用 -- `exclamationmark.triangle` (黄色) - 表示警告/需要注意 - -### 5.3 开关位置 - -- 每个分类列表顶部或 header 区域 -- 开关右侧对齐 -- 开关旁有说明文字 - -### 5.4 数据存储 - -- 使用 `@AppStorage` 持久化存储折叠状态 -- 键名统一使用驼峰命名 - -### 5.5 文案规范 - -- 所有文案使用中文 -- "RClick"替换为"RClick" -- 说明文字使用 `.secondary` 颜色 - ---- - -## 六、验收标准 - -- [ ] 通用设置界面与 macOS 系统设置风格一致 -- [ ] 折叠开关从通用设置中移除 -- [ ] 每个分类有独立的折叠开关(包括 Scripts) -- [ ] 所有开关状态持久化保存 -- [ ] 界面采用 SwiftUI 风格,无 AppKit 代码 -- [ ] 编译无错误和警告 -- [ ] 权限状态正确显示(绿色勾选/灰色圆圈) -- [ ] 重置所有设置按钮为红色,底部右对齐 -- [ ] 脚本设置界面支持添加/编辑/删除脚本 - ---- - -## 七、验证步骤 - -1. 编译项目无错误 -2. 运行应用,打开设置窗口 -3. 验证通用设置界面布局正确 -4. 验证各分类折叠开关存在且可用 -5. 开关状态重启后保持 -6. 验证权限状态显示正确 -7. 验证重置按钮位置和颜色正确 - ---- - -## 八、脚本设置界面设计 - -### 9.1 界面布局 - -脚本设置界面采用列表 + 编辑器的布局: - -| 区域 | 组件 | 说明 | -|------|------|------| -| 顶部 | 折叠开关 | "折叠脚本菜单"开关 | -| 列表区 | 脚本列表 | 显示所有脚本(名称、类型、启用状态) | -| 工具栏 | 添加/删除按钮 | 添加新脚本、删除选中脚本 | -| 编辑器 | 脚本编辑器 | 多行文本框,支持语法高亮(可选) | -| 底部 | 测试按钮 | 选中测试文件执行脚本 | - -### 9.2 脚本列表 - -每行显示: -- 左侧:SF Symbol 图标(根据脚本类型) -- 中间:脚本名称 + 类型标签 -- 右侧:启用/禁用开关 -- 拖拽手柄:支持上下拖动排序 - -### 9.3 脚本编辑器 - -编辑字段: -- 名称(必填) -- 类型选择器(Shell / Python / AppleScript) -- 脚本内容(多行文本框) -- 图标选择器(SF Symbol) - -### 9.4 预定义脚本模板 - -提供快速创建模板: -| 名称 | 类型 | 说明 | -|------|------|------| -| 显示文件信息 | shell | `mdls "$1"` | -| 在 VSCode 打开 | shell | `open -a "Visual Studio Code" "$@"` | -| 获取文件哈希 | shell | `shasum -a 256 "$@"` | - -### 9.5 安全提示 - -在脚本设置界面顶部添加黄色警告横幅: -> ⚠️ 安全提示:脚本以当前用户权限运行,请确保脚本来源可靠。自行承担执行风险。 - ---- - -## 九、参考文档 - -- [重构设计.md](重构设计.md) - 整体架构设计 -- [重构计划.md](重构计划.md) - 实施步骤 -- [contracts/data-models.md](contracts/data-models.md) - 数据模型设计(ScriptEntity) -- [CLAUDE.md](../CLAUDE.md) - 项目指南 diff --git "a/specs/\351\207\215\346\236\204\350\256\241\345\210\222.md" "b/specs/\351\207\215\346\236\204\350\256\241\345\210\222.md" deleted file mode 100644 index 79d728e..0000000 --- "a/specs/\351\207\215\346\236\204\350\256\241\345\210\222.md" +++ /dev/null @@ -1,1136 +0,0 @@ -# RClick 架构重构实施计划 - -> 最后更新:2026-04-08 -> 状态:实施计划(已根据专家审核更新) -> 基于:瘦 Extension 架构设计 - ---- - -## 零、专家审核意见采纳追踪 - -| 来源 | 风险/建议 | 采纳状态 | 更新位置 | -|------|----------|----------|----------| -| Security | 脚本命令注入风险 | ✅ 已采纳 | Phase 7 安全设计 | -| Security | IPC 消息伪造风险 | ✅ 已采纳 | Phase 2 消息安全 | -| Security | 权限检测不可靠 | ✅ 已采纳 | Phase 3 多点验证 | -| SRE | 快照持久化缺失 | ✅ 已采纳 | Phase 6 持久化 | -| SRE | 版本号单调性 | ✅ 已采纳 | Phase 6 持久化 | -| SRE | 心跳超时阈值过短 | ✅ 已采纳 | Phase 6 参数调整 | -| Architect | 外接磁盘动态监听 | ✅ 已采纳 | Phase 4 事件驱动 | -| Code Reviewer | Phase 顺序调整 | ✅ 已采纳 | 实施顺序调整 | - ---- - -## 一、计划摘要 - -本计划描述了 RClick 架构重构的详细实施步骤,目标是实现**瘦 Extension 架构**,将 FinderSync 扩展简化为纯渲染层。 - -### 实施顺序(v4 - 加入 Phase 0) - -| 顺序 | Phase | 优先级 | 风险 | 说明 | -|------|-------|--------|------|------| -| 0 | **Phase 0: 设计差异修复** | **P0** | **低** | **修复设计与实现差异** | -| 1 | Phase 1: 统一 Icon 缓存 | P1 | 低 | 已存在,让 Extension 使用 | -| 2 | Phase 2: IPC 消息安全加固 | P0 | 低 | 新增功能,不影响现有逻辑 | -| 3 | Phase 3: 全盘访问权限 | P1 | 中 | 只做添加,暂不删除旧代码 | -| 4 | **Phase 4: 瘦 Extension 架构** | **P0** | **高** | **核心重构** | -| 5 | Phase 5: Tag 管理优化 | P1 | 低 | Phase 4 的自然延伸 | -| 6 | Phase 6: Extension 重连机制 | P1 | 中 | 依赖 Phase 4 的菜单缓存 | -| 7 | Phase 7: 脚本执行功能 | P1 | 中 | 依赖 Phase 2 的安全消息 | -| 8 | Phase 8: 清理和验证 | P2 | 低 | 统一清理 | - -**调整理由**: - -| 调整点 | 理由 | -|--------|------| -| **新增 Phase 0** | 设计文档与实际代码存在差异,必须先修复才能继续重构 | -| **Phase 0 为 P0** | 差异修复是基础工作,影响后续所有 Phase | -| **Phase 6 风险降为中** | 改用 SwiftData 实时构建,不再需要持久化同步 | - -### 优先级说明 - -| 优先级 | 说明 | -|--------|------| -| P0 | 最高优先级,核心功能,必须完成 | -| P1 | 高优先级,重要优化 | -| P2 | 中优先级,改进体验 | - ---- - -## 二、实施阶段 - -### Phase 0: 设计与实现差异修复 ⭐⭐⭐ P0 - -> **目标**:修复设计文档与实际代码的差异,确保后续重构基于正确的基础。 - -**预计时间**:1-2 小时 - -**风险**:低 - -#### 步骤 0.1: `running` 消息携带菜单配置(P0) - -**文件**:`RClick/RClickApp.swift` - -**修改内容**: -- 第一次 `running` 发送完整 `MenuConfigPayload` -- 后续重试只携带时间戳(`RunningPayload(timestamp:)`),Extension 发现缓存过期再主动 `requestConfig` -- 减少不必要的完整配置构建和 JSON 序列化 - -**设计理由**:主 App 启动后指数退避重试 3–4 次,如果每次都要构建完整 `MenuConfigPayload`(几十个 action/app),JSON 序列化成本可能导致性能抖动。首次全量 + 后续仅时间戳的方案既保证 Extension 能快速获得配置,又避免重复传输开销。 - -**当前代码**: -```swift -// RClickApp.swift:169 -func sendObserveDirMessage() { - let directories: [String] = [] - messager.sendRunningNotification(directories: directories) -} -``` - -**修改为**: -```swift -// 第一次发送:携带完整配置 -func sendRunningMessage() { - let config = buildMenuConfig() - messager.sendToExtension(.running, data: config) -} - -// 后续重试:仅携带时间戳 -func sendRunningMessageRetry() { - let timestamp = Date().timeIntervalSince1970 - messager.sendToExtension(.running, data: RunningPayload(timestamp: timestamp)) -} - -struct RunningPayload: Codable { - let timestamp: TimeInterval -} -``` - -**Extension 端处理**: -```swift -private func handleRunning(_ data: Data?) { - // 尝试解码完整配置 - if let config = decode(data) { - applyConfig(config) - return - } - // 仅时间戳:如果本地缓存为空,主动请求 - if cachedMenuConfig == nil { - sendMenuRequest() - } -} -``` - -#### 步骤 0.2: 心跳超时阈值调整(P1) - -**文件**:`RClick/RClickApp.swift` - -**修改内容**: -- 将心跳超时从 15 秒调整为 25-30 秒 - -**当前代码**: -```swift -// RClickApp.swift:227 -heartBeatTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) -``` - -**修改为**: -```swift -heartBeatTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) -``` - -#### 步骤 0.3: 重试指数退避(P1) - -**文件**:`RClick/RClickApp.swift` - -**修改内容**: -- 将固定 5s × 6 次改为指数退避 2s→4s→8s - -**当前代码**: -```swift -// RClickApp.swift:243-265 -DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { ... } -``` - -**修改为**: -```swift -private var retryIntervals: [TimeInterval] = [2, 4, 8] -private var retryIndex: Int = 0 - -func sendRunningMessageWithExponentialBackoff() { - guard retryIndex < retryIntervals.count else { return } - sendRunningMessage() - let interval = retryIntervals[retryIndex] - retryIndex += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + interval) { - self.sendRunningMessageWithExponentialBackoff() - } -} -``` - -#### 步骤 0.4: 外接磁盘动态监听(P1) - -**文件**:`FinderSyncExt/FinderSyncExt.swift` - -**修改内容**: -- 添加 `NSWorkspace.volumeDidMountNotification` 和 `volumeDidUnmountNotification` 监听 -- 添加 `updateMonitoringDirectories()` 方法动态更新监听目录 - -#### 验收标准 - -- [ ] `running` 消息携带 `MenuConfigPayload` -- [ ] 心跳超时阈值调整为 25-30 秒 -- [ ] 重试采用指数退避(2s→4s→8s) -- [ ] 外接磁盘插拔时自动更新监听目录 - ---- - -### Phase 1: 统一 Icon 缓存 ⭐⭐ P1 - -> **状态**:部分完成 - `IconCache.swift` 已存在,需让 Extension 使用。 - -**目标**: 消除重复代码,统一图标加载 - -**预计时间**: 1 小时 - -**风险**: 低 - -#### 步骤 1.1: 创建统一 Icon 缓存 - -**文件**: `RClick/Shared/IconCache.swift` - -**状态**: ✅ 已完成 - -#### 步骤 1.2: 删除重复实现 - -**文件**: -- `FinderSyncExt/FinderSyncExt.swift` - 删除 IconCache 类(如有) -- `RClick/Shared/Messager.swift` - 删除 IconCacheManager(如有) - -#### 步骤 1.3: 使用统一缓存 - -**修改内容**: -- 所有图标加载使用 `IconCache.shared` - -#### 验收标准 - -- [ ] 只有一个 IconCache 实现 -- [ ] 图标加载正常工作 - ---- - -### Phase 2: IPC 消息安全加固 ⭐⭐⭐ P0 - -**目标**:防止消息伪造,确保 IPC 通信安全 - -**预计时间**:2-3 小时 - -**风险**:低 - -#### 步骤 2.1: 创建消息安全模块 - -**文件**:`RClick/Shared/MessageSecurity.swift` - -**内容**: -- 实现 HMAC-SHA256 签名算法 -- 实现 `SignedPayload` 泛型结构 -- 实现签名验证函数 - -#### 步骤 2.2: 更新 Messager 类 - -**文件**:`RClick/Shared/Messager.swift` - -**修改内容**: -- 发送消息时自动添加签名 -- 接收消息时验证签名 -- 签名失败时记录日志并丢弃消息 - -#### 步骤 2.3: 更新发送者验证 - -**文件**:`RClick/Shared/Messager.swift` - -**修改内容**: -- 验证发送者 Bundle ID -- 只接受来自 RClick 主程序或 FinderSyncExt 的消息 - -#### 验收标准 - -- [ ] 消息签名和验证正常 -- [ ] 伪造消息被正确拒绝 -- [ ] 签名失败日志正常记录 - ---- - -### Phase 3: 全盘访问权限 ⭐⭐ P1 - -> **状态**:✅ 已完成 - PermissionChecker 模块已创建,多点验证正常工作。 - -**目标**: 请求完全磁盘访问权限,无需 Bookmark 缓存 - -**预计时间**: 1 小时 - -**风险**: 中等 - -#### 步骤 3.1: 创建权限检查模块(多点验证) - -**文件**: `RClick/Shared/PermissionChecker.swift` - -**修改内容**: -- 实现多点权限验证(4 个检测点交叉验证) -- 实现实际文件操作测试 -- 实现打开系统设置函数 - -**状态**: ✅ 已完成 - -#### 步骤 3.2: 更新设置界面 - -**文件**: `RClick/Settings/GeneralSettingsTabView.swift` - -**修改内容**: -- 使用 `PermissionChecker.hasFullDiskAccess()` 检测权限 -- 显示权限状态(绿色勾选图标或"前往设置"按钮) - -**状态**: ✅ 已完成 - -#### 步骤 3.3: 更新 entitlements 配置 - -**文件**: `RClick/RClick.entitlements` - -**说明**: 不需要额外的 entitlement,完全磁盘访问权限由用户手动授权 - -#### 验收标准 - -- [x] 设置界面显示完全磁盘访问权限状态 -- [x] 点击按钮可跳转到系统设置 -- [x] 多点验证正常工作(4 个检测点) -- [x] 授权后功能正常工作 -- [ ] **注意**:旧 Bookmark 代码暂不删除,等 Phase 4 完成后再清理 - ---- - -### Phase 4: 瘦 Extension 架构 ⭐⭐⭐ P0 - -**目标**: Extension 只负责渲染和事件转发 - -**预计时间**: 2-3 小时 - -**风险**: 高 - -#### 步骤 4.1: 更新 FinderSyncExt - -**文件**: `FinderSyncExt/FinderSyncExt.swift` - -**修改内容**: -- 删除 SwiftData 读取逻辑 -- 添加 `menuConfig` 内存缓存 -- 实现 `setupMessageHandlers()` -- 实现纯渲染的 `menu(for:)` 方法 -- 实现全盘监听逻辑(`/Users/` + 外接磁盘) -- 添加 `setupVolumeChangeObserver()` 动态监听磁盘挂载/卸载 -- 添加外接磁盘刷新按钮的支持方法 `refreshMonitoringDirectories()` - -> ⚠️ **注意**: `FIFinderSyncController.default().directoryURLs` 的设置可能不会立即生效,有时需要重启 Finder。因此提供手动刷新方法供设置界面调用。 - -#### 步骤 4.2: 更新 AppState - -**文件**: `RClick/AppState.swift` - -**修改内容**: -- 添加 `buildMenuConfig()` 方法 -- 删除 `dirs`(许可目录)相关的属性和方法 -- 删除 `notifyExtensionMenuUpdate()` 方法及相关调用 - -#### 步骤 4.3: 更新 RClickApp - -**文件**: `RClick/RClickApp.swift` - -**修改内容**: -- 添加 `sendMenuConfig()` 方法 -- 添加 `handleClickEvent()` 方法 - -#### 验收标准 - -- [ ] Extension 不读取任何 SwiftData -- [ ] 菜单配置由主程序构建并发送 -- [ ] Extension 正确渲染菜单 - ---- - -### Phase 5: Tag 管理优化 ⭐⭐ P1 - -**目标**: 消除随机数冲突风险 - -**预计时间**: 30 分钟 - -**风险**: 极低 - -#### 步骤 5.1: 修改 Tag 生成逻辑 - -**文件**: `FinderSyncExt/FinderSyncExt.swift` - -**修改内容**: -- 添加 `nextTag` 计数器 -- 将随机 tag 改为递增 tag - -#### 验收标准 - -- [ ] Tag 使用递增方式生成 -- [ ] 无随机数冲突风险 - ---- - -### Phase 6: Extension 重连机制 ⭐⭐ P1 - -**目标**:解决主 App 已打开但 Extension 未加载的问题 - -**预计时间**:2-3 小时 - -**风险**:低 - -#### 设计原则(必须遵守) - -| 原则 | 说明 | 违反后果 | -|------|------|----------| -| **Notification ≠ 状态** | 通知只能当"信号",不能当"真相" | 丢消息=状态错乱 | -| **单一持久化源** | 业务数据只存 SwiftData,快照实时构建 | 双写一致性风险 | -| **Extension 必须支持主动拉取** | 不能只靠推送 | 无法自愈 | -| **所有通信必须幂等** | 重复消息不能出错 | 重复执行风险 | -| **时间戳防乱序** | 用时间戳判断配置新旧,拒绝旧配置 | 乱序覆盖问题 | - -#### 步骤 6.1: 实时构建配置机制(P0)⭐ 核心 - -**文件**:`RClick/RClickApp.swift` - -**修改内容**: -- 添加 `buildMenuConfig()` 方法从 SwiftData 实时构建 -- 添加内存缓存和 `lastBuildTime` 用于防抖(1秒内不重复构建) -- `MenuConfigPayload` 添加 `timestamp: TimeInterval` 字段 -- 注册 `requestConfig` 消息处理器,调用 `buildMenuConfig()` 发送 - -**核心设计**: - -```swift -// 内存缓存(仅用于防抖) -private var cachedMenuConfig: MenuConfigPayload? -private var lastBuildTime: TimeInterval = 0 - -/// 构建菜单配置(从 SwiftData 实时构建) -func buildMenuConfig() -> MenuConfigPayload { - let now = Date().timeIntervalSince1970 - - // 如果 1 秒内已构建过,直接返回缓存(防抖) - if now - lastBuildTime < 1.0, let cached = cachedMenuConfig { - return cached - } - - // 从 SwiftData 读取数据 - let actions = appState.actions.map { $0.toActionMenuItem() } - let apps = appState.apps.map { $0.toAppMenuItem() } - let newFiles = appState.newFiles.map { NewFileMenuItem(...) } - let commonDirs = appState.cdirs.map { CommonDirMenuItem(...) } - - let config = MenuConfigPayload( - timestamp: now, - actions: actions, - apps: apps, - newFiles: newFiles, - commonDirs: commonDirs - ) - - cachedMenuConfig = config - lastBuildTime = now - - return config -} - -// 响应 Extension 的 config 请求 -func handleConfigRequest() { - let config = buildMenuConfig() - messager.sendMenuConfig(config) -} - -// 发送 running 消息时携带当前配置 -func sendRunningMessage() { - let config = buildMenuConfig() - messager.sendToExtension(.running, data: config) -} -``` - -**优势**: -- 单一持久化源(SwiftData),无一致性风险 -- 实时构建毫秒级,无额外存储开销 -- 时间戳防乱序,无需维护递增版本号 - -#### 步骤 6.2: 主动请求机制(P0)⭐ 核心 - -**文件**:`FinderSyncExt/FinderSyncExt.swift` - -**修改内容**: -- 添加 `sendMenuRequest()` 方法 -- `init()` 中启动时发送请求 -- `menu(for:)` 中缓存为空时触发请求 -- 接收更新时检查时间戳(防乱序) - -```swift -class FinderSyncExt: FIFinderSync { - private var currentTimestamp: TimeInterval = 0 - private var cachedMenuConfig: MenuConfigPayload? - - override init() { - super.init() - setupMessageHandlers() - // 启动时主动请求配置 - sendMenuRequest() - } - - private func handleRunning(_ data: Data?) { - guard let config = decode(data) else { return } - // 时间戳检查:只接受更新的配置 - guard config.timestamp > currentTimestamp else { - logger.info("Ignoring outdated config") - return - } - currentTimestamp = config.timestamp - cachedMenuConfig = config - } - - private func handleMenuConfig(_ data: Data?) { - guard let config = decode(data) else { return } - // 时间戳检查 - guard config.timestamp > currentTimestamp else { return } - currentTimestamp = config.timestamp - cachedMenuConfig = config - } - - // 菜单构建:只读缓存 - override func menu(for menuKind: FIMenuKind) -> NSMenu { - guard let config = cachedMenuConfig else { - // 缓存为空,触发请求 - sendMenuRequest() - return NSMenu(title: "RClick (loading...)") - } - // 使用缓存构建菜单... - } -} -``` - -#### 步骤 6.3: 心跳机制(P1) - -**文件**:`RClick/RClickApp.swift` 和 `FinderSyncExt/FinderSyncExt.swift` - -**修改内容**: -- **Main App 端**: - - 在 `applicationDidFinishLaunching` 中发送 `running` 消息(携带 `MenuConfigPayload`) - - 启动重试定时器:指数退避(2s→4s→8s),总时长 14 秒 - - 注册 `heartbeat` 消息处理器,重置超时定时器(25-30 秒) - -- **Extension 端**: - - 启动定时器:每 10 秒发送 `heartbeat` 给主程序 - -```swift -// Main App 端 -private var heartbeatTimeoutTimer: Timer? -private var isExtensionOnline: Bool = false - -func handleHeartbeat() { - isExtensionOnline = true - // 重置超时定时器 - heartbeatTimeoutTimer?.invalidate() - heartbeatTimeoutTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in - self?.handleHeartbeatTimeout() - } -} - -// Extension 端 -Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in - Messager.shared.sendToMain(.heartbeat) -} -``` - -#### 步骤 6.4: 自动重连机制(P1) - -**文件**:`RClick/RClickApp.swift` - -**修改内容**: -- 添加心跳超时检测(15 秒) -- 超时后标记 Extension 为离线状态,菜单栏显示"扩展未响应" - -```swift -// Main App 端 - 心跳超时处理 -private func handleHeartbeatTimeout() { - // 15 秒未收到心跳,标记 Extension 离线 - isExtensionOnline = false - // 菜单栏显示"扩展未响应" - // 用户可点击"重新加载扩展"按钮 -} - -// 用户点击"重新加载扩展"时 -private func refreshExtension() { - // 发送 running + menuConfig,尝试重新连接 - sendRunningMessageWithRetry() -} -``` - -**心跳方向说明**: - -| 方向 | 消息类型 | 频率 | 目的 | -|------|----------|------|------| -| Main → Ext | `running` | 启动时 + 重试 3-4 次(指数退避:2s→4s→8s) | 告知 Extension 主程序已启动 | -| Ext → Main | `heartbeat` | 每 10 秒 | 告知主程序 Extension 存活 | - -**参数调整说明**: -- **重试次数**:从 6 次减少到 3-4 次,减少用户等待时间 -- **重试间隔**:采用指数退避(2s, 4s, 8s),总时长从 30 秒降至 14 秒 -- **超时阈值**:从 15 秒调整为 25-30 秒(心跳间隔的 2.5-3 倍),避免误判 - -#### 步骤 6.5: 菜单强制刷新(P2) - -**文件**:`RClick/Settings/MenuBarView.swift` 或菜单栏组件 - -**修改内容**: -- 在菜单栏添加"刷新 Extension"按钮 -- 实现 `refreshExtension()` 方法,发送 `running` + 当前配置 - -```swift -// 用户点击"刷新 Extension"时调用 -private func refreshExtension() { - // 重新发送 running 消息,携带当前配置 - let config = buildMenuConfig() - messager.sendToExtension(.running, data: config) -} -``` - -#### 验收标准 - -- [ ] `buildMenuConfig()` 从 SwiftData 实时构建配置 -- [ ] `MenuConfigPayload` 包含 `timestamp` 字段 -- [ ] Extension 启动后主动请求菜单 -- [ ] 时间戳检查正常工作(防乱序) -- [ ] 缓存为空时自动请求 -- [ ] 主 App 启动后指数退避重试(2s→4s→8s) -- [ ] Extension 每 10 秒发送 `heartbeat` 给主 App -- [ ] 主 App 25-30 秒未收到心跳,标记 Extension 离线 - ---- - -### Phase 8: 清理和验证 ⭐ P2 - -**目标**:删除旧代码,验证功能 - -**预计时间**:1-2 小时 - -**风险**:无 - -#### 步骤 8.1: 删除旧代码 - -**删除内容**: -1. FinderSyncExt 中的 IconCache 类 -2. Messager 中的 IconCacheManager 类 -3. ActionMenuItem 和 AppMenuItem 转换代码 -4. `notifyExtensionMenuUpdate()` 方法 -5. UserDefaults 中的菜单配置存储逻辑 -6. PermDirEntity / PermissiveDirEntity 相关的 UI 设置代码 -7. BookmarkManager 相关代码 -8. 旧的单一检测点权限检查代码 -9. 无签名的消息发送代码 - -**保留内容**: -- 向后兼容的消息解析代码(过渡期保留) -- 旧代码的注释说明(便于回滚参考) - -#### 步骤 8.2: 功能验证 - -**测试清单**: -- [ ] 编译测试 -- [ ] 功能测试(见三、验证计划) -- [ ] 性能测试(见三、验证计划) -- [ ] 安全测试(脚本执行、消息签名) - ---- - -## 三、验证计划 - -### 3.1 编译测试 - -```bash -# 清理构建 -xcodebuild clean -project RClick.xcodeproj -scheme RClick - -# 编译 Main App -xcodebuild build -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS' - -# 编译 Extension -xcodebuild build -project RClick.xcodeproj -scheme FinderSyncExt -destination 'platform=macOS' -``` - -### 3.2 功能测试清单 - -#### Extension 启动测试 - -- [ ] Extension 成功初始化 -- [ ] 发送 heartbeat 给主程序 -- [ ] 接收主程序发送的菜单配置 -- [ ] **全盘监听**: 验证 `/Users/` 被添加为监听目录 -- [ ] **外接磁盘**: 插入外接磁盘,验证自动添加为监听目录 - -#### 菜单创建测试 - -- [ ] 右键菜单显示正确的动作项 -- [ ] 右键菜单显示正确的应用项 -- [ ] 图标正确加载 - -#### 配置更新测试 - -- [ ] 在 Main App 中添加新应用,Extension 菜单更新 -- [ ] 在 Main App 中修改动作配置,Extension 菜单更新 - -#### 动作执行测试 - -- [ ] 点击"Copy Path"正确复制路径 -- [ ] 点击应用正确打开文件 -- [ ] Tag 正确关联到动作 - -#### 权限处理测试 - -**完全磁盘访问权限模式**(无需 Bookmark 缓存): - -- [ ] **完全磁盘访问权限**:设置界面显示权限状态 -- [ ] **权限引导**:点击按钮可跳转到系统设置 -- [ ] **权限检测**:多点验证正常工作(3 个检测点) -- [ ] **无 Bookmark 缓存**:确认代码中已删除 Bookmark 相关逻辑 - -#### 错误处理测试 - -- [ ] Main App 未启动时 Extension 行为 -- [ ] 消息丢失时的降级处理 -- [ ] 心跳超时检测(25-30 秒阈值) -- [ ] **新增**:快照持久化恢复测试 -- [ ] **新增**:版本号回滚防护测试 - -### 3.3 性能测试 - -- [ ] 菜单创建时间:< 50ms -- [ ] 图标加载时间:< 50ms/个 -- [ ] Extension 内存使用:< 20MB -- [ ] 消息传递延迟:< 100ms -- [ ] **新增**:消息签名/验证开销:< 10ms - -### 3.4 日志检查 - -查看 Console.app,验证: - -- [ ] Extension 成功接收菜单配置 -- [ ] 菜单数据正确加载 -- [ ] 消息正确发送和接收 -- [ ] 错误被正确处理和记录 -- [ ] **新增**:签名失败日志正常记录 - -### 3.5 安全测试(新增) - -**脚本执行安全**: -- [ ] 危险命令检测正常工作 -- [ ] 执行前用户确认对话框显示 -- [ ] 脚本超时 30 秒自动终止 -- [ ] 脚本文件存储权限正确(chmod 500) - -**IPC 消息安全**: -- [ ] 伪造消息被正确拒绝 -- [ ] 消息签名验证正常 -- [ ] 发送者身份验证正常 - -### 3.6 故障场景测试(新增) - -- [ ] Extension 进程强制重启 -- [ ] Main App 崩溃重启 -- [ ] 外接磁盘插拔 -- [ ] 权限被撤销 -- [ ] SwiftData 损坏 -- [ ] 100+ 文件菜单渲染 - ---- - -## 四、关键文件清单 - -### 需要创建的文件 - -1. `RClick/Shared/MessageSecurity.swift` - IPC 消息签名验证(Phase 2) -2. `RClick/Shared/PermissionChecker.swift` - 完全磁盘访问权限多点验证(Phase 3) -3. `RClick/Shared/ScriptExecutor.swift` - 脚本执行安全模块(Phase 7) -4. `RClick/Model/ScriptEntity.swift` - 脚本实体(Phase 7) -5. `RClick/Settings/ScriptsSettingsTabView.swift` - 脚本设置界面(Phase 7) - -**注**:`IconCache.swift` 已存在,无需创建(Phase 1)。 - -### 需要修改的文件 - -1. **FinderSyncExt/FinderSyncExt.swift** - - Phase 1:使用 `IconCache.shared` 统一图标加载 - - Phase 4:删除 SwiftData 读取逻辑,实现瘦 Extension 架构 - - Phase 5:优化 Tag 管理(使用递增 ID) - -2. **RClick/Shared/Messager.swift** - - Phase 2:添加消息签名和验证逻辑 - - Phase 1:删除 IconCacheManager(如有) - -3. **RClick/AppState.swift** - - Phase 4:添加 `buildMenuConfig()` 方法 - - Phase 4:删除 `dirs` 相关属性和方法 - -4. **RClick/RClickApp.swift** - - Phase 3:添加完全磁盘访问权限检测逻辑 - - Phase 4:添加 `sendMenuConfig()` 和 `handleClickEvent()` 方法 - - Phase 6:心跳重试和超时检测机制 - -5. **RClick/Settings/GeneralSettingsTabView.swift** - - Phase 3:使用 `PermissionChecker` 多点验证权限 - ---- - -## 五、风险评估与缓解 - -### 5.1 破坏性变更 - -**风险**: 通信协议重构可能影响兼容性 - -**级别**: 中等 - -**缓解**: -- Enum 的 rawValue 保持与原有字符串相同 -- 支持向后兼容的消息解析 -- 逐步迁移,保留过渡期 - -### 5.2 消息同步 - -**风险**: 主程序和 Extension 消息不同步 - -**级别**: 中等 - -**缓解**: -- 使用心跳机制确认连接 -- 主程序启动时主动发送配置 -- Extension 定期请求配置刷新 - -### 5.3 性能影响 - -**风险**: 消息传递可能比直接读取慢 - -**级别**: 低 - -**缓解**: -- 使用内存缓存,减少消息频率 -- 只在配置变更时发送完整配置 -- 实测性能,优化消息频率 - ---- - -## 六、时间估算 - -### 6.1 按实施顺序排列 - -| 顺序 | 阶段 | 预计时间 | 优先级 | 风险 | -|------|------|----------|--------|------| -| 0 | **Phase 0: 设计差异修复** | **1-2 小时** | **P0** | **低** | -| 1 | Phase 1: 统一 Icon 缓存 | 1 小时 | P1 | 低 | -| 2 | Phase 2: IPC 消息安全加固 | 2-3 小时 | P0 | 低 | -| 3 | Phase 3: 全盘访问权限 | 1 小时 | P1 | 中 | -| 4 | **Phase 4: 瘦 Extension 架构** | **2-3 小时** | **P0** | **高** | -| 5 | Phase 5: Tag 管理优化 | 30 分钟 | P1 | 低 | -| 6 | Phase 6: Extension 重连机制 | 2-3 小时 | P1 | 中 | -| 7 | Phase 7: 脚本执行功能 | 4-5 小时 | P1 | 中 | -| 8 | Phase 8: 清理和验证 | 1 小时 | P2 | 低 | -| **总计** | - | **16-22 小时** | - | - | - -### 6.2 按优先级排列 - -| 优先级 | 阶段 | 预计时间 | -|--------|------|----------| -| P0 | Phase 2: IPC 消息安全加固 | 2-3 小时 | -| P0 | Phase 4: 瘦 Extension 架构 | 2-3 小时 | -| P1 | Phase 1: 统一 Icon 缓存 | 1 小时 | -| P1 | Phase 3: 全盘访问权限 | 1 小时 | -| P1 | Phase 5: Tag 管理优化 | 30 分钟 | -| P1 | Phase 6: Extension 重连机制 | 2-3 小时 | -| P1 | Phase 7: 脚本执行功能 | 4-5 小时 | -| P2 | Phase 8: 清理和验证 | 1 小时 | - -**时间调整说明**: -- Phase 2:IPC 消息安全加固,2-3 小时(新增) -- Phase 3:从 30 分钟增加到 1 小时(多点验证) -- Phase 4:瘦 Extension 架构,2-3 小时(核心重构) -- Phase 6:从 1-2 小时增加到 2-3 小时(持久化设计) -- Phase 7:从 3-4 小时增加到 4-5 小时(安全加固) - ---- - -## 七、参考文档 - -- **重构设计**: [../重构设计.md](../重构设计.md) -- **通信协议**: [contracts/communication-protocol.md](contracts/communication-protocol.md) -- **数据模型**: [contracts/data-models.md](contracts/data-models.md) -- **Extension 重连**: [contracts/extension-reconnect.md](contracts/extension-reconnect.md) -- **消息安全**: [contracts/message-security.md](contracts/message-security.md)(待创建) -- **脚本执行安全**: [contracts/script-execution-security.md](contracts/script-execution-security.md)(待创建) - ---- - -## 八、脚本执行功能实施计划 - -### Phase 7: 脚本执行功能 ⭐⭐ P1 - -**目标**: 支持用户自定义脚本,通过右键菜单执行 - -**预计时间**: 4-5 小时 - -**风险**: 中等 - -#### 步骤 7.1: 创建 ScriptEntity - -**文件**: `RClick/Model/ScriptEntity.swift` - -**内容**: -```swift -@Model -final class ScriptEntity { - @Attribute(.unique) var id: String - var name: String - var scriptType: String // "shell" | "python" | "applescript" - var scriptContent: String - var icon: String - var sortOrder: Int - var isEnabled: Bool -} -``` - -#### 步骤 7.2: 添加 RCScript 内存模型 - -**文件**: `RClick/Model/RCBase.swift` - -**修改内容**: -- 添加 `RCScript` 结构 -- 添加 `ScriptType` 枚举 -- 添加 `RCScript` ↔ `ScriptEntity` 转换扩展 - -#### 步骤 7.3: 添加 ScriptMenuItem 消息模型 - -**文件**: `RClick/Shared/CommunicationProtocol.swift` - -**修改内容**: -- 添加 `ScriptMenuItem` 结构 -- 在 `MenuConfigPayload` 中添加 `scripts: [ScriptMenuItem]` -- 在 `MenuItemType` 中添加 `case script` - -#### 步骤 7.4: 更新 AppState - -**文件**: `RClick/AppState.swift` - -**修改内容**: -- 添加 `@Published var scripts: [RCScript]` -- 添加 `save()` 和 `load()` 方法 -- 添加 `buildMenuConfig()` 方法中构建脚本菜单 - -#### 步骤 7.5: 更新 RClickApp - -**文件**: `RClick/RClickApp.swift` - -**修改内容**: -- 添加 `executeScript()` 方法 -- 实现 Shell 脚本执行 -- 实现 Python 脚本执行 -- 实现 AppleScript 执行 -- 在 `handleClickEvent()` 中处理 script 类型点击 -- ⚠️ **重要**: 所有脚本执行必须在后台线程(`DispatchQueue.global()` 或 `async/await`)中调用,`process.waitUntilExit()` 在主线程会阻塞界面 - -#### 步骤 7.6: 更新 FinderSyncExt - -**文件**: `FinderSyncExt/FinderSyncExt.swift` - -**修改内容**: -- 在菜单中添加"执行脚本"项(二级菜单) -- 渲染脚本子菜单 -- 点击时发送 `click` 事件(type 为 `script`) - -#### 步骤 7.7: 创建脚本设置界面 - -**文件**: `RClick/Settings/ScriptsSettingsTabView.swift` - -**内容**: -- 脚本列表(名称、类型、启用状态) -- 添加/编辑/删除脚本 -- 上下拖动排序 -- 脚本编辑器(多行文本框) -- 测试按钮 - -#### 步骤 7.8: 脚本执行安全加固(P0 - 必须实施) - -**文件**: `RClick/Shared/ScriptExecutor.swift` - -**内容**: - -**1. 危险命令检测**: -```swift -func detectDangerousCommands(_ scriptContent: String) -> [String] { - let dangerousPatterns = [ - "rm -rf /", "rm -rf ~", "sudo", - "dd if=/dev/zero", ":(){ :|:& };:", - "> /dev/disk", "mkfs", "newfs" - ] - return dangerousPatterns.filter { scriptContent.contains($0) } -} -``` - -**2. 执行前用户确认**: -```swift -func confirmScriptExecution(script: RCScript, files: [String]) -> Bool { - let alert = NSAlert() - alert.messageText = "确认执行脚本?" - alert.informativeText = """ - 脚本名称:\(script.name) - 脚本内容预览:\(script.scriptContent.prefix(200))... - 将处理 \(files.count) 个文件 - """ - return alert.runModal() == .alertFirstButtonReturn -} -``` - -**3. 执行超时控制(后台线程)**: -> ⚠️ **必须在后台线程调用**(`async/await` 或 `DispatchQueue.global`),`process.waitUntilExit()` 在主线程会阻塞界面。 -```swift -func executeScriptWithTimeout(_ script: String, files: [String], timeout: TimeInterval = 30) async throws { - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = ["-c", script, "--"] + files - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - try process.run() - - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - if process.isRunning { process.terminate() } - } - - process.waitUntilExit() // 在后台线程阻塞是安全的 - continuation.resume(returning: ()) - } catch { - continuation.resume(throwing: error) - } - } - } -} -``` - -**4. 脚本存储安全**: -```swift -func saveScriptToFile(_ content: String, to url: URL) throws { - // 写入沙盒内目录 - // 设置只读权限(chmod 500) -} -``` - -**5. 脚本沙箱(可选增强)**: -> ⚠️ `sandbox-exec` 需要 macOS 特定的 sandbox profile 配置文件,不同 macOS 版本可能存在兼容性差异。作为可选增强功能,基础安全由"执行前用户确认"和"危险命令检测"保障。 - -#### 验收标准 - -- [ ] ScriptEntity 正确创建和存储 -- [ ] 脚本配置正确保存和加载 -- [ ] 右键菜单显示"执行脚本"二级菜单 -- [ ] **执行前显示用户确认对话框** -- [ ] **危险命令检测正常工作** -- [ ] **脚本执行超时自动终止(30 秒)** -- [ ] **脚本文件存储权限正确(chmod 500)** -- [ ] 点击脚本正确执行 -- [ ] Shell 脚本执行正常 -- [ ] Python 脚本执行正常 -- [ ] AppleScript 执行正常 -- [ ] 设置界面可添加/编辑/删除脚本 -- [ ] 脚本按 sortOrder 排序 -- [ ] 编译无错误和警告 - -### 8.1 脚本执行安全考虑 - -**安全限制**: -1. 脚本以当前用户权限运行(无 sudo) -2. 脚本执行前不经过沙盒限制(需要完全磁盘访问权限) -3. 用户自行承担脚本执行风险 - -**建议**: -- 在设置界面添加安全提示 -- 提供脚本执行日志 -- 支持脚本执行超时(默认 30 秒) - -### 8.2 脚本模板 - -**预定义脚本模板**(可选): -| 名称 | 类型 | 说明 | -|------|------|------| -| 显示文件信息 | shell | `mdls "$1"` | -| 在 VSCode 打开 | shell | `open -a "Visual Studio Code" "$@"` | -| 获取文件哈希 | shell | `shasum -a 256 "$@"` | -| 批量重命名(小写) | shell | `for f in "$@"; do mv "$f" "${f,,}"; done` | - -用户可以从模板快速创建脚本,然后修改内容。 - -### 8.3 预留设计(当前不实施) - -> 以下设计当前暂不实施,但已在架构中预留扩展点,便于后续演进。 - -#### 8.3.1 增量更新预留字段 - -在 `MenuConfigPayload` 中预留 `updateType` 字段: - -```swift -struct MenuConfigPayload: Codable { - let timestamp: TimeInterval - let updateType: UpdateType = .full // 预留 - let actions: [ActionMenuItem] - // ... -} - -enum UpdateType: String, Codable { - case full // 全量推送(当前使用) - case incremental // 增量更新(预留) -} -``` - -#### 8.3.2 图标缓存预热预留 - -在 `AppMenuItem` 中预留 `iconPath` 字段: - -```swift -struct AppMenuItem: Codable { - let id: String - let name: String - let appURL: URL - let iconPath: String? // 预留:预解析的图标路径 -} -``` - -#### 8.3.3 Extension 降级模式预留 - -预留 Extension 在主程序无响应时的应急菜单机制(详见重构设计 7.4.3)。 - -#### 8.3.4 Extension 内存防护预留 - -预留菜单配置大小检查机制(详见重构设计 7.4.4)。 - ---- - -## 九、时间估算汇总 - -| 顺序 | 阶段 | 预计时间 | 优先级 | -|------|------|----------|--------| -| 0 | **Phase 0: 设计差异修复** | **1-2 小时** | **P0** | -| 1 | Phase 1: 统一 Icon 缓存 | 1 小时 | P1 | -| 2 | Phase 2: IPC 消息安全加固 | 2-3 小时 | P0 | -| 3 | Phase 3: 全盘访问权限 | 1 小时 | P1 | -| 4 | Phase 4: 瘦 Extension 架构 | 2-3 小时 | P0 | -| 5 | Phase 5: Tag 管理优化 | 30 分钟 | P1 | -| 6 | Phase 6: Extension 重连机制 | 2-3 小时 | P1 | -| 7 | Phase 7: 脚本执行功能 | 4-5 小时 | P1 | -| 8 | Phase 8: 清理和验证 | 1 小时 | P2 | -| **总计** | - | **16-22 小时** | - | diff --git "a/specs/\351\207\215\346\236\204\350\256\276\350\256\241.md" "b/specs/\351\207\215\346\236\204\350\256\276\350\256\241.md" deleted file mode 100644 index 2bb1e4c..0000000 --- "a/specs/\351\207\215\346\236\204\350\256\276\350\256\241.md" +++ /dev/null @@ -1,1416 +0,0 @@ -# RClick 架构重构设计 - -> 最后更新:2026-04-18 -> 状态:⚠️ **方向已过时** —— "完全磁盘访问权限(FDA)"部分已被 [design-bookmark-permission.md](design-bookmark-permission.md) 取代(改用 Security-Scoped Bookmark 自动授权)。重构执行计划见 [v2.2.0-重构计划.md](v2.2.0-重构计划.md)。 -> 架构方向(已更新):**瘦 Extension**(Slim Extension)+ **全盘监听** + **Security-Scoped Bookmark 授权** - ---- - -## 零、设计与实现差异追踪 ⚠️ - -> 以下差异在代码审查中发现,需要在重构前优先修复。 - -| # | 设计承诺 | 实际实现 | 分析结论 | 状态 | -|---|----------|----------|----------|------| -| 1 | `MenuSnapshot` 持久化到 `@AppStorage` | 只有内存变量 | **设计不合理**:业务数据已用 SwiftData,无需额外持久化 | ✅ 无需修复 | -| 2 | `running` 消息携带 `MenuSnapshot` | 只发 `RunningPayload(directories)` | P0 - 需要修复 | ❌ 待修复 | -| 3 | Extension `requestConfig` 时发送持久化快照 | 重算而非发送快照 | **设计合理**:从 SwiftData 实时构建更好 | ✅ 无需修复 | -| 4 | 心跳超时阈值 25-30 秒 | 实际是 15 秒 | P1 - 需要修复 | ❌ 待修复 | -| 5 | 重试指数退避 2s→4s→8s | 固定 5s × 6 次 | P1 - 需要修复 | ❌ 待修复 | -| 6 | 外接磁盘动态监听 | 只在 `init()` 静态设置 | P1 - 需要修复 | ❌ 待修复 | - -**修复计划**:参见 `重构计划.md` Phase 0。 - -**关于 MenuSnapshot 持久化的说明**: - -原设计要求用 `@AppStorage` 持久化 `MenuSnapshot`,但这会引入问题: -- 业务数据(Apps、Actions、NewFiles、CommonDirs)已用 SwiftData 持久化 -- 额外持久化快照会造成**双写一致性风险** -- 快照只是 IPC 消息封装,不是独立业务实体 - -**修正方案**: -- 快照从 SwiftData **实时构建**(SwiftData 查询毫秒级) -- 版本检查改用 **时间戳** 替代递增版本号 -- 无需额外持久化存储 - ---- - -## 一、专家审核意见采纳追踪 - -| 来源 | 风险/建议 | 采纳状态 | 更新位置 | -|------|----------|----------|----------| -| Security | 脚本命令注入风险 | ✅ 已采纳 | 第九节 9.8 安全设计 | -| Security | IPC 消息伪造风险 | ✅ 已采纳 | 第四节 4.4 消息安全 | -| Security | 权限检测不可靠 | ✅ 已采纳 | 第五节 5.1 多点验证 | -| SRE | 快照持久化缺失 | ✅ 已修正 | 第八节 8.6(改用 SwiftData 实时构建) | -| SRE | 版本号单调性 | ✅ 已修正 | 第八节 8.7(改用时间戳) | -| SRE | 心跳超时阈值过短 | ✅ 已采纳 | 第八节 8.3 参数调整 | -| Architect | 外接磁盘动态监听 | ✅ 已采纳 | 第四节 4.3 事件驱动监听 | -| Code Reviewer | Phase 顺序调整 | ✅ 已采纳 | 重构计划.md | - ---- - ---- - -## 一、执行摘要 - -本重构计划的核心目标是构建**瘦 Extension 架构**,将 RClick 的 FinderSync 扩展简化为纯渲染层,所有业务逻辑和数据管理集中在主程序中。 - -### 核心原则(一句话) - -> **FinderSyncExt 只负责 3 件事:渲染菜单、发送点击事件、全盘监听。所有业务逻辑在主程序中实现。** - -### 点击事件数据结构 - -当用户点击菜单项时,Extension 发送点击事件到主程序: - -```swift -// FinderSyncExt 中:将 FIMenuKind 转换为 MenuScene -extension FIMenuKind { - var toMenuScene: MenuScene { - switch self { - case .contextualMenuForItems: return .contextualMenuForItems - case .contextualMenuForContainer: return .contextualMenuForContainer - case .contextualMenuForSidebar: return .contextualMenuForSidebar - case .toolbarItemMenu: return .toolbarItemMenu - @unknown default: return .contextualMenuForContainer - } - } -} - -/// 触发场景(与 FIMenuKind 对应,但可在主程序中使用) -enum MenuScene: String, Codable { - /// 选中文件/文件夹右键 - case contextualMenuForItems = "ctx-items" - /// 空白处右键 - case contextualMenuForContainer = "ctx-container" - /// 侧边栏右键 - case contextualMenuForSidebar = "ctx-sidebar" - /// 工具栏菜单 - case toolbarItemMenu = "toolbar" -} - -/// 菜单项信息 -struct MenuItemInfo: Codable { - let type: MenuItemType // 类型:action/app/newFile/commonDir - let id: String // 菜单项 ID - let tag: Int // 菜单项 tag(用于快速查找) -} - -/// 点击事件载荷 -struct ClickEventPayload: Codable { - let scene: MenuScene // 触发场景 - let currentDirectory: String // 当前目录路径 - let selectedItems: [String] // 选中的文件/目录路径列表 - let menuItem: MenuItemInfo // 点击的菜单项信息 -} -``` -``` - -### 职责划分 - -| 职责 | Extension | Main App | -|------|-----------|----------| -| FinderSync 监听目录 | ✅ 全盘(/Users/ + 外接磁盘) | - | -| 数据读取 | ❌ 不读取 | ✅ SwiftData | -| 菜单构建 | ❌ 只渲染 | ✅ 构建配置 | -| 业务逻辑 | ❌ 无 | ✅ 全部 | -| 权限请求 | ❌ 不处理 | ✅ 完全磁盘访问权限 | -| 图标加载 | ✅ 根据 appURL 加载 | ✅ 预加载缓存 | - -**核心原则**:Extension 只做 3 件事——监听文件目录(FinderSync)、渲染菜单、发送事件。 - -**说明**: -- **FinderSync 监听目录**:通过 `FIFinderSyncController.default().directoryURLs` 设置,决定 FinderSync 在哪些目录生效 -- **数据读取**:Extension 不读取 SwiftData,菜单配置由 Main App 通过 IPC 发送 - -### 设计特性 - -| 特性 | 说明 | 状态 | -|------|------|------| -| 瘦 Extension | Extension 不读取数据,只负责渲染 | ✅ 已设计 | -| 全盘监听 | 默认监听 `/Users/`,外接磁盘自动添加 | ✅ 已设计 | -| 完全磁盘访问权限 | 由用户手动授权,无需 Bookmark 缓存 | ✅ 已设计 | -| 类型安全协议 | 使用枚举替代字符串 | ✅ 已设计 | -| Notification + 协议层 | 基于 DistributedNotificationCenter + Codable 协议封装 | ✅ 已设计 | -| 消息 ID | 每个消息带 UUID,支持追踪和去重 | ✅ 已设计 | -| 统一 Icon 缓存 | 共享模块统一管理 | ✅ 已设计 | -| 递增 Tag | 避免随机数冲突 | ✅ 已设计 | - -### 预期收益 - -- Extension 内存使用 < 20MB(无 SwiftData 依赖) -- 菜单渲染时间 < 50ms -- 代码行数减少约 30% -- 维护成本降低约 40% -- 用户体验提升:无需手动授权文件夹,一次授权全盘访问 - ---- - -## 二、当前问题总结 - -### 2.1 架构层面 - -- **重复实现**: Extension 有 `IconCache`,Main App 有 `IconCacheManager` -- **数据模型冗余**: RCAction/ActionMenuItem、OpenWithApp/AppMenuItem 需要转换 -- **通信协议简陋**: `MessagePayload.action` 使用 String 类型,缺乏类型安全 -- **转换层复杂**: 需要在 SwiftData 模型和 MenuItem 模型间转换 - -### 2.2 性能问题 - -- **频繁 JSON 编解码**: 每次菜单更新都需要通过 UserDefaults 编解码 -- **Tag 管理低效**: 使用随机数生成 tag,可能冲突导致性能问题 -- **无缓存机制**: 图标每次都重新加载 - -### 2.3 数据一致性 - -- **同步延迟**: 配置更新需要等待通知和 UserDefaults 刷新 -- **转换错误风险**: 模型转换可能丢失数据或引入 bug - -### 2.4 用户体验 - -- **Main App 依赖**: Extension 在 Main App 关闭时无法工作 -- **更新延迟**: 配置更新实时性差(依赖通知 + 数据转换) - ---- - -## 三、设计目标 - -### 3.1 核心原则 - -1. **简洁性**: 减少重复代码,统一 Icon 缓存实现 -2. **类型安全**: 使用枚举替代字符串,编译时检查 -3. **性能**: 优化 Tag 管理,使用递增 ID;图标预加载缓存 -4. **可靠性**: 错误处理完善,日志记录清晰 - -### 3.2 架构选择:瘦 Extension - -Extension 只做 3 件事: - -1. **接收消息** - 从主程序接收菜单配置、刷新指令 -2. **渲染菜单** - 根据收到的配置渲染菜单(不读任何数据源) -3. **发送事件** - 用户点击后发送操作数据给主程序 - -**所有业务逻辑在主程序完成。** - -### 3.4 核心改进(按优先级) - -| 优先级 | 改进项 | 预计时间 | 风险 | -|--------|--------|----------|------| -| P0 | 类型安全通信协议 | 1-2 小时 | 低 | -| P0 | 瘦 Extension 架构 | 2-3 小时 | 中 | -| P0 | **IPC 消息安全签名** | 2-3 小时 | 低 | -| P0 | **脚本执行安全加固** | 2-3 小时 | 中 | -| P1 | 优化 Tag 管理 | 30 分钟 | 极低 | -| P1 | 统一 Icon 缓存 | 1 小时 | 低 | -| P1 | **快照持久化** | 1 小时 | 低 | -| P2 | 文档翻译/注释 | 1 小时 | 无 | - -### 3.5 可靠性目标(SLO/SLI) - -| 指标 | 目标 SLO | 测量方式 | -|------|----------|----------| -| 消息传递成功率 | ≥ 99.9% | 成功接收数 / 发送数 | -| 心跳延迟 p99 | < 2 秒 | Extension 发送 → Main App 接收 | -| 菜单恢复时间 | < 5 秒 | Extension 启动到菜单可用 | -| 故障自愈率 | ≥ 90% | 自动恢复次数 / 总故障次数 | -| 菜单渲染时间 | < 50ms | 右键触发到菜单显示 | -| Extension 内存使用 | < 20MB | Instruments 测量 | - -### 3.6 安全原则 - -| 原则 | 说明 | 违反后果 | -|------|------|----------| -| **脚本执行需确认** | 执行前显示脚本内容预览和用户确认 | 恶意脚本执行风险 | -| **IPC 消息需签名** | 所有跨进程消息使用 HMAC 验证 | 消息伪造风险 | -| **权限检测多点验证** | 使用多个检测点交叉验证权限状态 | 权限误判风险 | -| **敏感数据不落地** | 快照、日志中的敏感信息需脱敏 | 数据泄露风险 | - ---- - -## 四、架构设计 - -### 4.1 架构图 - -```mermaid -graph TB - subgraph MainApp["Main App"] - AppState["AppState
业务逻辑"] - SwiftData["SwiftData
数据持久化"] - Handlers["Action Handlers
执行所有操作"] - ConfigBuilder["MenuConfigBuilder
构建菜单配置"] - end - - subgraph Extension["FinderSync Extension"] - MenuConfig["MenuConfig
内存缓存
- actions
- apps
- newFiles
- commonDirs"] - Renderer["Menu Renderer
纯渲染
- buildActionMenu()
- buildAppMenu()
- buildNewFileMenu()
- buildCommonDirMenu()"] - EventSender["Event Sender
事件转发
ClickEventPayload:
- scene
- currentDirectory
- selectedItems
- menuItem"] - end - - IPC(("DistributedNotificationCenter")) - - AppState --> ConfigBuilder - SwiftData --> ConfigBuilder - Handlers --> ConfigBuilder - ConfigBuilder --> IPC - IPC --> MenuConfig - MenuConfig --> Renderer - Renderer -->|用户点击| EventSender -``` - -### 4.4 IPC 消息安全设计 - -**威胁模型**: -- 恶意应用可能伪造 `DistributedNotificationCenter` 消息 -- 消息可能被截获导致路径信息泄露 -- 重复消息可能导致意外执行 - -**安全加固措施**: - -#### 1. HMAC 消息签名 - -```swift -// Shared/MessageSecurity.swift -import CryptoKit -import Foundation - -public class MessageSecurity { - private static let sharedKey = "RClick_IPC_SharedKey_2026" // 应存储在 Keychain - - /// 为消息载荷添加 HMAC 签名 - public static func sign(_ payload: T) throws -> SignedPayload { - let data = try JSONEncoder().encode(payload) - let key = SymmetricKey(data: sharedKey.data(using: .utf8)!) - let hmac = HMAC.authenticationCode(for: data, using: key) - - return SignedPayload( - payload: payload, - signature: Data(hmac).base64EncodedString() - ) - } - - /// 验证消息签名 - public static func verify(_ signed: SignedPayload) -> Bool { - guard let signatureData = Data(base64Encoded: signed.signature) else { - return false - } - - let key = SymmetricKey(data: sharedKey.data(using: .utf8)!) - let expectedHMAC = HMAC.authenticationCode( - for: try! JSONEncoder().encode(signed.payload), - using: key - ) - - return signatureData == Data(expectedHMAC) - } -} - -public struct SignedPayload: Codable { - public let payload: T - public let signature: String -} -``` - -#### 2. 发送者身份验证 - -```swift -// 验证发送者是否为 RClick 自身(代码签名验证) -func verifySender() -> Bool { - // 通过 codesign 验证发送者 Bundle ID - let bundleID = Bundle.main.bundleIdentifier - return bundleID == "com.lixu.RClick" || bundleID == "com.lixu.RClick.FinderSyncExt" -} -``` - -#### 3. 敏感信息脱敏 - -```swift -// 日志中的路径脱敏 -logger.info("Processing file: \(filePath.private)") - -// ClickEventPayload 加密传输(可选,按需启用) -struct EncryptedClickEvent: Codable { - let encryptedData: Data // 使用 ChaChaPoly 加密 - let nonce: Data -} -``` - ---- - -### 4.5 新架构职责划分 - -#### Main App 职责 - -- **数据管理**: 读取 SwiftData,构建菜单配置 -- **业务逻辑**: 处理所有动作执行(复制路径、删除、打开应用、创建文件等) -- **配置推送**: 数据变更时发送完整菜单配置给 Extension -- **事件处理**: 接收 Extension 的点击事件,根据场景 + 目录 + 选中项 + 菜单项信息执行操作 -- **权限请求**: 请求完全磁盘访问权限(用户手动授权) - -#### Extension 职责 - -- **FinderSync 监听**:设置 `directoryURLs`(默认 `/Users/` + 外接磁盘) -- **消息监听**:监听来自主程序的配置消息 -- **菜单渲染**:根据配置渲染 NSMenuItem(纯渲染,不读取任何数据) -- **事件转发**:用户点击后发送事件给主程序,包含: - - `scene`: 触发场景(右键物品/右键容器/工具栏/侧边栏) - - `currentDirectory`: 当前目录路径 - - `selectedItems`: 选中的文件/目录列表 - - `menuItem`: 点击的菜单项信息(type, id, tag) -- **心跳发送**:每 10 秒发送 `heartbeat` 给主程序(让主程序知道 Extension 存活) - -### 4.3 FinderSync 监听范围 - -**Extension 启动时设置监听目录**: - -```swift -// FinderSyncExt.swift -override init() { - super.init() - - // 默认监听 /Users/ - let usersDir = URL(fileURLWithPath: "/Users/") - - // 检测并添加外接磁盘 - let volumeURLs = FileManager.default.mountedVolumeURLs( - includingResourceValuesForKeys: [.isBrowsableKey], - options: [.skipHiddenVolumes] - ) ?? [] - - var allURLs = [usersDir] - for volume in volumeURLs { - if volume.path != "/" { // 排除根目录 - allURLs.append(volume) - } - } - - FIFinderSyncController.default().directoryURLs = Set(allURLs) - - logger.info("Monitoring directories: \(allURLs.map { $0.path })") - - setupMessageHandlers() - sendHeartbeat() // 开始发送心跳 - - // 注册磁盘挂载/卸载事件监听 - setupVolumeChangeObserver() -} - -// 动态监听磁盘挂载/卸载 -private func setupVolumeChangeObserver() { - NotificationCenter.default.addObserver( - self, - selector: #selector(volumeDidChange), - name: NSWorkspace.volumeDidMountNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(volumeDidChange), - name: NSWorkspace.volumeDidUnmountNotification, - object: nil - ) -} - -@objc private func volumeDidChange(_ notification: Notification) { - // 重新检测外接磁盘并更新监听目录 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.updateMonitoringDirectories() - } -} - -private func updateMonitoringDirectories() { - let volumeURLs = FileManager.default.mountedVolumeURLs( - includingResourceValuesForKeys: [.isBrowsableKey], - options: [.skipHiddenVolumes] - ) ?? [] - - var allURLs = [URL(fileURLWithPath: "/Users/")] - for volume in volumeURLs { - // 排除根目录和网络卷 - if volume.path != "/" && !isNetworkVolume(volume) { - allURLs.append(volume) - } - } - - FIFinderSyncController.default().directoryURLs = Set(allURLs) - logger.info("Updated monitoring directories: \(allURLs.map { $0.path })") -} - -private func isNetworkVolume(_ url: URL) -> Bool { - // 检测是否为网络卷(AFP、SMB、NFS 等) - let values = try? url.resourceValues(forKeys: [.volumeIsNetworkKey]) - return values?.volumeIsNetwork == true -} -``` - -#### 监听策略 - -| 类型 | 处理方式 | -|------|----------| -| **默认监听** | `/Users/`(用户主目录) | -| **外接磁盘** | 自动检测并添加所有可浏览的挂载卷 | -| **网络卷** | 不监听(通过 `volumeIsNetwork` 排除) | -| **动态插拔** | 通过 `NSWorkspace` 通知动态更新监听目录 | -| **手动刷新** | 设置界面提供"刷新监听目录"按钮,若新磁盘右键菜单未出现可手动触发 | - -> ⚠️ **注意**:`FIFinderSyncController.default().directoryURLs` 的设置对已加载的 Finder Sync 扩展可能不会立即生效,有时需要重启 Finder 才能覆盖所有情况。因此提供手动刷新按钮作为补充。 - ---- - -## 五、验证计划 - -### 5.1 编译测试 - -```bash -# 清理构建 -xcodebuild clean -project RClick.xcodeproj -scheme RClick - -# 编译 Main App -xcodebuild build -project RClick.xcodeproj -scheme RClick -destination 'platform=macOS' - -# 验证无错误和警告 -``` - -### 5.2 功能测试清单 - -1. **Extension 启动测试** - - Extension 成功初始化 - - 发送 heartbeat 给主程序 - - 接收主程序发送的菜单配置 - - **全盘监听**: 验证 `/Users/` 被添加为监听目录 - - **外接磁盘**: 插入外接磁盘,验证自动添加为监听目录 - -2. **菜单创建测试** - - 右键菜单显示正确的动作项 - - 右键菜单显示正确的应用项 - - 图标正确加载 - -3. **配置更新测试** - - 在 Main App 中添加新应用,Extension 菜单更新 - - 在 Main App 中修改动作配置,Extension 菜单更新 - -4. **动作执行测试** - - 点击"Copy Path"正确复制路径 - - 点击应用正确打开文件 - - Tag 正确关联到动作 - -5. **权限处理测试** - - **完全磁盘访问权限**: 设置界面显示权限状态 - - **权限引导**: 点击按钮可跳转到系统设置 - - **权限检测**: 授权后功能正常工作(多点验证,见下方 5.1 节) - - **无 Bookmark 缓存**: 确认代码中已删除 Bookmark 相关逻辑 - -### 5.1 完全磁盘访问权限多点验证设计(P0 - 必须实施) - -**问题**:单一检测点(如访问 `/Library/Application Support`)可能产生误判。 - -**解决方案**:使用三个检测点交叉验证 - -```swift -// RClick/Shared/PermissionChecker.swift -import Foundation - -public class PermissionChecker { - /// 多点验证完全磁盘访问权限 - /// 返回 true 表示可能已授权,false 表示可能未授权 - public static func hasFullDiskAccess() -> Bool { - let testPaths = [ - "/Library/Application Support", - "~/Library/Application Support", - "/Library/Preferences" - ] - - var accessibleCount = 0 - - for path in testPaths { - let expandedPath = NSString(string: path).expandingTildeInPath - if FileManager.default.isReadableFile(atPath: expandedPath) { - accessibleCount += 1 - } - } - - // 至少 2 个路径可访问才认为已授权 - return accessibleCount >= 2 - } - - /// 实际文件操作测试(更准确,但有副作用风险) - public static func verifyWithActualOperation() -> Bool { - // 尝试在沙盒外创建一个临时文件 - let testURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent(".rclick_permission_test") - - do { - try "test".write(to: testURL, atomically: true, encoding: .utf8) - try FileManager.default.removeItem(at: testURL) - return true - } catch { - return false - } - } - - /// 打开系统设置请求权限 - public static func openFullDiskAccessSettings() { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!) - } -} -``` - -**权限状态显示**: - -```swift -// RClick/Settings/GeneralSettingsTabView.swift -struct GeneralSettingsTabView: View { - @State private var permissionStatus: PermissionStatus = .unknown - - enum PermissionStatus { - case granted - case denied - case unknown - } - - var body: some View { - Form { - Section("权限") { - HStack { - Text("完全磁盘访问权限") - Spacer() - if permissionStatus == .granted { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - } else { - Button("前往设置") { - PermissionChecker.openFullDiskAccessSettings() - } - } - } - } - } - .onAppear { - // 延迟检测,给系统时间加载 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - permissionStatus = PermissionChecker.hasFullDiskAccess() ? .granted : .denied - } - } - } -} -``` - -6. **错误处理测试** - - Main App 未启动时 Extension 行为 - - 消息丢失时的降级处理 - - 心跳超时检测 - -### 5.3 性能测试 - -1. 菜单创建时间:应 < 50ms -2. 图标加载时间:应 < 50ms/个 -3. 内存使用:Extension < 20MB -4. 消息传递延迟:< 100ms - -### 5.4 日志检查 - -查看 Console.app,验证: - -- Extension 成功接收菜单配置 -- 菜单数据正确加载 -- 消息正确发送和接收 -- 错误被正确处理和记录 - ---- - -## 六、成功标准 - -### 6.1 技术指标 - -- ✅ 编译无错误和警告 -- ✅ 所有测试通过 -- ✅ 菜单创建时间 < 50ms -- ✅ Extension 内存使用 < 20MB - -### 6.2 代码质量 - -- ✅ 删除所有转换层代码 -- ✅ 消除重复的 IconCache 实现 -- ✅ 所有通信使用类型安全的枚举 -- ✅ 代码注释完整(中英文混合) - -### 6.3 用户体验 - -- ✅ Extension 菜单正常显示 -- ✅ 配置更新实时生效 -- ✅ 图标加载快速 -- ✅ 动作执行正确 - ---- - -## 七、后续优化方向 - -### 7.1 短期优化(1-2 周) - -1. 实现菜单数据缓存,减少消息频率 -2. 添加性能监控和日志 -3. 优化图标预加载策略 - -### 7.2 中期优化(1 个月) - -1. 实现增量数据同步(只传输变更) -2. 添加数据版本控制和迁移机制 -3. 实现 Extension 离线模式(内置默认配置) - -### 7.3 长期优化(3 个月) - -1. 实现更智能的缓存策略 -2. 添加 A/B 测试支持 -3. 性能监控和分析工具集成 - -### 7.4 预留设计(当前不实施,预留扩展点) - -> 以下设计当前暂不实施,但已在架构中预留扩展点,便于后续演进。 - -#### 7.4.1 增量更新支持 - -当前是全量推送菜单配置,短期内数据量不会成为问题。但可在数据结构中预留字段: - -```swift -struct MenuConfigPayload: Codable { - let timestamp: TimeInterval - let updateType: UpdateType = .full // 预留:full | incremental - let actions: [ActionMenuItem] - // ... -} - -enum UpdateType: String, Codable { - case full // 全量推送(当前使用) - case incremental // 增量更新(预留) -} -``` - -#### 7.4.2 图标缓存预热 - -主程序可在推送配置前,提前将图标信息预处理为 Extension 可直接使用的格式: - -```swift -struct AppMenuItem: Codable { - let id: String - let name: String - let appURL: URL - let iconPath: String? // 预留:预解析的图标路径,Extension 可直接用 NSImage(contentsOfFile:) 加载 -} -``` - -#### 7.4.3 Extension 降级模式 - -如果主程序长时间不响应(如崩溃),Extension 可加载应急菜单: - -```swift -// Extension 端:心跳超时后进入降级模式 -func enterFallbackMode() { - cachedMenuConfig = MenuConfigPayload.fallback // 仅显示基本操作 - logger.warning("Main app unresponsive, entering fallback mode") -} - -extension MenuConfigPayload { - static var fallback: MenuConfigPayload { - // 应急菜单:仅显示最基本的操作 - MenuConfigPayload( - timestamp: Date().timeIntervalSince1970, - actions: [ActionMenuItem(name: "复制路径", id: "copy-path")], - apps: [], - newFiles: [], - commonDirs: [] - ) - } -} -``` - -#### 7.4.4 Extension 内存防护 - -`MenuConfigPayload` 如果过大可能导致 Extension 内存超标。预留字段数量上限作为防护: - -```swift -// Extension 端:菜单配置大小检查 -func validateMenuConfig(_ config: MenuConfigPayload) -> Bool { - let totalItems = config.actions.count + config.apps.count + - config.newFiles.count + config.commonDirs.count - return totalItems < 200 // 防护上限,可根据实测调整 -} -``` - ---- - -## 八、附录:Extension 重连方案 - -### 8.1 问题背景 - -FinderSync Extension 由 macOS 系统控制加载时机,可能出现**主 App 已打开但 Extension 未加载**的情况,导致右键菜单不显示。 - -### 8.2 核心设计原则 - -| 原则 | 说明 | 违反后果 | -|------|------|----------| -| **Notification ≠ 状态** | 通知只能当"信号",不能当"真相" | 丢消息=状态错乱 | -| **单一持久化源** | 业务数据只存 SwiftData,快照实时构建 | 双写一致性风险 | -| **Extension 必须支持主动拉取** | 不能只靠推送 | 无法自愈 | -| **所有通信必须幂等** | 重复消息不能出错 | 重复执行风险 | -| **时间戳防乱序** | 用时间戳判断配置新旧,拒绝旧配置 | 乱序覆盖问题 | - -### 8.3 架构:推 + 拉混合模式 - -```mermaid -graph LR - subgraph Before["纯推送(之前)"] - A1[Main App] -->|发送消息| A2[Extension] - A2 -.->|靠运气接收| A1 - end - - subgraph After["推拉混合(升级)"] - B1[Main App] <-->|发送/请求| B2[Extension] - B2 -.->|可主动拉取| B1 - end -``` - -### 8.4 四层防护策略 - -| 层级 | 机制 | 作用 | 优先级 | -|------|------|------|--------| -| L1 | **实时构建配置** | 从 SwiftData 实时构建,无持久化一致性风险 | P0 | -| L2 | **running 重试** | 主 App 启动后定期发送 running(携带当前配置) | P1 | -| L3 | **主动请求** | Extension 启动/缓存为空时主动请求 | P0 | -| L4 | **心跳检测** | Extension 定期发送 heartbeat,主 App 检测超时 | P1 | -| L5 | **手动刷新** | 用户点击菜单栏"刷新 Extension"按钮 | P2 | - -**心跳方向**: - -| 方向 | 消息类型 | 频率 | 目的 | -|------|----------|------|------| -| Main → Ext | `running`(携带 MenuConfigPayload) | 启动时 + 重试 3-4 次(指数退避:2s→4s→8s) | 告知 Extension 主程序已启动 | -| Ext → Main | `heartbeat` | 每 10 秒 | 告知主程序 Extension 存活 | -| Ext → Main | `requestConfig` | 启动/缓存为空时 | 请求菜单配置 | - -**参数调整说明**: -- **重试次数**:从 6 次减少到 3-4 次,减少用户等待时间 -- **重试间隔**:采用指数退避(2s, 4s, 8s),总时长从 30 秒降至 14 秒 -- **超时阈值**:从 15 秒调整为 25-30 秒(心跳间隔的 2.5-3 倍),避免误判 - -### 8.5 能力评估 - -| 场景 | 之前 | 升级后 | -|------|------|--------| -| Extension 随机重启 | ❌ | ✅ | -| Notification 丢失 | ❌ | ✅ | -| 主 App 先启动 | ⚠️ | ✅ | -| Extension 后启动 | ⚠️ | ✅ | -| Finder 延迟加载 | ❌ | ✅ | -| 状态一致性 | 🟡 弱 | 🟢 接近强一致 | - -**场景说明**: - -1. **Extension 随机重启**:Finder 可能导致 Extension 进程重启,重启后缓存丢失 - - 解决:Extension 启动时主动发送 `requestConfig` - -2. **Notification 丢失**:`DistributedNotificationCenter` 消息可能丢失 - - 解决:主 App 发送 `running` 后指数退避重试(2s→4s→8s) - -3. **主 App 先启动**:主 App 启动时 Extension 尚未加载 - - 解决:主 App 发送 `running` 消息,Extension 后加载时主动请求 - -4. **Extension 后启动**:Extension 在 Finder 启动后才加载 - - 解决:Extension 启动时主动请求配置 - -5. **Finder 延迟加载**:Finder 可能延迟加载 Extension - - 解决:主 App 重试发送 `running` 消息 - -6. **心跳超时检测**:主 App 25-30 秒未收到 `heartbeat`,标记 Extension 离线 - - 解决:用户可点击"刷新 Extension"按钮重新连接,或等待自动重连 - -### 8.6 菜单配置同步设计(修正版) - -> **设计修正**:原方案要求持久化快照,但业务数据已用 SwiftData,无需额外持久化。 - -**核心原则**: -- **单一持久化源**:业务数据只存 SwiftData,避免双写一致性风险 -- **实时构建快照**:每次请求时从 SwiftData 查询构建(毫秒级) -- **时间戳防乱序**:用时间戳替代递增版本号 - -**修正后的方案**: - -```swift -// RClick/RClickApp.swift -class AppDelegate: NSObject, NSApplicationDelegate { - // 内存缓存(仅用于减少重复构建) - private var cachedMenuConfig: MenuConfigPayload? - private var lastBuildTime: TimeInterval = 0 - - /// 构建菜单配置(从 SwiftData 实时构建) - func buildMenuConfig() -> MenuConfigPayload { - let now = Date().timeIntervalSince1970 - - // 如果 1 秒内已构建过,直接返回缓存(防抖) - if now - lastBuildTime < 1.0, let cached = cachedMenuConfig { - return cached - } - - // 从 SwiftData 读取数据 - let actions = appState.actions.map { $0.toActionMenuItem() } - let apps = appState.apps.map { $0.toAppMenuItem() } - let newFiles = appState.newFiles.map { NewFileMenuItem(...) } - let commonDirs = appState.cdirs.map { CommonDirMenuItem(...) } - - let config = MenuConfigPayload( - timestamp: now, - actions: actions, - apps: apps, - newFiles: newFiles, - commonDirs: commonDirs - ) - - cachedMenuConfig = config - lastBuildTime = now - - return config - } - - /// 发送 running 消息(携带当前配置) - func sendRunningMessage() { - let config = buildMenuConfig() - messager.sendToExtension(.running, data: config) - } - - /// 响应 Extension 的配置请求 - func handleConfigRequest() { - let config = buildMenuConfig() - messager.sendMenuConfig(config) - } -} - -// MenuConfigPayload 增加时间戳 -struct MenuConfigPayload: Codable { - let timestamp: TimeInterval // 构建时间戳 - let actions: [ActionMenuItem] - let apps: [AppMenuItem] - let newFiles: [NewFileMenuItem] - let commonDirs: [CommonDirMenuItem] - // ... 其他字段 -} -``` - -**Extension 端版本检查**: - -```swift -// FinderSyncExt.swift -class FinderSyncExt: FIFinderSync { - private var currentTimestamp: TimeInterval = 0 - private var cachedMenuConfig: MenuConfigPayload? - - private func handleMenuConfig(_ data: Data?) { - guard let config = decode(data) else { return } - - // 时间戳检查:只接受更新的配置 - guard config.timestamp > currentTimestamp else { - logger.info("Ignoring outdated config (timestamp=\(config.timestamp))") - return - } - - currentTimestamp = config.timestamp - cachedMenuConfig = config - logger.info("Menu config updated (timestamp=\(config.timestamp))") - } -} -``` - -**优势对比**: - -| 方案 | 原方案(@AppStorage) | 修正方案(SwiftData) | -|------|----------------------|----------------------| -| 持久化 | 双写(SwiftData + UserDefaults) | 单写(SwiftData) | -| 一致性 | 有风险 | 无风险 | -| 复杂度 | 需维护版本号、快照同步 | 只需时间戳 | -| 性能 | 快照可能过大 | 实时构建毫秒级 | -| 崩溃恢复 | 需恢复版本号 | 无需额外恢复 | - -### 8.7 时间戳防乱序机制 - -**问题**:消息可能乱序到达,Extension 需判断是否接受。 - -**解决方案**:时间戳 + 配置哈希双重校验 - -```swift -struct MenuConfigPayload: Codable { - let timestamp: TimeInterval // 构建时间戳 - let configHash: String // 配置内容 SHA256(可选) - let actions: [ActionMenuItem] - // ... -} - -// Extension 端检查逻辑 -func shouldAcceptConfig(_ new: MenuConfigPayload) -> Bool { - // 首先检查时间戳 - if new.timestamp > currentTimestamp { - return true - } - - // 时间戳相同或更旧时,检查配置哈希 - // 如果哈希不同说明配置变化了,可能需要接受 - if new.configHash != currentConfigHash { - logger.warning("Config hash mismatch, may need manual refresh") - // 不自动接受,等用户手动刷新或下次更新 - } - - return false -} -``` - -### 8.8 实施详情 - -详细实施方案请参阅:[contracts/extension-reconnect.md](contracts/extension-reconnect.md) - ---- - -## 九、设置界面重构设计 - -### 9.1 设计目标 - -重新设计设置界面的"通用设置"部分,采用 macOS 系统设置风格(分组卡片式布局),并将"折叠上下文菜单"开关从通用设置移到每个分类里面。 - -### 9.2 通用设置新布局 - -通用设置采用三组卡片式布局: - -#### 第一组:主要控制 - -| 项目 | 类型 | 说明 | -|------|------|------| -| 启用「RClick」 | Toggle 开关 | 主开关,控制扩展启用/禁用 | -| 登录时启动 | Toggle 开关 | 系统登录时自动启动 RClick | - -#### 第二组:权限 - -| 项目 | 类型 | 说明 | -|------|------|------| -| Finder 扩展 | 状态显示 | 只读,带绿色勾选图标表示已启用 | -| 完全磁盘访问权限 | 按钮 | 点击跳转到系统设置请求权限 | -| 辅助功能 | 按钮 | 点击跳转到系统设置请求权限 | - -#### 第三组:设置管理 - -| 项目 | 类型 | 说明 | -|------|------|------| -| 备份 | 按钮组 | 导出设置 / 导入设置 | -| 日志 | 按钮 | 导出日志 | -| 重置所有设置 | 文字按钮 | 红色,底部右对齐,危险操作 | - -### 9.3 各分类折叠开关 - -将"折叠上下文菜单"从通用设置移除,改为每个分类独立控制: - -| 分类 | 开关名称 | 存储键 | -|------|----------|--------| -| Apps | 折叠应用菜单 | `foldAppsMenu` | -| Actions | 折叠动作菜单 | `foldActionsMenu` | -| New File | 折叠新建文件菜单 | `foldNewFileMenu` | -| Common Dir | 折叠常用文件夹菜单 | `foldCommonDirMenu` | - -### 9.4 实施文件清单 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `GeneralSettingsTabView.swift` | 重写 | 采用新分组卡片风格 | -| `AppsSettingsTabView.swift` | 修改 | 添加"折叠应用菜单"开关 | -| `ActionSettingsTabView.swift` | 修改 | 添加"折叠动作菜单"开关 | -| `NewFileSettingsTabView.swift` | 修改 | 添加"折叠新建文件菜单"开关 | -| `CommonDirsSettingTabView.swift` | 修改 | 添加"折叠常用文件夹菜单"开关 | -| `AppState.swift` | 修改 | 添加 4 个新的 `@AppStorage` 属性 | - -### 9.5 设计规范 - -1. **分组风格**: 使用 `Form` + `Section` 结构,每组有独立标题 -2. **图标状态**: 使用 SF Symbols 系统图标(如 `checkmark.circle.fill` 表示已授权) -3. **开关位置**: 每个分类列表顶部或 header 区域 -4. **数据存储**: 使用 `@AppStorage` 持久化存储折叠状态 -5. **文案规范**: 所有文案使用中文,"RClick"替换为"RClick" - -### 9.6 验收标准 - -- [ ] 通用设置界面与 macOS 系统设置风格一致 -- [ ] 折叠开关从通用设置中移除 -- [ ] 每个分类有独立的折叠开关 -- [ ] 所有开关状态持久化保存 -- [ ] 界面采用 SwiftUI 风格,无 AppKit 代码 -- [ ] 编译无错误和警告 - ---- - -## 九、脚本执行功能设计 - -### 9.1 功能概述 - -**用户场景**: -- 用户选中文件/文件夹后,右键菜单选择"执行脚本" -- 子菜单显示用户配置的脚本列表(如:压缩图片、转换格式、上传到服务器等) -- 点击后执行对应脚本,将选中的文件路径作为参数传入 - -**支持脚本类型**: -| 类型 | 说明 | 执行方式 | -|------|------|----------| -| **Shell** | Bash/Zsh 脚本 | `/bin/bash -c "script" -- files...` | -| **Python** | Python 2/3 脚本 | `/usr/bin/python3 -c "script" files...` | -| **AppleScript** | AppleScript 脚本 | `osascript -e "script"` | - -### 9.8 安全设计(P0 - 必须实施) - -**威胁模型**: -- 恶意脚本可能删除用户文件或外泄数据 -- 用户可能被诱导执行危险命令(如 `rm -rf ~/Documents`) -- 脚本可能被未授权修改 - -**安全加固措施**: - -#### 1. 执行前用户确认对话框 - -```swift -// 执行前显示脚本内容预览和用户确认 -func confirmScriptExecution(script: RCScript, files: [String], completion: @escaping (Bool) -> Void) { - let alert = NSAlert() - alert.messageText = "确认执行脚本?" - alert.informativeText = """ - 脚本名称:\(script.name) - 脚本类型:\(script.scriptType) - - 脚本内容预览: - \(script.scriptContent.prefix(200))... - - 将处理 \(files.count) 个文件 - """ - alert.alertStyle = .warning - alert.addButton(withTitle: "执行") - alert.addButton(withTitle: "取消") - - let response = alert.runModal() - completion(response == .alertFirstButtonReturn) -} -``` - -#### 2. 危险命令检测 - -```swift -// 检测脚本中的危险命令 -func detectDangerousCommands(_ scriptContent: String) -> [String] { - let dangerousPatterns = [ - "rm -rf /", - "rm -rf ~", - "sudo", - "dd if=/dev/zero", - ":(){ :|:& };:", // Fork bomb - "> /dev/disk", - "mkfs", - "newfs" - ] - - return dangerousPatterns.filter { scriptContent.contains($0) } -} - -// 使用示例 -let dangerous = detectDangerousCommands(scriptContent) -if !dangerous.isEmpty { - // 显示警告,要求用户二次确认 - showDangerWarning(commands: dangerous) -} -``` - -#### 3. 脚本执行沙盒(可选增强) - -> ⚠️ **注意**:`sandbox-exec` 需要 macOS 特定的 sandbox profile 配置文件,不同 macOS 版本可能存在兼容性差异。建议作为可选增强功能,而非必选项。基础安全由"执行前用户确认"和"危险命令检测"保障。 - -```swift -// 使用 sandbox-exec 限制脚本访问范围(可选) -func executeWithSandbox(script: String, files: [String]) throws { - let sandboxProfile = """ - (version 1) - (deny default) - (allow process-exec) - (allow file-read* (subpath "/Users")) - (allow file-write* (subpath "/Users")) - (deny network-outbound) - (deny system-ipc) - """ - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/sandbox-exec") - process.arguments = ["-f", sandboxProfile, "/bin/bash", "-c", script, "--"] + files - try process.run() -} -``` - -#### 4. 执行超时控制(后台线程执行) - -> ⚠️ **重要**:脚本执行必须在后台线程调用,`process.waitUntilExit()` 如果在主线程执行会阻塞主程序界面。 - -```swift -// 脚本执行超时自动终止(必须在后台线程调用) -func executeScriptWithTimeout(_ script: String, files: [String], timeout: TimeInterval = 30) async throws { - // 使用 async/await 确保在后台执行 - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = ["-c", script, "--"] + files - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - try process.run() - - // 启动超时监控 - DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { - if process.isRunning { - process.terminate() - logger.warning("Script execution timed out after \(timeout)s") - } - } - - process.waitUntilExit() // 在后台线程阻塞是安全的 - let outputData = try pipe.fileHandleForReading.readToEnd() - let output = String(data: outputData ?? Data(), encoding: .utf8) ?? "" - - if process.terminationStatus == 0 { - continuation.resume(returning: ()) - } else { - continuation.resume(throwing: ScriptError.executionFailed(output)) - } - } catch { - continuation.resume(throwing: error) - } - } - } -} -``` - -#### 5. 脚本存储安全 - -```swift -// 脚本文件存储权限控制 -func saveScriptToFile(_ content: String, to url: URL) throws { - // 写入沙盒内目录 - let sandboxURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("Scripts", isDirectory: true) - - try FileManager.default.createDirectory(at: sandboxURL, withIntermediateDirectories: true) - - let scriptFileURL = sandboxURL.appendingPathComponent(UUID().uuidString + ".sh") - try content.write(to: scriptFileURL, atomically: true, encoding: .utf8) - - // 设置只读权限(chmod 500) - let chmod = Process() - chmod.executableURL = URL(fileURLWithPath: "/bin/chmod") - chmod.arguments = ["500", scriptFileURL.path] - try chmod.run() -} -``` - ---- - -### 9.2 数据模型 - -#### SwiftData 实体(ScriptEntity) - -```swift -import SwiftData -import Foundation - -/// 脚本实体 - 用于存储用户自定义脚本配置 -@Model -final class ScriptEntity { - @Attribute(.unique) var id: String - var name: String // 菜单显示名称 - var scriptType: String // "shell" | "python" | "applescript" - var scriptContent: String // 脚本内容 - var icon: String // SF Symbol 图标 - var sortOrder: Int - var isEnabled: Bool - - init(id: String = UUID().uuidString, - name: String, - scriptType: String, - scriptContent: String, - icon: String = "terminal", - sortOrder: Int = 0, - isEnabled: Bool = true) { - self.id = id - self.name = name - self.scriptType = scriptType - self.scriptContent = scriptContent - self.icon = icon - self.sortOrder = sortOrder - self.isEnabled = isEnabled - } -} -``` - -#### 内存模型(RCScript) - -```swift -struct RCScript: RCBase { - var id: String - var name: String - var scriptType: ScriptType - var scriptContent: String - var icon: String - var enabled: Bool - var idx: Int - - enum ScriptType: String, Codable { - case shell = "shell" - case python = "python" - case applescript = "applescript" - } -} -``` - -#### 消息模型(ScriptMenuItem) - -```swift -/// 脚本菜单项(IPC 通信) -struct ScriptMenuItem: Codable { - let id: String - let name: String - let icon: String - let scriptType: String -} -``` - -### 9.3 菜单结构设计 - -```mermaid -graph TD - Root["右键菜单"] - Root --> Actions["动作菜单"] - Root --> Apps["用应用打开"] - Root --> NewFile["新建文件"] - Root --> CommonDir["常用目录"] - Root --> Script["执行脚本 ▼"] - - Script --> Compress["压缩图片"] - Script --> Convert["转换为 PDF"] - Script --> Upload["上传到服务器"] - Script --> Custom["自定义脚本..."] -``` - -** Extension 渲染**: -- 主菜单显示"执行脚本"项(图标:`terminal`) -- 鼠标悬停时展开子菜单 -- 子菜单显示所有启用的脚本(按 `sortOrder` 排序) - -### 9.4 点击事件设计 - -**扩展 MenuItemType**: -```swift -enum MenuItemType: String, Codable { - case action = "action" - case app = "app" - case newFile = "new-file" - case commonDir = "common-dir" - case script = "script" // 新增 -} -``` - -**ClickEventPayload**(扩展): -```swift -struct ClickEventPayload: Codable { - let scene: MenuScene - let currentDirectory: String - let selectedItems: [String] - let menuItem: MenuItemInfo // type 为 script -} -``` - -### 9.5 主程序执行逻辑 - -```swift -// Main App - RClickApp.swift -func executeScript(_ script: RCScript, with files: [String], in directory: String) { - switch script.scriptType { - case .shell: - executeShellScript(script.content, files: files) - case .python: - executePythonScript(script.content, files: files) - case .applescript: - executeAppleScript(script.content, files: files) - } -} - -private func executeShellScript(_ content: String, files: [String]) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/bash") - process.arguments = ["-c", content, "--"] + files - - // 执行并捕获输出 - try? process.run() - process.waitUntilExit() -} - -private func executePythonScript(_ content: String, files: [String]) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") - process.arguments = ["-c", content] + files - - try? process.run() - process.waitUntilExit() -} - -private func executeAppleScript(_ content: String, files: [String]) { - let source = content - // AppleScript 通过 argv 接收参数 - let fullScript = """ - set fileList to every item of the argv - \(source) - """ - // 使用 NSAppleScript 执行 -} -``` - -### 9.6 脚本模板示例 - -| 名称 | 类型 | 脚本示例 | -|------|------|----------| -| 压缩图片 | shell | `for f in "$@"; do sips -Z 1200 "$f"; done` | -| 转换为 PDF | shell | `for f in "$@"; do cjpeg -quality 85 "$f"; done` | -| 显示文件信息 | shell | `mdls "$1"` | -| 在 VSCode 打开 | shell | `open -a "Visual Studio Code" "$@"` | -| 上传到服务器 | python | `import sys; for f in sys.argv[1:]: upload(f)` | -| 获取文件哈希 | shell | `shasum -a 256 "$@"` | -| 批量重命名 | shell | `for f in "$@"; do mv "$f" "${f%.JPG}.jpg"; done` | - -### 9.7 设置界面设计 - -**脚本设置页面**: -- 脚本列表(名称、类型、启用状态) -- 添加/编辑/删除脚本 -- 上下拖动排序 -- 启用/禁用开关 -- 脚本编辑器(语法高亮) -- 测试按钮(选中测试文件执行) - -### 9.8 实施文件清单 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `ScriptEntity.swift` | 新建 | SwiftData 实体 | -| `RCBase.swift` | 修改 | 添加 `RCScript` 结构 | -| `CommunicationProtocol.swift` | 修改 | 添加 `ScriptMenuItem` | -| `AppState.swift` | 修改 | 添加 `scripts` 属性和方法 | -| `ScriptsSettingsTabView.swift` | 新建 | 设置界面 | -| `RClickApp.swift` | 修改 | 添加 `executeScript()` 方法 | -| `FinderSyncExt.swift` | 修改 | 添加脚本菜单渲染 | - ---- - -## 十、参考文档 - -- **通信协议规范**: [contracts/communication-protocol.md](contracts/communication-protocol.md) -- **数据模型设计**: [contracts/data-models.md](contracts/data-models.md) -- **Extension 重连方案**: [contracts/extension-reconnect.md](contracts/extension-reconnect.md) -- **重构计划**: [重构计划.md](重构计划.md) -- **设置界面重构**: [设置界面重构设计.md](设置界面重构设计.md) -- **CLAUDE.md**: [../CLAUDE.md](../CLAUDE.md)