From ae48c2c392492098f43dd483da84aefde01eb5ed Mon Sep 17 00:00:00 2001 From: EliasDev <116743216+EliasMatDev@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:22:38 -0600 Subject: [PATCH 1/5] feat(i18n): add Latin American Spanish (es) localization Adds a complete es translation for both the main app and the FinderSync extension, registers es in knownRegions, and makes the last user-visible hardcoded strings localizable: - RClick/Localizable.xcstrings: 156 es strings (8 keys added that the UI already used but the catalog was missing: OK, Save, Add, Display Name, Default Open App, Edit App Properties, plus Download failed / Unknown error for the updater) - FinderSyncExt/Localizable.xcstrings: 143 es strings (adds the Finder toolbar tooltip key) - FinderSyncExt: toolbarItemToolTip now goes through AppLocalization - Updater: the two remaining Chinese user-facing error strings are now localized keys - Default seed data (actions, common folders) uses the English source keys instead of hardcoded Chinese, so displayName lookups and the English fallback line up - scripts/check-localization.py verifies es coverage and that every translation keeps the source format specifiers, in order Newly introduced keys ship en, es, zh-Hans and ja so no language regresses. English stays the source language and the fallback for unsupported system languages. No logic, signing, entitlement or bundle-id changes. --- FinderSyncExt/FinderSyncExt.swift | 2 +- FinderSyncExt/Localizable.xcstrings | 885 ++++++++++++++++++++- RClick.xcodeproj/project.pbxproj | 1 + RClick/Localizable.xcstrings | 1127 ++++++++++++++++++++++++++- RClick/Model/ActionEntity.swift | 8 +- RClick/Model/CommonDirEntity.swift | 10 +- RClick/Shared/Updater.swift | 6 +- scripts/check-localization.py | 130 +++ 8 files changed, 2153 insertions(+), 16 deletions(-) create mode 100644 scripts/check-localization.py diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index 2635091..bf84987 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -168,7 +168,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { } override var toolbarItemToolTip: String { - return "RClick: Click for menu options" + return AppLocalization.localized("RClick: Click for menu options") } override var toolbarItemImage: NSImage { diff --git a/FinderSyncExt/Localizable.xcstrings b/FinderSyncExt/Localizable.xcstrings index 8b94d4b..a9b5797 100644 --- a/FinderSyncExt/Localizable.xcstrings +++ b/FinderSyncExt/Localizable.xcstrings @@ -10,6 +10,12 @@ "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", @@ -33,6 +39,12 @@ "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", @@ -56,6 +68,12 @@ "value" : "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "3. Haz clic en “+” -> abre “Aplicaciones” -> selecciona “RClick”" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -79,6 +97,12 @@ "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", @@ -102,6 +126,12 @@ "value" : "About" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -125,6 +155,12 @@ "value" : "Accessibility" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accesibilidad" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -148,6 +184,12 @@ "value" : "Actions" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acciones" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -171,6 +213,12 @@ "value" : "Add" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -194,6 +242,12 @@ "value" : "Add App" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar aplicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -217,6 +271,12 @@ "value" : "Add File Type" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar tipo de archivo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -240,6 +300,12 @@ "value" : "Add Folder" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar carpeta" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -263,6 +329,12 @@ "value" : "Added Folders" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpetas agregadas" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -286,6 +358,12 @@ "value" : "AirDrop" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "AirDrop" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -309,6 +387,12 @@ "value" : "Applications" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicaciones" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -332,6 +416,12 @@ "value" : "Apps" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apps" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -355,6 +445,12 @@ "value" : "Arguments (semicolon separated)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumentos (separados por punto y coma)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -378,6 +474,12 @@ "value" : "Arguments: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumentos: %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -401,6 +503,12 @@ "value" : "Authorized" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -424,6 +532,12 @@ "value" : "Automatic" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automático" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -447,6 +561,12 @@ "value" : "Backup" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Respaldo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -470,6 +590,12 @@ "value" : "Cancel" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -493,6 +619,12 @@ "value" : "Change App" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambiar aplicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -516,6 +648,12 @@ "value" : "Change Template" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambiar plantilla" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -539,6 +677,12 @@ "value" : "Check for Updates" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar actualizaciones" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -562,6 +706,12 @@ "value" : "Checking for updates..." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscando actualizaciones..." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -585,6 +735,12 @@ "value" : "Choose Default App" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elegir aplicación predeterminada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -608,6 +764,12 @@ "value" : "Choose Template File" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elegir archivo de plantilla" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -631,6 +793,12 @@ "value" : "Collapse actions menu" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de acciones" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -654,6 +822,12 @@ "value" : "Collapse apps menu" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de aplicaciones" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -677,6 +851,12 @@ "value" : "Collapse menu" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -700,6 +880,12 @@ "value" : "Collapse new file menu" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contraer el menú de nuevo archivo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -723,6 +909,12 @@ "value" : "Common Folders" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpetas frecuentes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -746,6 +938,12 @@ "value" : "Common Folders" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpetas frecuentes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -769,6 +967,12 @@ "value" : "Copy Path" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copiar ruta" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -792,6 +996,12 @@ "value" : "Default Open App" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicación predeterminada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -815,6 +1025,12 @@ "value" : "Delete App" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar aplicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -838,6 +1054,12 @@ "value" : "Delete Direct" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar directamente" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -861,6 +1083,12 @@ "value" : "Desktop" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escritorio" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -884,6 +1112,12 @@ "value" : "Display Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre visible" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -907,6 +1141,12 @@ "value" : "Documents" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Documentos" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -930,6 +1170,12 @@ "value" : "Don't Ask Again" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No preguntar de nuevo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -953,6 +1199,12 @@ "value" : "Download and Install" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargar e instalar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -976,6 +1228,12 @@ "value" : "Download Manually" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargar manualmente" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -999,6 +1257,12 @@ "value" : "Downloads" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descargas" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1022,6 +1286,12 @@ "value" : "Edit App" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar aplicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1045,6 +1315,12 @@ "value" : "Edit App Properties" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar propiedades de la aplicación" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1068,6 +1344,12 @@ "value" : "Edit File Type" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar tipo de archivo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1091,6 +1373,12 @@ "value" : "Enable common folders" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar carpetas frecuentes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1114,6 +1402,12 @@ "value" : "Enable Full Disk Access" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar Acceso total al disco" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1137,6 +1431,12 @@ "value" : "Enable RClick" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar RClick" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1160,6 +1460,12 @@ "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", @@ -1183,6 +1489,12 @@ "value" : "Enabled" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1206,6 +1518,12 @@ "value" : "English" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inglés" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1229,6 +1547,12 @@ "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", @@ -1252,6 +1576,12 @@ "value" : "Environment Variables" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables de entorno" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1275,6 +1605,12 @@ "value" : "Environment variables: %lld" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variables de entorno: %lld" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1298,6 +1634,12 @@ "value" : "Export Logs…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar registros…" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1321,6 +1663,12 @@ "value" : "Export…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar…" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1344,6 +1692,12 @@ "value" : "Extension: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión: %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1367,6 +1721,12 @@ "value" : "Extraction failed: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error al descomprimir: %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1390,6 +1750,12 @@ "value" : "Failed to Check for Updates" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudieron buscar actualizaciones" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1413,6 +1779,12 @@ "value" : "File Extension" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de archivo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1436,6 +1808,12 @@ "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", @@ -1459,10 +1837,16 @@ "value" : "Finder Extension" } }, - "ja" : { + "es" : { "stringUnit" : { "state" : "translated", - "value" : "Finder 拡張" + "value" : "Extensión del Finder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finder 拡張" } }, "zh-Hans" : { @@ -1482,6 +1866,12 @@ "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", @@ -1505,6 +1895,12 @@ "value" : "Folders cannot be shared via AirDrop: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las carpetas no se pueden compartir por AirDrop: %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1528,6 +1924,12 @@ "value" : "For example: txt, md, json" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por ejemplo: txt, md, json" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1551,6 +1953,12 @@ "value" : "Format: KEY=VALUE, one per line" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Formato: CLAVE=VALOR, uno por línea" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1574,6 +1982,12 @@ "value" : "Full Disk Access" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acceso total al disco" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1597,6 +2011,12 @@ "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", @@ -1620,6 +2040,12 @@ "value" : "General" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "General" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1643,6 +2069,12 @@ "value" : "Grant Permission" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otorgar permiso" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1666,6 +2098,12 @@ "value" : "Hide" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1689,6 +2127,12 @@ "value" : "Home" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta de inicio" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1712,6 +2156,12 @@ "value" : "Icon" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Icono" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1735,6 +2185,12 @@ "value" : "Ignore This Version" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar esta versión" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1758,6 +2214,12 @@ "value" : "Import…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Importar…" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1781,6 +2243,12 @@ "value" : "Installation failed: %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error al instalar: %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1804,6 +2272,12 @@ "value" : "Invalid Folder" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta no válida" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1827,6 +2301,12 @@ "value" : "Japanese" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Japonés" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1850,6 +2330,12 @@ "value" : "Language" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Idioma" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1873,6 +2359,12 @@ "value" : "Later" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más tarde" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1896,6 +2388,12 @@ "value" : "Launch at login" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir al iniciar sesión" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1919,6 +2417,12 @@ "value" : "Logs" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1942,6 +2446,12 @@ "value" : "Main Controls" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controles principales" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1965,6 +2475,12 @@ "value" : "Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -1988,6 +2504,12 @@ "value" : "New File" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuevo archivo" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2011,6 +2533,12 @@ "value" : "New Version Available" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva versión disponible" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2034,6 +2562,12 @@ "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", @@ -2057,6 +2591,12 @@ "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", @@ -2080,6 +2620,12 @@ "value" : "No update is available." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay actualizaciones disponibles." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2103,6 +2649,12 @@ "value" : "Not Authorized" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin autorización" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2126,6 +2678,12 @@ "value" : "Notice" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2149,6 +2707,12 @@ "value" : "OK" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2172,6 +2736,12 @@ "value" : "Open Settings" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir Ajustes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2195,6 +2765,12 @@ "value" : "Open With" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir con" + } + }, "zh-Hans" : { "stringUnit" : { "state" : "translated", @@ -2212,6 +2788,12 @@ "value" : "Permissions" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permisos" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2235,6 +2817,12 @@ "value" : "Please select the correct Applications folder." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona la carpeta Aplicaciones correcta." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2258,6 +2846,12 @@ "value" : "Preview:" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vista previa:" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2281,6 +2875,12 @@ "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", @@ -2304,6 +2904,12 @@ "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", @@ -2327,6 +2933,12 @@ "value" : "Quit" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salir" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2350,6 +2962,12 @@ "value" : "RClick (loading...)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "RClick (cargando...)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2373,6 +2991,12 @@ "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", @@ -2396,6 +3020,12 @@ "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", @@ -2419,6 +3049,12 @@ "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", @@ -2442,6 +3078,12 @@ "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", @@ -2456,6 +3098,35 @@ } } }, + "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" : { @@ -2465,6 +3136,12 @@ "value" : "Reset" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2488,6 +3165,12 @@ "value" : "Reset All Settings?" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Restablecer todos los ajustes?" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2511,6 +3194,12 @@ "value" : "Reset All Settings…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer todos los ajustes…" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2534,6 +3223,12 @@ "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", @@ -2557,6 +3252,12 @@ "value" : "Restart Later" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar más tarde" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2580,6 +3281,12 @@ "value" : "Restart Now" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reiniciar ahora" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2603,6 +3310,12 @@ "value" : "Restore Defaults" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurar valores predeterminados" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2626,6 +3339,12 @@ "value" : "Run Arguments" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Argumentos de ejecución" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2649,6 +3368,12 @@ "value" : "Save" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2672,6 +3397,12 @@ "value" : "Separate multiple arguments with semicolons (;)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Separa varios argumentos con punto y coma (;)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2695,6 +3426,12 @@ "value" : "Settings" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2718,6 +3455,12 @@ "value" : "Settings Management" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración de ajustes" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2741,6 +3484,12 @@ "value" : "Settings…" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes…" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2764,6 +3513,12 @@ "value" : "SF Symbol Name" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre del SF Symbol" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2787,6 +3542,12 @@ "value" : "Show icon in menu bar" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar el icono en la barra de menús" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2810,6 +3571,12 @@ "value" : "Simplified Chinese" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chino simplificado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2833,6 +3600,12 @@ "value" : "Template" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plantilla" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2856,6 +3629,12 @@ "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." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2879,6 +3658,12 @@ "value" : "The application has been updated successfully. Restart the app to finish the update." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2902,6 +3687,12 @@ "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." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2925,6 +3716,12 @@ "value" : "The current folder cannot be shared. Please select files or subfolders instead." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede compartir la carpeta actual. En su lugar, selecciona archivos o subcarpetas." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2948,6 +3745,12 @@ "value" : "The current version is already up to date." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La versión actual ya está actualizada." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2971,6 +3774,12 @@ "value" : "The selected folder is a subfolder of an already selected folder. Please choose a different folder." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La carpeta seleccionada es una subcarpeta de otra que ya elegiste. Elige una carpeta diferente." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -2994,6 +3803,12 @@ "value" : "The user cancelled authorization." } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El usuario canceló la autorización." + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3017,6 +3832,12 @@ "value" : "This will delete all custom configurations and restore the defaults. This action cannot be undone." } }, + "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", @@ -3040,6 +3861,12 @@ "value" : "Unauthorized Folder" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carpeta sin autorización" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3063,6 +3890,12 @@ "value" : "Unhide" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3086,6 +3919,12 @@ "value" : "Unknown" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconocido" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3109,6 +3948,12 @@ "value" : "Untitled" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin título" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3132,6 +3977,12 @@ "value" : "Up to Date" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo actualizado" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3155,6 +4006,12 @@ "value" : "Update Installed" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización instalada" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3178,6 +4035,12 @@ "value" : "Version %@" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3201,6 +4064,12 @@ "value" : "Version %@ (%@)" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión %@ (%@)" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3224,6 +4093,12 @@ "value" : "Version %@ is ignored" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ignoró la versión %@" + } + }, "ja" : { "stringUnit" : { "state" : "translated", @@ -3247,6 +4122,12 @@ "value" : "Warning" } }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advertencia" + } + }, "ja" : { "stringUnit" : { "state" : "translated", diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index 602e611..e101abb 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -316,6 +316,7 @@ Base, "zh-Hans", ja, + es, ); mainGroup = 5C8A28D12F83D65900FE687C; minimizedProjectReferenceProxies = 1; diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 1962e41..5b3673f 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -40,6 +40,12 @@ "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", @@ -63,6 +69,12 @@ "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", @@ -86,6 +98,12 @@ "value": "3. Click \"+\" -> open \"Applications\" -> select \"RClick\"" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "3. Haz clic en “+” -> abre “Aplicaciones” -> selecciona “RClick”" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -109,6 +127,12 @@ "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", @@ -132,6 +156,12 @@ "value": "About" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acerca de" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -155,6 +185,12 @@ "value": "Accessibility" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Accesibilidad" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -178,6 +214,12 @@ "value": "Actions" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acciones" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -192,6 +234,35 @@ } } }, + "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 a Folder…": { "extractionState": "manual", "localizations": { @@ -201,6 +272,12 @@ "value": "Add a Folder…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar una carpeta…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -224,6 +301,12 @@ "value": "Add App" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar aplicación" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -247,6 +330,12 @@ "value": "Add File Type" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar tipo de archivo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -270,6 +359,12 @@ "value": "Add Folder" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar carpeta" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -293,6 +388,12 @@ "value": "Added Folders" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas agregadas" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -316,6 +417,12 @@ "value": "AirDrop" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -339,6 +446,12 @@ "value": "Applications" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicaciones" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -362,6 +475,12 @@ "value": "Apps" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Apps" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -385,6 +504,12 @@ "value": "Are you sure you want to delete \"%@\" (%@)? This action cannot be undone." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Seguro que quieres eliminar “%@” (%@)? Esta acción no se puede deshacer." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -408,6 +533,12 @@ "value": "Arguments (semicolon separated)" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Argumentos (separados por punto y coma)" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -431,6 +562,12 @@ "value": "Arguments: %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Argumentos: %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -454,6 +591,12 @@ "value": "Authorize folders to let RClick create, delete, and manage files in them." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autoriza carpetas para que RClick pueda crear, eliminar y administrar archivos en ellas." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -477,6 +620,12 @@ "value": "Authorized" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autorizado" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -500,6 +649,12 @@ "value": "Backup" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Respaldo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -523,6 +678,12 @@ "value": "Cancel" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -546,6 +707,12 @@ "value": "Change App" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar aplicación" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -569,6 +736,12 @@ "value": "Change Template" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar plantilla" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -592,6 +765,12 @@ "value": "Check for Updates" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -615,6 +794,12 @@ "value": "Checking for updates..." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscando actualizaciones..." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -638,6 +823,12 @@ "value": "Choose a folder for RClick to access." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige una carpeta a la que RClick pueda acceder." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -661,6 +852,12 @@ "value": "Choose Default App" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir aplicación predeterminada" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -684,6 +881,12 @@ "value": "Choose Template File" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir archivo de plantilla" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -707,6 +910,12 @@ "value": "Collapse actions menu" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú de acciones" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -730,6 +939,12 @@ "value": "Collapse apps menu" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú de aplicaciones" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -753,6 +968,12 @@ "value": "Collapse menu" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -776,6 +997,12 @@ "value": "Collapse new file menu" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú de nuevo archivo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -799,6 +1026,12 @@ "value": "Common Folders" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas frecuentes" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -822,6 +1055,12 @@ "value": "Copy Path" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar ruta" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -836,6 +1075,35 @@ } } }, + "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": { "extractionState": "manual", "localizations": { @@ -845,6 +1113,12 @@ "value": "Delete" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -868,6 +1142,12 @@ "value": "Delete App" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar aplicación" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -891,6 +1171,12 @@ "value": "Delete Direct" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar directamente" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -914,6 +1200,12 @@ "value": "Delete File Type" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar tipo de archivo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -937,6 +1229,12 @@ "value": "Desktop" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escritorio" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -951,6 +1249,35 @@ } } }, + "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": { @@ -960,6 +1287,12 @@ "value": "Documents" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Documentos" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -983,6 +1316,12 @@ "value": "Don't Ask Again" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No preguntar de nuevo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1006,6 +1345,12 @@ "value": "Done" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Listo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1029,6 +1374,12 @@ "value": "Download and Install" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar e instalar" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1043,13 +1394,48 @@ } } }, + "Download failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La descarga falló" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードに失敗しました" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载失败" + } + } + } + }, "Download Manually": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Download Manually" + "value": "Download Manually" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar manualmente" } }, "ja": { @@ -1075,6 +1461,12 @@ "value": "Downloads" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargas" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1098,6 +1490,12 @@ "value": "Edit App" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar aplicación" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1112,6 +1510,35 @@ } } }, + "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": { @@ -1121,6 +1548,12 @@ "value": "Edit File Type" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar tipo de archivo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1144,6 +1577,12 @@ "value": "Enable common folders" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar carpetas frecuentes" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1167,6 +1606,12 @@ "value": "Enable Full Disk Access" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar Acceso total al disco" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1190,6 +1635,12 @@ "value": "Enable RClick" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar RClick" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1213,6 +1664,12 @@ "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", @@ -1236,6 +1693,12 @@ "value": "Enabled" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1259,6 +1722,12 @@ "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", @@ -1282,6 +1751,12 @@ "value": "Environment Variables" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Variables de entorno" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1305,6 +1780,12 @@ "value": "Environment variables: %lld" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Variables de entorno: %lld" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1328,6 +1809,12 @@ "value": "Export Logs…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar registros…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1351,6 +1838,12 @@ "value": "Export…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1374,6 +1867,12 @@ "value": "Extension: %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensión: %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1397,6 +1896,12 @@ "value": "Extraction failed: %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al descomprimir: %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1420,6 +1925,12 @@ "value": "Failed to Check for Updates" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron buscar actualizaciones" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1443,6 +1954,12 @@ "value": "File Extension" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensión de archivo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1466,6 +1983,12 @@ "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", @@ -1489,6 +2012,12 @@ "value": "Finder Extension" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensión del Finder" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1512,6 +2041,12 @@ "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", @@ -1535,6 +2070,12 @@ "value": "Folder Permissions" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permisos de carpetas" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1558,6 +2099,12 @@ "value": "Folders cannot be shared via AirDrop: %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las carpetas no se pueden compartir por AirDrop: %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1581,6 +2128,12 @@ "value": "For example: txt, md, json" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Por ejemplo: txt, md, json" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1604,6 +2157,12 @@ "value": "Format: KEY=VALUE, one per line" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Formato: CLAVE=VALOR, uno por línea" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1627,6 +2186,12 @@ "value": "Full Disk Access" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso total al disco" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1650,6 +2215,12 @@ "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", @@ -1673,6 +2244,12 @@ "value": "General" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "General" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1687,7 +2264,16 @@ } } }, - "github.com/wflixu/RClick": {}, + "github.com/wflixu/RClick": { + "localizations": { + "es": { + "stringUnit": { + "state": "translated", + "value": "github.com/wflixu/RClick" + } + } + } + }, "Grant Access": { "extractionState": "manual", "localizations": { @@ -1697,6 +2283,12 @@ "value": "Grant Access" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir acceso" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1720,6 +2312,12 @@ "value": "Grant Permission" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otorgar permiso" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1743,6 +2341,12 @@ "value": "Grant RClick access to this folder to perform file operations." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otorga a RClick acceso a esta carpeta para realizar operaciones con archivos." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1766,6 +2370,12 @@ "value": "Hide" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1789,6 +2399,12 @@ "value": "Home" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpeta de inicio" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1812,6 +2428,12 @@ "value": "Icon" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Icono" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1835,6 +2457,12 @@ "value": "Ignore This Version" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ignorar esta versión" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1858,6 +2486,12 @@ "value": "Import…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Importar…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1881,6 +2515,12 @@ "value": "Installation failed: %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al instalar: %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1904,6 +2544,12 @@ "value": "Invalid Folder" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpeta no válida" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1927,6 +2573,12 @@ "value": "Launch at login" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir al iniciar sesión" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1950,6 +2602,12 @@ "value": "Logs" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registros" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1973,6 +2631,12 @@ "value": "Main Controls" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controles principales" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -1996,6 +2660,12 @@ "value": "Manage…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrar…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2019,6 +2689,12 @@ "value": "Name" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2042,6 +2718,12 @@ "value": "New File" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo archivo" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2065,6 +2747,12 @@ "value": "New Version Available" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva versión disponible" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2088,6 +2776,12 @@ "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", @@ -2111,6 +2805,12 @@ "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", @@ -2134,6 +2834,12 @@ "value": "No folders authorized" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay carpetas autorizadas" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2157,6 +2863,12 @@ "value": "No update is available." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay actualizaciones disponibles." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2180,6 +2892,12 @@ "value": "Not Authorized" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin autorización" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2203,6 +2921,12 @@ "value": "Notice" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aviso" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2217,6 +2941,35 @@ } } }, + "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": { @@ -2226,6 +2979,12 @@ "value": "Open Settings" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2249,6 +3008,12 @@ "value": "Open With" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir con" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2266,6 +3031,12 @@ "value": "Open With %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir con %@" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2283,6 +3054,12 @@ "value": "Permissions" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permisos" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2306,6 +3083,12 @@ "value": "Please select the correct Applications folder." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Selecciona la carpeta Aplicaciones correcta." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2329,6 +3112,12 @@ "value": "PPTX" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "PPTX" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2346,6 +3135,12 @@ "value": "Preview:" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista previa:" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2369,6 +3164,12 @@ "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", @@ -2392,6 +3193,12 @@ "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", @@ -2415,6 +3222,12 @@ "value": "Quit" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salir" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2437,6 +3250,12 @@ "value": "RClick" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2454,6 +3273,12 @@ "value": "RClick (loading...)" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick (cargando...)" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2477,6 +3302,12 @@ "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." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -2494,6 +3325,12 @@ "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", @@ -2517,6 +3354,12 @@ "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", @@ -2540,6 +3383,12 @@ "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", @@ -2563,6 +3412,12 @@ "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", @@ -2586,6 +3441,12 @@ "value": "Remove folder permission" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quitar el permiso de la carpeta" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2609,6 +3470,12 @@ "value": "Reset" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2632,6 +3499,12 @@ "value": "Reset All Settings?" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Restablecer todos los ajustes?" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2655,6 +3528,12 @@ "value": "Reset All Settings…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer todos los ajustes…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2678,6 +3557,12 @@ "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", @@ -2701,6 +3586,12 @@ "value": "Restart Later" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar más tarde" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2724,6 +3615,12 @@ "value": "Restart Now" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar ahora" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2747,6 +3644,12 @@ "value": "Restore Defaults" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restaurar valores predeterminados" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2770,6 +3673,12 @@ "value": "Run Arguments" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Argumentos de ejecución" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2784,6 +3693,35 @@ } } }, + "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": { @@ -2793,6 +3731,12 @@ "value": "Separate multiple arguments with semicolons (;)" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Separa varios argumentos con punto y coma (;)" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2816,6 +3760,12 @@ "value": "Settings" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2839,6 +3789,12 @@ "value": "Settings Management" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administración de ajustes" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2862,6 +3818,12 @@ "value": "Settings…" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes…" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2885,6 +3847,12 @@ "value": "SF Symbol Name" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre del SF Symbol" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2908,6 +3876,12 @@ "value": "Show icon in menu bar" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar el icono en la barra de menús" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2931,6 +3905,12 @@ "value": "Template" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Plantilla" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2954,6 +3934,12 @@ "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." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -2977,6 +3963,12 @@ "value": "The application has been updated successfully. Restart the app to finish the update." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3000,6 +3992,12 @@ "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." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3023,6 +4021,12 @@ "value": "The current folder cannot be shared. Please select files or subfolders instead." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede compartir la carpeta actual. En su lugar, selecciona archivos o subcarpetas." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3046,6 +4050,12 @@ "value": "The current version is already up to date." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La versión actual ya está actualizada." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3069,6 +4079,12 @@ "value": "The selected folder is a subfolder of an already selected folder. Please choose a different folder." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La carpeta seleccionada es una subcarpeta de otra que ya elegiste. Elige una carpeta diferente." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3092,6 +4108,12 @@ "value": "The user cancelled authorization." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El usuario canceló la autorización." + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3115,6 +4137,12 @@ "value": "This will delete all custom configurations and restore the defaults. This action cannot be undone." } }, + "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", @@ -3138,6 +4166,12 @@ "value": "Unauthorized Folder" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpeta sin autorización" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3161,6 +4195,12 @@ "value": "Unhide" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3184,6 +4224,12 @@ "value": "Unknown" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3198,6 +4244,35 @@ } } }, + "Unknown error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown error" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error desconocido" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラー" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知错误" + } + } + } + }, "Untitled": { "extractionState": "manual", "localizations": { @@ -3207,6 +4282,12 @@ "value": "Untitled" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin título" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3230,6 +4311,12 @@ "value": "Up to Date" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todo actualizado" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3253,6 +4340,12 @@ "value": "Update Installed" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualización instalada" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3276,6 +4369,12 @@ "value": "Version %@" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Versión %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3299,6 +4398,12 @@ "value": "Version %@ (%@)" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Versión %@ (%@)" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3322,6 +4427,12 @@ "value": "Version %@ is ignored" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se ignoró la versión %@" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -3345,6 +4456,12 @@ "value": "Want to add a feature? Give feedback here." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Quieres proponer una función? Envía tus comentarios aquí." + } + }, "zh-Hans": { "stringUnit": { "state": "translated", @@ -3362,6 +4479,12 @@ "value": "Warning" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Advertencia" + } + }, "ja": { "stringUnit": { "state": "translated", diff --git a/RClick/Model/ActionEntity.swift b/RClick/Model/ActionEntity.swift index 4d77802..6261360 100644 --- a/RClick/Model/ActionEntity.swift +++ b/RClick/Model/ActionEntity.swift @@ -47,10 +47,10 @@ final class ActionEntity { // 预定义动作的工厂方法(默认只开启前两个) static func createDefaultActions() -> [ActionEntity] { return [ - ActionEntity(id: "copy-path", name: "复制路径", icon: "doc.on.doc", isEnabled: true, sortOrder: 0), - ActionEntity(id: "delete-direct", name: "直接删除", icon: "trash", isEnabled: true, sortOrder: 1), - ActionEntity(id: "hide", name: "隐藏", icon: "eye.slash", isEnabled: false, sortOrder: 2), - ActionEntity(id: "unhide", name: "显示", icon: "eye", isEnabled: false, sortOrder: 3), + ActionEntity(id: "copy-path", name: "Copy Path", icon: "doc.on.doc", isEnabled: true, sortOrder: 0), + ActionEntity(id: "delete-direct", name: "Delete Direct", icon: "trash", isEnabled: true, sortOrder: 1), + ActionEntity(id: "hide", name: "Hide", icon: "eye.slash", isEnabled: false, sortOrder: 2), + ActionEntity(id: "unhide", name: "Unhide", icon: "eye", isEnabled: false, sortOrder: 3), ActionEntity(id: "airdrop", name: "AirDrop", icon: "paperplane", isEnabled: false, sortOrder: 4), ] } diff --git a/RClick/Model/CommonDirEntity.swift b/RClick/Model/CommonDirEntity.swift index e101db0..ffa5631 100644 --- a/RClick/Model/CommonDirEntity.swift +++ b/RClick/Model/CommonDirEntity.swift @@ -65,11 +65,11 @@ final class CommonDirEntity { let documents = homeDir.appendingPathComponent("Documents") let downloads = homeDir.appendingPathComponent("Downloads") - dirs.append(CommonDirEntity(id: "desktop", name: "桌面", path: desktop, icon: iconForDirectory(url: desktop), sortOrder: 0)) - dirs.append(CommonDirEntity(id: "documents", name: "文档", path: documents, icon: iconForDirectory(url: documents), sortOrder: 1)) - dirs.append(CommonDirEntity(id: "downloads", name: "下载", path: downloads, icon: iconForDirectory(url: downloads), sortOrder: 2)) - dirs.append(CommonDirEntity(id: "applications", name: "应用程序", path: URL(fileURLWithPath: "/Applications"), icon: iconForDirectory(url: URL(fileURLWithPath: "/Applications")), sortOrder: 3)) - dirs.append(CommonDirEntity(id: "home", name: "用户主目录", path: homeDir, icon: iconForDirectory(url: homeDir), sortOrder: 4)) + dirs.append(CommonDirEntity(id: "desktop", name: "Desktop", path: desktop, icon: iconForDirectory(url: desktop), sortOrder: 0)) + dirs.append(CommonDirEntity(id: "documents", name: "Documents", path: documents, icon: iconForDirectory(url: documents), sortOrder: 1)) + dirs.append(CommonDirEntity(id: "downloads", name: "Downloads", path: downloads, icon: iconForDirectory(url: downloads), sortOrder: 2)) + dirs.append(CommonDirEntity(id: "applications", name: "Applications", path: URL(fileURLWithPath: "/Applications"), icon: iconForDirectory(url: URL(fileURLWithPath: "/Applications")), sortOrder: 3)) + dirs.append(CommonDirEntity(id: "home", name: "Home", path: homeDir, icon: iconForDirectory(url: homeDir), sortOrder: 4)) return dirs } diff --git a/RClick/Shared/Updater.swift b/RClick/Shared/Updater.swift index 68c4609..0b632b5 100644 --- a/RClick/Shared/Updater.swift +++ b/RClick/Shared/Updater.swift @@ -273,6 +273,8 @@ class UpdateManager: ObservableObject { var request = URLRequest(url: URL(string: asset.browserDownloadUrl)!) request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + let downloadFailedMessage = AppLocalization.localized("Download failed") + // 使用 AsyncThrowingStream 来包装下载进度和结果 return try await withCheckedThrowingContinuation { continuation in // Stream bytes and write to destination file @@ -290,7 +292,7 @@ class UpdateManager: ObservableObject { let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - continuation.resume(throwing: DownloadError.downloadFailed("下载失败")) + continuation.resume(throwing: DownloadError.downloadFailed(downloadFailedMessage)) print("downn error") return } @@ -337,7 +339,7 @@ class UpdateManager: ObservableObject { guard process.terminationStatus == 0 else { let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorString = String(data: errorData, encoding: .utf8) ?? "未知错误" + let errorString = String(data: errorData, encoding: .utf8) ?? AppLocalization.localized("Unknown error") throw InstallationError.zipExtractionFailed(String(format: AppLocalization.localized("Extraction failed: %@"), errorString)) } diff --git a/scripts/check-localization.py b/scripts/check-localization.py new file mode 100644 index 0000000..3f428ad --- /dev/null +++ b/scripts/check-localization.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Localization sanity checks for RClick's String Catalogs. + +Verifies, for every `Localizable.xcstrings` passed on the command line (or, by +default, both catalogs of the project): + +1. The catalog is valid JSON with the expected top-level shape. +2. Every key carries a translation for each language in --languages + (default: es). Keys whose source string holds no letters at all — bare + format specifiers such as "%@" or "%lld" — have nothing to translate and + are reported as skipped instead of missing. +3. Every translation uses exactly the same format specifiers, in the same + order, as the source string (the `en` value, or the key itself when the + catalog has no explicit `en` entry). + +Exit code is 0 when everything passes, 1 otherwise. + +Usage: + python3 scripts/check-localization.py + python3 scripts/check-localization.py --languages es ja zh-Hans +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +DEFAULT_CATALOGS = ( + "RClick/Localizable.xcstrings", + "FinderSyncExt/Localizable.xcstrings", +) + +# %@ %1$@ %lld %2$lld %d %f %% ... +SPECIFIER = re.compile(r"%(?:(\d+)\$)?(?:%|@|l{0,2}[dioux]|[eEfgGcs])") + + +def specifiers(text: str) -> list[str]: + """Return the format specifiers of `text`, in order of appearance.""" + return [m.group(0) for m in SPECIFIER.finditer(text)] + + +def source_value(key: str, entry: dict) -> str: + """The string the translations must stay compatible with.""" + en = entry.get("localizations", {}).get("en", {}).get("stringUnit", {}) + return en.get("value", key) + + +def translatable(source: str) -> bool: + """False for keys that are only format specifiers or punctuation.""" + return any(char.isalpha() for char in SPECIFIER.sub("", source)) + + +def check_catalog(path: Path, languages: list[str]) -> tuple[list[str], list[str]]: + problems: list[str] = [] + skipped: list[str] = [] + try: + catalog = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"{path}: unreadable or invalid JSON ({exc})"], skipped + + if not isinstance(catalog.get("strings"), dict): + return [f"{path}: missing top-level 'strings' object"], skipped + + for key, entry in sorted(catalog["strings"].items()): + localizations = entry.get("localizations", {}) + source = source_value(key, entry) + expected = specifiers(source) + + if not translatable(source): + skipped.append(f"{path}: nothing to translate in {key!r}") + continue + + for language in languages: + unit = localizations.get(language, {}).get("stringUnit", {}) + value = unit.get("value") + if value is None: + problems.append(f"{path}: [{language}] missing translation for {key!r}") + continue + found = specifiers(value) + if found != expected: + problems.append( + f"{path}: [{language}] format specifiers differ for {key!r}: " + f"source {expected} vs translation {found}" + ) + + return problems, skipped + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("catalogs", nargs="*", default=list(DEFAULT_CATALOGS)) + parser.add_argument("--languages", nargs="+", default=["es"]) + args = parser.parse_args() + + root = Path(__file__).resolve().parent.parent + problems: list[str] = [] + skipped: list[str] = [] + checked = 0 + + for name in args.catalogs or DEFAULT_CATALOGS: + path = Path(name) + if not path.is_absolute(): + path = root / path + catalog_problems, catalog_skipped = check_catalog(path, args.languages) + problems.extend(catalog_problems) + skipped.extend(catalog_skipped) + checked += 1 + + for entry in skipped: + print(f"skipped: {entry}") + + if problems: + for problem in problems: + print(problem) + print(f"\n{len(problems)} problem(s) in {checked} catalog(s).") + return 1 + + print( + f"OK: {checked} catalog(s) valid, every translatable key rendered in " + f"{', '.join(args.languages)} with matching format specifiers " + f"({len(skipped)} key(s) skipped as non-translatable)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 3d8023dbb04c50992c3f1332627900b3a01239ea Mon Sep 17 00:00:00 2001 From: stors789 Date: Fri, 14 Aug 2026 11:14:47 +0800 Subject: [PATCH 2/5] fix(ci): provision App Groups for Developer ID releases --- .github/workflows/release.yml | 158 ++++++++++++++++-- ...64\346\226\260\346\226\271\346\241\210.md" | 17 +- 2 files changed, 155 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ee1bfb..e3cf990 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,10 @@ jobs: runs-on: macos-latest env: APP_NAME: RClick + APP_BUNDLE_ID: cn.wflixu.RClick EXTENSION_NAME: FinderSyncExt + EXTENSION_BUNDLE_ID: cn.wflixu.RClick.FinderSyncExt + APP_GROUP_ID: group.cn.wflixu.RClick SCHEME: RClick PROJECT: RClick.xcodeproj steps: @@ -63,9 +66,104 @@ jobs: security list-keychains -d user -s build.keychain login.keychain - CERT_NAME=$(security find-identity -v -s 'Developer ID Application' | head -n 1 | awk -F'"' '{print $2}') - echo "CERT_NAME=$CERT_NAME" >> $GITHUB_ENV - echo "Using certificate: $CERT_NAME" + IDENTITY_INFO=$(security find-identity -v -p codesigning build.keychain \ + | awk '/"Developer ID Application:/') + IDENTITY_COUNT=$(printf '%s\n' "$IDENTITY_INFO" \ + | awk 'NF { count++ } END { print count + 0 }') + if [ "$IDENTITY_COUNT" -ne 1 ]; then + echo "Expected exactly one Developer ID Application identity, found $IDENTITY_COUNT" + printf '%s\n' "$IDENTITY_INFO" + exit 1 + fi + + CERT_IDENTITY=$(printf '%s\n' "$IDENTITY_INFO" | awk '{print $2}') + CERT_NAME=$(printf '%s\n' "$IDENTITY_INFO" | awk -F'"' '{print $2}') + echo "CERT_IDENTITY=$CERT_IDENTITY" >> "$GITHUB_ENV" + echo "Using certificate: $CERT_NAME ($CERT_IDENTITY)" + + - name: Prepare App Group Provisioning Profiles + env: + APP_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISIONING_PROFILE }} + EXTENSION_PROFILE_BASE64: ${{ secrets.MACOS_EXTENSION_PROVISIONING_PROFILE }} + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + : "${APP_PROFILE_BASE64:?Missing MACOS_APP_PROVISIONING_PROFILE secret}" + : "${EXTENSION_PROFILE_BASE64:?Missing MACOS_EXTENSION_PROVISIONING_PROFILE secret}" + : "${TEAM_ID:?Missing APPLE_TEAM_ID secret}" + : "${CERT_IDENTITY:?Missing Developer ID Application identity}" + + mkdir -p signing + APP_PROFILE_PATH="$PWD/signing/${APP_NAME}.provisionprofile" + EXTENSION_PROFILE_PATH="$PWD/signing/${EXTENSION_NAME}.provisionprofile" + printf '%s' "$APP_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH" + printf '%s' "$EXTENSION_PROFILE_BASE64" | base64 --decode > "$EXTENSION_PROFILE_PATH" + + security cms -D -i "$APP_PROFILE_PATH" -o signing/app-profile.plist + security cms -D -i "$EXTENSION_PROFILE_PATH" -o signing/extension-profile.plist + + validate_profile() { + local profile_plist="$1" + local expected_bundle_id="$2" + + python3 - \ + "$profile_plist" \ + "$expected_bundle_id" \ + "$TEAM_ID" \ + "$APP_GROUP_ID" \ + "$CERT_IDENTITY" <<'PY' + import hashlib + import plistlib + import sys + + profile_path, expected_bundle_id, expected_team, expected_group, signing_identity = sys.argv[1:] + with open(profile_path, "rb") as profile_file: + profile = plistlib.load(profile_file) + + entitlements = profile.get("Entitlements", {}) + actual_identifier = entitlements.get("com.apple.application-identifier", "") + _, separator, actual_bundle_id = actual_identifier.partition(".") + if not separator or actual_bundle_id != expected_bundle_id: + raise SystemExit( + f"Provisioning profile does not match bundle ID " + f"{expected_bundle_id}: {actual_identifier}" + ) + + actual_team = entitlements.get("com.apple.developer.team-identifier") + if actual_team != expected_team: + raise SystemExit( + f"Provisioning profile team mismatch: expected {expected_team}, got {actual_team}" + ) + + actual_groups = entitlements.get("com.apple.security.application-groups") + if not isinstance(actual_groups, list) or expected_group not in actual_groups: + raise SystemExit(f"Provisioning profile does not authorize {expected_group}") + + certificate_hashes = { + hashlib.sha1(certificate).hexdigest().upper() + for certificate in profile.get("DeveloperCertificates", []) + if isinstance(certificate, bytes) + } + if signing_identity.upper() not in certificate_hashes: + raise SystemExit( + f"Provisioning profile does not contain signing certificate {signing_identity}" + ) + PY + } + + validate_profile signing/app-profile.plist "$APP_BUNDLE_ID" + validate_profile signing/extension-profile.plist "$EXTENSION_BUNDLE_ID" + APP_IDENTIFIER=$(/usr/libexec/PlistBuddy \ + -c 'Print :Entitlements:com.apple.application-identifier' \ + signing/app-profile.plist) + EXTENSION_IDENTIFIER=$(/usr/libexec/PlistBuddy \ + -c 'Print :Entitlements:com.apple.application-identifier' \ + signing/extension-profile.plist) + + echo "APP_PROFILE_PATH=$APP_PROFILE_PATH" >> "$GITHUB_ENV" + echo "EXTENSION_PROFILE_PATH=$EXTENSION_PROFILE_PATH" >> "$GITHUB_ENV" + echo "APP_IDENTIFIER=$APP_IDENTIFIER" >> "$GITHUB_ENV" + echo "EXTENSION_IDENTIFIER=$EXTENSION_IDENTIFIER" >> "$GITHUB_ENV" # --- 2. 构建 Release App --- - name: Build Release @@ -102,10 +200,29 @@ jobs: # --- 3. 分层代码签名 --- - name: Code Sign + env: + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - echo "Signing with: $CERT_NAME" + echo "Signing with identity: $CERT_IDENTITY" + + MAIN_ENTITLEMENTS="signing/${APP_NAME}.entitlements" + EXTENSION_ENTITLEMENTS="signing/${EXTENSION_NAME}.entitlements" + cp "${APP_NAME}/${APP_NAME}.entitlements" "$MAIN_ENTITLEMENTS" + cp "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" "$EXTENSION_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.application-identifier string $APP_IDENTIFIER" \ + "$MAIN_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.developer.team-identifier string $TEAM_ID" \ + "$MAIN_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.application-identifier string $EXTENSION_IDENTIFIER" \ + "$EXTENSION_ENTITLEMENTS" + /usr/libexec/PlistBuddy \ + -c "Add :com.apple.developer.team-identifier string $TEAM_ID" \ + "$EXTENSION_ENTITLEMENTS" # 1) 签名所有 framework if [ -d "$APP_PATH/Contents/Frameworks" ]; then @@ -113,32 +230,45 @@ jobs: find "$APP_PATH/Contents/Frameworks" -type f -perm +111 \ -exec codesign --force --options runtime \ --timestamp \ - --sign "$CERT_NAME" {} \; + --sign "$CERT_IDENTITY" {} \; fi # 2) 签名扩展 (FinderSyncExt.appex) echo "Signing FinderSync extension..." EXT_PATH="$APP_PATH/Contents/PlugIns/${EXTENSION_NAME}.appex" if [ -d "$EXT_PATH" ]; then + cp "$EXTENSION_PROFILE_PATH" "$EXT_PATH/Contents/embedded.provisionprofile" codesign --force --options runtime \ --timestamp \ - --entitlements "${EXTENSION_NAME}/${EXTENSION_NAME}.entitlements" \ - --sign "$CERT_NAME" \ + --entitlements "$EXTENSION_ENTITLEMENTS" \ + --sign "$CERT_IDENTITY" \ "$EXT_PATH" + else + echo "FinderSync extension not found at $EXT_PATH" + exit 1 fi - # 3) 签名主 App (使用项目 entitlements) + # 3) 嵌入主 App profile 并签名 echo "Signing main app..." + cp "$APP_PROFILE_PATH" "$APP_PATH/Contents/embedded.provisionprofile" codesign --force --options runtime \ --timestamp \ - --entitlements "${APP_NAME}/${APP_NAME}.entitlements" \ - --sign "$CERT_NAME" \ + --entitlements "$MAIN_ENTITLEMENTS" \ + --sign "$CERT_IDENTITY" \ "$APP_PATH" - # 验证签名 - echo "Verifying signatures..." + # 验证签名、profile 和 App Group 身份声明 + echo "Verifying signatures and App Group authorization..." + test -f "$APP_PATH/Contents/embedded.provisionprofile" + test -f "$EXT_PATH/Contents/embedded.provisionprofile" + APP_SIGNED_ENTITLEMENTS=$(codesign -d --entitlements - "$APP_PATH" 2>&1) + EXTENSION_SIGNED_ENTITLEMENTS=$(codesign -d --entitlements - "$EXT_PATH" 2>&1) + grep -Fq "$APP_IDENTIFIER" <<< "$APP_SIGNED_ENTITLEMENTS" + grep -Fq "$EXTENSION_IDENTIFIER" <<< "$EXTENSION_SIGNED_ENTITLEMENTS" + grep -Fq "$APP_GROUP_ID" <<< "$APP_SIGNED_ENTITLEMENTS" + grep -Fq "$APP_GROUP_ID" <<< "$EXTENSION_SIGNED_ENTITLEMENTS" codesign -dv --verbose=4 "$APP_PATH" 2>&1 - codesign --verify --verbose "$APP_PATH" + codesign --verify --deep --strict --verbose=4 "$APP_PATH" # --- 4. 打包为 ZIP 用于公证 --- - name: Zip for Notarization @@ -217,7 +347,7 @@ jobs: "$DMG_NAME" \ "$DMG_STAGING/${APP_NAME}.app" - codesign --force --sign "$CERT_NAME" "$DMG_NAME" + codesign --force --sign "$CERT_IDENTITY" "$DMG_NAME" rm -rf "$DMG_STAGING" diff --git "a/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" "b/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" index 3756701..0e5a43a 100644 --- "a/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" +++ "b/specs/CI-CD\344\270\216\350\207\252\345\212\250\346\233\264\346\226\260\346\226\271\346\241\210.md" @@ -61,7 +61,7 @@ RClick 决定放弃 App Store 发布,转为纯开源项目。需要两个关 | 0. 准备 | Checkout, 选 Xcode, 从 tag 提取版本号 | `actions/checkout@v4` | | 1. 导入证书 | 创建临时 keychain, 导入 Developer ID 证书 | `security import` | | 2. 构建 | xcodebuild Release, `CODE_SIGNING_ALLOWED=NO`, `ARCHS=arm64` | `xcodebuild` | -| 3. 分层签名 | Frameworks → Extensions → Main App, `--options runtime` | `codesign` | +| 3. 授权并分层签名 | 校验并嵌入主 App/扩展的 Developer ID profile,再签 Frameworks → Extension → Main App | `security cms` + `codesign` | | 4. 打包 ZIP | `ditto -c -k` 打包用于公证 | `ditto` | | 5. 公证 | `notarytool submit` 使用 App Store Connect API Key (p8) | `xcrun notarytool` | | 6. 装订票据 | `stapler staple` + `spctl --assess` 验证 | `xcrun stapler` | @@ -76,6 +76,8 @@ RClick 决定放弃 App Store 发布,转为纯开源项目。需要两个关 |--------|------| | `MACOS_CERT_P12` | Developer ID Application 证书的 base64 | | `MACOS_CERT_PASSWORD` | 证书导出密码 | +| `MACOS_APP_PROVISIONING_PROFILE` | `cn.wflixu.RClick` 的 Developer ID provisioning profile(base64),须授权 `group.cn.wflixu.RClick` | +| `MACOS_EXTENSION_PROVISIONING_PROFILE` | `cn.wflixu.RClick.FinderSyncExt` 的 Developer ID provisioning profile(base64),须授权同一 App Group | | `APPLE_TEAM_ID` | Apple Developer Team ID | | `NOTARY_KEY_ID` | App Store Connect API Key ID | | `NOTARY_ISSUER_ID` | API Key Issuer ID | @@ -87,16 +89,19 @@ RClick 决定放弃 App Store 发布,转为纯开源项目。需要两个关 | 文件 | 对象 | 关键权限 | |------|------|---------| -| `RClick/RClick.entitlements` | 主 App (Release) | app-groups, bookmarks, user-selected.read-write, **temporary-exception.apple-events**, **temporary-exception.files.home-relative-path.read-write(/)**, app-sandbox(通过 build setting) | +| `RClick/RClick.entitlements` | 主 App (Release) | app-groups, bookmarks, user-selected.read-write, **temporary-exception.apple-events**, **temporary-exception.files.home-relative-path.read-write(/)**;为兼容 Accessibility,主 App 不启用 App Sandbox | | `RClick/RClickDebug.entitlements` | 主 App (Debug) | app-groups, bookmarks, temporary-exception.files.home-relative-path.read-write(/) | | `FinderSyncExt/FinderSyncExt.entitlements` | 扩展 | app-groups, bookmarks, app-sandbox(通过 build setting) | -**Release 签名策略**:**不创建动态 entitlements**,直接使用项目已有文件。 +`group.` 前缀的 App Group 在 macOS 上必须由每个进程自己的 provisioning profile 授权。主 App 和 FinderSync 扩展使用不同 Bundle ID,因此需要各自的 Developer ID profile;二者都必须包含 `group.cn.wflixu.RClick`。 + +**Release 签名策略**:以项目 entitlements 为基础,签名前精确校验 profile 的 Bundle ID、App Group 和签名证书,再补入 `com.apple.application-identifier` 与 `com.apple.developer.team-identifier`。所有代码签名均使用导入证书的 identity hash,避免同名证书歧义。 构建时 `CODE_SIGNING_ALLOWED=NO`,然后分层手动签名: -1. **Frameworks**(如有):`codesign --force --options runtime --timestamp --sign "$CERT_NAME"` -2. **FinderSyncExt.appex**:`codesign` + 使用 `FinderSyncExt/FinderSyncExt.entitlements` -3. **RClick.app**:`codesign` + 使用 `RClick/RClick.entitlements` + `--options runtime` +1. **Frameworks**(如有):`codesign --force --options runtime --timestamp --sign "$CERT_IDENTITY"` +2. **FinderSyncExt.appex**:嵌入扩展 profile,使用补全身份声明后的 `FinderSyncExt.entitlements` 签名 +3. **RClick.app**:嵌入主 App profile,使用补全身份声明后的 `RClick.entitlements` + `--options runtime` 签名 +4. **验证**:检查两份 profile 存在、最终签名包含各自 application identifier 和 App Group,并执行 `codesign --verify --deep --strict` **公证注意事项**:`RClick.entitlements` 包含 `temporary-exception.files.home-relative-path.read-write` 设为 `/`(整个家目录),这是一个宽泛的临时例外。RClick 作为 Finder 右键扩展需要访问用户在 Finder 中选中的任意文件,功能上合理。公证时 Apple 可能会审查此项,若不通过则需要收紧权限范围。 From 4d09af320e8133b737dc83bfa9853895bc8896e7 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 23 Aug 2026 19:19:47 +0800 Subject: [PATCH 3/5] refactor: v2.2.0 App Store sandbox + in-process Runtime layering - Add RClick/Runtime service layer (RCRuntime, MenuService, ActionService, PermissionService, ConfigService); slim down AppDelegate - Enable app sandbox for main app; update entitlements (app-sandbox, network.client; remove temporary exceptions); align Debug/Release - Add extension offline degradation (connection-state cache, actionAck timeout prompt, immediate offline on quit) - Fix launch-at-login (restore LaunchAtLogin.Toggle + status warning) - Polish bookmark authorization (clean dead bookmarks, clearer prompt, common-dirs grant on add, folder permissions list) - Isolate updater for App Store (disable self-update on MAS builds) - Remove dead code (SenderValidator, respondMenuConfigRequest); update docs - Bump version to 2.2.0 (build 20260823001) Co-Authored-By: Claude --- CLAUDE.md | 76 +- FinderSyncExt/FinderSyncExt.swift | 103 +- FinderSyncExt/Localizable.xcstrings | 5579 +++++++++-------- RClick.xcodeproj/project.pbxproj | 22 +- RClick/AppState.swift | 139 +- RClick/Localizable.xcstrings | 234 +- RClick/RClick.entitlements | 8 +- RClick/RClickApp.swift | 548 +- RClick/RClickDebug.entitlements | 10 +- RClick/Runtime/ActionService.swift | 505 ++ RClick/Runtime/ActionStateProviding.swift | 22 + RClick/Runtime/ConfigService.swift | 124 + RClick/Runtime/MenuService.swift | 44 + RClick/Runtime/PermissionService.swift | 43 + RClick/Runtime/RCRuntime.swift | 36 + .../Settings/CommonDirsSettingTabView.swift | 2 + .../Settings/FolderPermissionsSheetView.swift | 20 +- RClick/Settings/GeneralSettingsTabView.swift | 3 +- RClick/Shared/BookmarkManager.swift | 51 +- RClick/Shared/LaunchAtLogin.swift | 30 +- RClick/Shared/Updater.swift | 30 +- RClickTests/ActionServiceTests.swift | 79 + SECURITY.md | 2 +- Shared/MessageSecurity.swift | 30 - Shared/Messager.swift | 35 +- specs/design-bookmark-permission.md | 3 +- ...15\346\236\204\346\203\263\346\263\225.md" | 388 ++ ...15\346\236\204\350\256\241\345\210\222.md" | 189 + ...15\346\236\204\350\256\276\350\256\241.md" | 4 +- 29 files changed, 4872 insertions(+), 3487 deletions(-) create mode 100644 RClick/Runtime/ActionService.swift create mode 100644 RClick/Runtime/ActionStateProviding.swift create mode 100644 RClick/Runtime/ConfigService.swift create mode 100644 RClick/Runtime/MenuService.swift create mode 100644 RClick/Runtime/PermissionService.swift create mode 100644 RClick/Runtime/RCRuntime.swift create mode 100644 RClickTests/ActionServiceTests.swift create mode 100644 "specs/v2.2.0-\351\207\215\346\236\204\346\203\263\346\263\225.md" create mode 100644 "specs/v2.2.0-\351\207\215\346\236\204\350\256\241\345\210\222.md" diff --git a/CLAUDE.md b/CLAUDE.md index 07b8ec3..8e27473 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,26 +34,25 @@ RClick follows a dual-process architecture: ### 3. Communication Layer The main app and extension communicate via `DistributedNotificationCenter`: -- **Extension → App**: `RClick.MessageFromFinder` (actions, file operations) -- **App → Extension**: `RClick.MessageFromApp` (menu updates, config changes) -- Protocol defined in: [specs/001-macos-app-macos/contracts/app-extension-communication.md](specs/001-macos-app-macos/contracts/app-extension-communication.md) -- Implementation: [Messager.swift](RClick/Shared/Messager.swift) +- **Extension → App**: `RClick.ExtensionToMain` (click events, heartbeat, requestConfig) +- **App → Extension**: `RClick.MainToExtension` (menu config, running, quit, actionAck) +- Messages are HMAC-SHA256 signed (`MessageSecurity`) and JSON-encoded via `Messager` +- Implementation: [Messager.swift](Shared/Messager.swift) ### 4. State Management -- **AppState**: Centralized `ObservableObject` managing all app state - - Apps: External applications that can open files - - Dirs: Permissive directories (with security bookmarks) - - Actions: Custom context menu actions - - NewFiles: File templates for creation - - CommonDirs: Quick access folders -- Persistence: SwiftData with shared container between app and extension -- Location: [AppState.swift](RClick/AppState.swift) +- **AppState**: `@MainActor ObservableObject` holding runtime state (in-memory config arrays, fold toggles, `BookmarkManager`) +- **ConfigService**: SwiftData config load/save/reset (persistence separated from runtime state) +- **RCRuntime**: in-process dependency container (`RClick/Runtime/`) holding AppState + ConfigService + MenuService + ActionService + PermissionService + Messager. **It is NOT a separate process / Helper.** +- **BookmarkManager**: security-scoped bookmarks — the only folder-authorization mechanism (mandatory for the sandboxed App Store build) +- Data categories: Apps (external apps), Actions (context menu actions), NewFiles (file templates), CommonDirs (quick access folders), BookmarkEntity (authorized folder credentials) +- Persistence: SwiftData with shared App Group container between app and extension +- Location: [AppState.swift](RClick/AppState.swift) / [RClick/Runtime/](RClick/Runtime/) ### 5. Data Models -All models are in [RClick/Model/](RClick/Model/): -- `Models.swift`: SwiftData `@Model` definitions (PermDir, OpenWithApp, RCAction, NewFile, CommonDir) -- `RCBase.swift`: Base protocol for common functionality -- `ModelContainer.swift`: Shared SwiftData container configuration +SwiftData `@Model` entities are in [RClick/Model/](RClick/Model/), one file per entity: +- `AppEntity.swift`, `ActionEntity.swift`, `NewFileTypeEntity.swift`, `CommonDirEntity.swift`, `BookmarkEntity.swift`, `DataVersion.swift` +- In-memory models & IPC DTOs are in [Shared/RCBase.swift](Shared/RCBase.swift) (`OpenWithApp`, `RCAction`, `NewFile`, `CommonDir`, `AppMenuItem`, `ActionMenuItem`, …) +- `ModelContainer.swift`: Shared App Group SwiftData container configuration ## Build and Development Commands @@ -88,31 +87,30 @@ swiftlint ### Adding New Context Menu Actions -1. Define action model in [Models.swift](RClick/Model/Models.swift) +1. Define action model in [Shared/RCBase.swift](Shared/RCBase.swift) 2. Add to `RCAction.all` static property -3. Handle action in [RClickApp.swift](RClick/RClickApp.swift) in `actionHandler()` method -4. Extension receives action via menu callback and sends message to main app +3. Handle action in [ActionService.swift](RClick/Runtime/ActionService.swift) in `actionHandler()` method +4. Extension receives action via menu callback and sends `.click` message to main app (via `Messager`) ### Adding New File Templates -1. Add to `NewFile` model in [Models.swift](RClick/Model/Models.swift) +1. Add to `NewFile` model in [Shared/RCBase.swift](Shared/RCBase.swift) 2. Add template file to [Assets.xcassets](RClick/Assets.xcassets/) -3. Handle creation in [RClickApp.swift](RClick/RClickApp.swift) in `createFile()` method +3. Handle creation in [ActionService.swift](RClick/Runtime/ActionService.swift) in `createFile()` method ### Inter-Process Communication When adding new message types: -1. Define `MessagePayload` structure in [Messager.swift](RClick/Shared/Messager.swift) -2. Register message handler in appropriate init method -3. Update contract documentation in [specs/001-macos-app-macos/contracts/app-extension-communication.md](specs/001-macos-app-macos/contracts/app-extension-communication.md) +1. Define the message enum case + payload in [Messager.swift](Shared/Messager.swift) +2. Register message handler in the appropriate `init` / `applicationDidFinishLaunching` +3. If the extension needs to detect whether the main app received a message, add a matching ack message ### Security-Scoped Resource Access -When working with files outside app sandbox: -1. User must grant permission via `NSOpenPanel` -2. Store bookmark data using `URL.bookmarkData(options: ...)` -3. Access files with `startAccessingSecurityScopedResource()` / `stopAccessingSecurityScopedResource()` -4. See `deleteFoldorFile()` and `createFile()` in [RClickApp.swift](RClick/RClickApp.swift) for examples +When working with files outside the sandbox container (required for the App Store build): +1. Check `PermissionService.hasAccess(to:)` (bookmark prefix match, covers subtree) +2. If not authorized, `PermissionService.promptForPermission(for:)` shows `NSOpenPanel` (powerbox) → user grants once → bookmark cached +3. See [BookmarkManager.swift](RClick/Shared/BookmarkManager.swift) (implementation) and [ActionService.swift](RClick/Runtime/ActionService.swift) (usage in delete/create/hide/open) ## Important Constraints @@ -123,9 +121,10 @@ When working with files outside app sandbox: - **Target macOS 15 Sequoia and above only** ### Extension Development -- Extension runs in separate process with limited memory -- Must handle `isHostAppOpen` state - don't block if main app not available -- Use heartbeat mechanism to verify main app is running +- Extension runs in separate process with limited memory (keep it a thin renderer + event forwarder) +- **Connection state is a UX cache, not a reliability mechanism**: the extension tracks "last message from main app" (`lastMainActivity`, 30s timeout) to show a disabled "RClick is not running" menu — it does NOT determine operation success +- **Real liveness check is at click time**: extension sends `.click`; main app replies `.actionAck`; if no ack in 3s, extension shows a "RClick did not respond" alert +- On `.quit` the extension immediately marks itself offline (no 30s "zombie" window) - See [FinderSyncExt.swift](FinderSyncExt/FinderSyncExt.swift) ### Logging @@ -154,15 +153,16 @@ When working with files outside app sandbox: RClick/ ├── RClick/ # Main application target │ ├── RClickApp.swift # App entry point & AppDelegate -│ ├── AppState.swift # Global state management -│ ├── Model/ # SwiftData models +│ ├── AppState.swift # Runtime state +│ ├── Runtime/ # In-process service layer (MenuService/ActionService/PermissionService/ConfigService/RCRuntime) +│ ├── Model/ # SwiftData entities │ ├── Settings/ # Settings views (UI) -│ ├── Shared/ # Utilities & services +│ ├── Shared/ # Utilities (BookmarkManager, LaunchAtLogin, Updater, …) │ ├── Assets.xcassets/ # Images, templates, icons │ └── Resources/ # Localization files -├── FinderSyncExt/ # Finder extension target -│ ├── FinderSyncExt.swift # Extension main file -│ └── MenuItemClickable.swift # Menu item handlers +├── FinderSyncExt/ # Finder extension target (thin: render + forward) +│ └── FinderSyncExt.swift # Extension main file +├── Shared/ # Code shared by app & extension (Messager, RCBase, AppLocalization, …) ├── specs/ # Feature specifications & contracts └── RClick.xcodeproj # Xcode project ``` diff --git a/FinderSyncExt/FinderSyncExt.swift b/FinderSyncExt/FinderSyncExt.swift index bf84987..ba3d482 100644 --- a/FinderSyncExt/FinderSyncExt.swift +++ b/FinderSyncExt/FinderSyncExt.swift @@ -49,6 +49,19 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { /// 消息管理器 private let messager = Messager.shared + // MARK: - 连接状态(UX 优化,非可靠性机制) + + /// 最近一次收到主程序消息的时间 + private var lastMainActivity = Date() + + /// 是否认为主程序可通信(连续 3 次心跳未收到即判离线) + private var isHostAppRunning: Bool { + Date().timeIntervalSince(lastMainActivity) < 30 + } + + /// 待确认的点击事件 ack 计时器 + private var pendingAckWorkItem: DispatchWorkItem? + // MARK: - Initialization override init() { @@ -93,6 +106,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { // 处理主程序发送的菜单配置 messager.onMainMessage(.menuConfig) { [weak self] data in guard let self = self else { return } + self.markMainActivity() // 使用 decodeSignedData 解码签名数据 if let config = self.messager.decodeSignedData(data, as: MenuConfigPayload.self) { self.handleMenuConfig(config) @@ -104,17 +118,85 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { // 处理主程序发送的 running 通知 messager.onMainMessage(.running) { [weak self] data in guard let self = self else { return } + self.markMainActivity() if let payload = self.messager.decodeSignedData(data, as: RunningPayload.self) { logger.info("Received running notification: \(payload.directories)") // 可以根据 payload 更新监听目录 } } - // 处理主程序发送的退出通知 - messager.onMainMessage(.quit) { _ in + // 处理主程序发送的退出通知:立即置离线,消除"退出后 30s 假活"窗口 + messager.onMainMessage(.quit) { [weak self] _ in logger.info("Received quit notification from main app") - // 可以标记主程序已退出 + self?.markMainOffline() + } + + // 点击事件确认:主程序已收到 click,取消 ack 超时提示 + messager.onMainMessage(.actionAck) { [weak self] _ in + DispatchQueue.main.async { + self?.handleAck() + } + } + } + + // MARK: - 连接状态辅助 + + /// 记录主程序最近活动(统一到主线程,避免 DNC 回调线程写 / menu 主线程读竞争) + private func markMainActivity() { + DispatchQueue.main.async { [weak self] in + self?.lastMainActivity = Date() + } + } + + /// 标记主程序离线(收到 .quit 时立即生效) + private func markMainOffline() { + DispatchQueue.main.async { [weak self] in + self?.lastMainActivity = .distantPast + } + } + + /// 发送点击事件:离线先提示;在线则等待 ack,超时无响应再提示 + private func sendClickEvent(_ event: ClickEventPayload) { + guard isHostAppRunning else { + Task { @MainActor in + self.showAlert( + title: AppLocalization.localized("RClick is not running"), + message: AppLocalization.localized("This action requires RClick to be running. Please launch RClick and try again.") + ) + } + return } + messager.sendClickEvent(event) + awaitAck() + } + + /// 启动 ack 等待:主程序收到 click 后回 actionAck,超时未收到则提示操作可能未执行 + private func awaitAck() { + pendingAckWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + Task { @MainActor in + self?.showAlert( + title: AppLocalization.localized("RClick did not respond"), + message: AppLocalization.localized("The operation may not have been executed. Please check that RClick is running.") + ) + } + } + pendingAckWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work) + } + + /// 收到 ack,取消超时提示 + private func handleAck() { + pendingAckWorkItem?.cancel() + pendingAckWorkItem = nil + } + + @MainActor private func showAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() } /// 处理菜单配置 @@ -246,6 +328,13 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { let menu = NSMenu(title: "RClick") + // 连接状态缓存:主程序离线时,显示置灰提示而非可点击菜单(避免"点了没反应") + guard isHostAppRunning else { + menu.addItem(withTitle: AppLocalization.localized("RClick is not running"), action: nil, keyEquivalent: "") + menu.addItem(withTitle: AppLocalization.localized("Restart RClick to restore the menu"), action: nil, keyEquivalent: "") + return menu + } + // 如果缓存为空,触发请求并返回加载中的菜单 guard let config = cachedMenuConfig else { requestMenuConfig() @@ -419,7 +508,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { target: itemPaths, trigger: getTriggerForMenuKind() ) - messager.sendClickEvent(event) + sendClickEvent(event) } @objc private func handleAppClick(_ sender: NSMenuItem) { @@ -444,7 +533,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { trigger: getTriggerForMenuKind() ) logger.debug("Sending click event for app: \(app.name)") - messager.sendClickEvent(event) + sendClickEvent(event) } @objc private func handleNewFileClick(_ sender: NSMenuItem) { @@ -462,7 +551,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { target: newFileTargetPaths(), trigger: getTriggerForMenuKind() ) - messager.sendClickEvent(event) + sendClickEvent(event) } @objc private func handleCommonDirClick(_ sender: NSMenuItem) { @@ -483,7 +572,7 @@ class FinderSyncExt: FIFinderSync, @unchecked Sendable { target: target, trigger: getTriggerForMenuKind() ) - messager.sendClickEvent(event) + sendClickEvent(event) } // MARK: - Helper Methods diff --git a/FinderSyncExt/Localizable.xcstrings b/FinderSyncExt/Localizable.xcstrings index a9b5797..54cecae 100644 --- a/FinderSyncExt/Localizable.xcstrings +++ b/FinderSyncExt/Localizable.xcstrings @@ -1,4147 +1,4292 @@ { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "1. Haz clic en “Ajustes…” a la derecha para abrir Ajustes del Sistema" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "1. 右側の「設定…」をクリックしてシステム設定を開きます" + "ja": { + "stringUnit": { + "state": "translated", + "value": "1. 右側の「設定…」をクリックしてシステム設定を開きます" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "2. Haz clic en el icono del candado y autentícate" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "2. 左下の鍵アイコンをクリックして認証します" + "ja": { + "stringUnit": { + "state": "translated", + "value": "2. 左下の鍵アイコンをクリックして認証します" } }, - "zh-Hans" : { - "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\"" + "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”" + "es": { + "stringUnit": { + "state": "translated", + "value": "3. Haz clic en “+” -> abre “Aplicaciones” -> selecciona “RClick”" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "3. 「+」を押して「アプリケーション」から「RClick」を選択します" + "ja": { + "stringUnit": { + "state": "translated", + "value": "3. 「+」を押して「アプリケーション」から「RClick」を選択します" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "4. Asegúrate de que el interruptor junto a RClick esté activado" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "4. RClick の横にあるスイッチがオンになっていることを確認します" + "ja": { + "stringUnit": { + "state": "translated", + "value": "4. RClick の横にあるスイッチがオンになっていることを確認します" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "4. 确保 RClick 旁边的开关已打开" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "4. 确保 RClick 旁边的开关已打开" } } } }, - "About" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "About" + "About": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Acerca de" + "es": { + "stringUnit": { + "state": "translated", + "value": "Acerca de" } }, - "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" + "Accessibility": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accessibility" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accesibilidad" + "es": { + "stringUnit": { + "state": "translated", + "value": "Accesibilidad" } }, - "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" + "Actions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actions" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Acciones" + "es": { + "stringUnit": { + "state": "translated", + "value": "Acciones" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "操作" + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "操作" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作" } } } }, - "Add" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add" + "Add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Agregar" + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar" } }, - "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" + "Add App": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add App" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Agregar aplicación" + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar aplicación" } }, - "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" + "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": "Agregar tipo de archivo" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ファイル種類を追加" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル種類を追加" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "添加文件类型" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加文件类型" } } } }, - "Add Folder" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add Folder" + "Add Folder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Folder" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Agregar carpeta" + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar carpeta" } }, - "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" + "Added Folders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Added Folders" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carpetas agregadas" + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas agregadas" } }, - "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" + "AirDrop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "AirDrop" + "es": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "AirDrop" + "ja": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "AirDrop" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AirDrop" } } } }, - "Applications" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Applications" + "Applications": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applications" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aplicaciones" + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicaciones" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アプリケーション" + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリケーション" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "应用程序" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用程序" } } } }, - "Apps" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apps" + "Apps": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apps" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Apps" + "es": { + "stringUnit": { + "state": "translated", + "value": "Apps" } }, - "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)" + "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": "Argumentos (separados por punto y coma)" } }, - "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: %@" + "Arguments: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Arguments: %@" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Argumentos: %@" + "es": { + "stringUnit": { + "state": "translated", + "value": "Argumentos: %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "引数: %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "引数: %@" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "参数:%@" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "参数:%@" } } } }, - "Authorized" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Authorized" + "Authorized": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authorized" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Autorizado" + "es": { + "stringUnit": { + "state": "translated", + "value": "Autorizado" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "許可済み" + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可済み" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "已授权" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已授权" } } } }, - "Automatic" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatic" + "Automatic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automático" + "es": { + "stringUnit": { + "state": "translated", + "value": "Automático" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "自動" + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "自动" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" } } } }, - "Backup" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Backup" + "Backup": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Backup" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Respaldo" + "es": { + "stringUnit": { + "state": "translated", + "value": "Respaldo" } }, - "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" + "Cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cancelar" + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "キャンセル" + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "取消" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" } } } }, - "Change App" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change App" + "Change App": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change App" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambiar aplicación" + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar aplicación" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アプリを変更" + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリを変更" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "更换应用" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更换应用" } } } }, - "Change Template" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change Template" + "Change Template": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Change Template" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambiar plantilla" + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar plantilla" } }, - "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" + "Check for Updates": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Buscar actualizaciones" + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones" } }, - "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..." + "Checking for updates...": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking for updates..." } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Buscando actualizaciones..." + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscando actualizaciones..." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アップデートを確認中..." + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認中..." } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在检查更新..." + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检查更新..." } } } }, - "Choose Default App" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Choose Default App" + "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": "Elegir aplicación predeterminada" } }, - "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" + "Choose Template File": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose Template File" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Elegir archivo de plantilla" + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir archivo de plantilla" } }, - "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" + "Collapse actions menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse actions menu" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Contraer el menú de acciones" + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú de acciones" } }, - "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" + "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": "Contraer el menú de aplicaciones" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アプリメニューを折りたたむ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリメニューを折りたたむ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "折叠应用菜单" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "折叠应用菜单" } } } }, - "Collapse menu" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Collapse menu" + "Collapse menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse menu" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Contraer el menú" + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú" } }, - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer el menú de nuevo archivo" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "新規ファイルメニューを折りたたむ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ファイルメニューを折りたたむ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "折叠新建文件菜单" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "折叠新建文件菜单" } } } }, - "Common Dir" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Common Folders" + "Common Dir": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Common Folders" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carpetas frecuentes" + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas frecuentes" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "よく使うフォルダ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "よく使うフォルダ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "常用文件夹" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "常用文件夹" } } } }, - "Common Dirs" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Common Folders" + "Common Dirs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Common Folders" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carpetas frecuentes" + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas frecuentes" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "よく使うフォルダ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "よく使うフォルダ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "常用文件夹" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "常用文件夹" } } } }, - "Copy Path" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copy Path" + "Copy Path": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Path" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Copiar ruta" + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar ruta" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "パスをコピー" + "ja": { + "stringUnit": { + "state": "translated", + "value": "パスをコピー" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "复制路径" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制路径" } } } }, - "Default Open App" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Default Open App" + "Default Open App": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Open App" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aplicación predeterminada" + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicación predeterminada" } }, - "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 App": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete App" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eliminar aplicación" + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar aplicación" } }, - "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" + "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 directamente" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "直接削除" + "ja": { + "stringUnit": { + "state": "translated", + "value": "直接削除" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "直接删除" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直接删除" } } } }, - "Desktop" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Desktop" + "Desktop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Escritorio" + "es": { + "stringUnit": { + "state": "translated", + "value": "Escritorio" } }, - "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" + "Display Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display Name" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nombre visible" + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre visible" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "表示名" + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示名" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "显示名称" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示名称" } } } }, - "Documents" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Documents" + "Documents": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Documents" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Documentos" + "es": { + "stringUnit": { + "state": "translated", + "value": "Documentos" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "書類" + "ja": { + "stringUnit": { + "state": "translated", + "value": "書類" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "No preguntar de nuevo" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "今後表示しない" + "ja": { + "stringUnit": { + "state": "translated", + "value": "今後表示しない" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "不再询问" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不再询问" } } } }, - "Download and Install" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Download and Install" + "Download and Install": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download and Install" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Descargar e instalar" + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar e instalar" } }, - "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" + "Download Manually": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download Manually" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Descargar manualmente" + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar manualmente" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "手動でダウンロード" + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動でダウンロード" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "手动下载" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手动下载" } } } }, - "Downloads" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Downloads" + "Downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Descargas" + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargas" } }, - "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" + "Edit App": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit App" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar aplicación" + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar aplicación" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アプリを編集" + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリを編集" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "编辑应用" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑应用" } } } }, - "Edit App Properties" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edit App Properties" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar propiedades de la aplicación" } }, - "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" + "Edit File Type": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit File Type" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Editar tipo de archivo" + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar tipo de archivo" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ファイル種類を編集" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル種類を編集" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "编辑文件类型" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑文件类型" } } } }, - "Enable common folders" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enable common folders" + "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": "Activar carpetas frecuentes" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "よく使うフォルダを有効化" + "ja": { + "stringUnit": { + "state": "translated", + "value": "よく使うフォルダを有効化" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar Acceso total al disco" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "フルディスクアクセスを有効にする" + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルディスクアクセスを有効にする" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "启用完全磁盘访问权限" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用完全磁盘访问权限" } } } }, - "Enable RClick" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enable RClick" + "Enable RClick": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable RClick" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activar RClick" + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar RClick" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick を有効にする" + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick を有効にする" } }, - "zh-Hans" : { - "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" + "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" + "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 の右クリックメニューに表示されます" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルプロバイダで RClick を有効にすると Finder の右クリックメニューに表示されます" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在文件提供程序中启用 RClick 以在 Finder 右键菜单中显示功能" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在文件提供程序中启用 RClick 以在 Finder 右键菜单中显示功能" } } } }, - "Enabled" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enabled" + "Enabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enabled" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activado" + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "有効" + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "启用" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用" } } } }, - "English" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "English" + "English": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "English" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Inglés" + "es": { + "stringUnit": { + "state": "translated", + "value": "Inglés" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "English" + "ja": { + "stringUnit": { + "state": "translated", + "value": "English" } }, - "zh-Hans" : { - "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" + "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": "Escribe el nombre de un SF Symbol, por ejemplo doc.text o curlybraces" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "SF Symbol 名を入力してください。例: doc.text、curlybraces" + "ja": { + "stringUnit": { + "state": "translated", + "value": "SF Symbol 名を入力してください。例: doc.text、curlybraces" } }, - "zh-Hans" : { - "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" + "Environment Variables": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Environment Variables" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Variables de entorno" + "es": { + "stringUnit": { + "state": "translated", + "value": "Variables de entorno" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "環境変数" + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "环境变量" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "环境变量" } } } }, - "Environment variables: %lld" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Environment variables: %lld" + "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": "Variables de entorno: %lld" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "環境変数: %lld 個" + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数: %lld 個" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "环境变量:%lld 个" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "环境变量:%lld 个" } } } }, - "Export Logs…" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Export Logs…" + "Export Logs…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export Logs…" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exportar registros…" + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar registros…" } }, - "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…" + "Export…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export…" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exportar…" + "es": { + "stringUnit": { + "state": "translated", + "value": "Exportar…" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "書き出し…" + "ja": { + "stringUnit": { + "state": "translated", + "value": "書き出し…" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "导出…" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "导出…" } } } }, - "Extension: %@" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Extension: %@" + "Extension: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Extension: %@" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Extensión: %@" + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensión: %@" } }, - "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: %@" + "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": "Error al descomprimir: %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "展開に失敗しました: %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "展開に失敗しました: %@" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron buscar actualizaciones" } }, - "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" + "File Extension": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File Extension" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Extensión de archivo" + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensión de archivo" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ファイル拡張子" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル拡張子" } }, - "zh-Hans" : { - "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" + "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": "Proveedor de archivos: selecciona “RClick” en la lista para activar el menú contextual del Finder" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ファイルプロバイダ一覧で「RClick」を選択すると Finder の右クリックメニューを有効にできます" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルプロバイダ一覧で「RClick」を選択すると Finder の右クリックメニューを有効にできます" } }, - "zh-Hans" : { - "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" + "Finder Extension": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finder Extension" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Extensión del Finder" + "es": { + "stringUnit": { + "state": "translated", + "value": "Extensión del Finder" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "Finder 拡張" + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finder 拡張" } }, - "zh-Hans" : { - "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." + "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": "Se necesita permiso de acceso a la carpeta para usar esta función." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "この機能を使うにはフォルダへのアクセス許可が必要です。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "この機能を使うにはフォルダへのアクセス許可が必要です。" } }, - "zh-Hans" : { - "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: %@" + "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": "Las carpetas no se pueden compartir por AirDrop: %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "フォルダは AirDrop で共有できません: %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダは AirDrop で共有できません: %@" } }, - "zh-Hans" : { - "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" + "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": "Por ejemplo: txt, md, json" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "例: txt、md、json" + "ja": { + "stringUnit": { + "state": "translated", + "value": "例: txt、md、json" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "Formato: CLAVE=VALOR, uno por línea" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "形式: KEY=VALUE を1行ずつ入力" + "ja": { + "stringUnit": { + "state": "translated", + "value": "形式: KEY=VALUE を1行ずつ入力" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "格式:KEY=VALUE,每行一个" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "格式:KEY=VALUE,每行一个" } } } }, - "Full Disk Access" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Full Disk Access" + "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": "Acceso total al disco" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "フルディスクアクセス" + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルディスクアクセス" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso total al disco: se necesita para crear y eliminar archivos en directorios protegidos" } }, - "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" + "General": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "General" + "es": { + "stringUnit": { + "state": "translated", + "value": "General" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "一般" + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "通用" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通用" } } } }, - "Grant Permission" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Grant Permission" + "Grant Permission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant Permission" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Otorgar permiso" + "es": { + "stringUnit": { + "state": "translated", + "value": "Otorgar permiso" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "権限を付与" + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限を付与" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "授予权限" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授予权限" } } } }, - "Hide" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hide" + "Hide": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ocultar" + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar" } }, - "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" + "Home": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Home" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Carpeta de inicio" + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpeta de inicio" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ホーム" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホーム" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用户主目录" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户主目录" } } } }, - "Icon" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Icon" + "Icon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Icon" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Icono" + "es": { + "stringUnit": { + "state": "translated", + "value": "Icono" } }, - "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" + "Ignore This Version": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ignore This Version" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ignorar esta versión" + "es": { + "stringUnit": { + "state": "translated", + "value": "Ignorar esta versión" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "このバージョンを無視" + "ja": { + "stringUnit": { + "state": "translated", + "value": "このバージョンを無視" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "忽略此版本" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "忽略此版本" } } } }, - "Import…" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Import…" + "Import…": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Import…" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Importar…" + "es": { + "stringUnit": { + "state": "translated", + "value": "Importar…" } }, - "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: %@" + "Installation failed: %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Installation failed: %@" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Error al instalar: %@" + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al instalar: %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "インストールに失敗しました: %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストールに失敗しました: %@" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "安装失败:%@" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装失败:%@" } } } }, - "Invalid Folder" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Invalid Folder" + "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": "Carpeta no válida" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "無効なフォルダ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なフォルダ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无效文件夹" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效文件夹" } } } }, - "Japanese" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Japanese" + "Japanese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Japanese" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Japonés" + "es": { + "stringUnit": { + "state": "translated", + "value": "Japonés" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "日本語" + "ja": { + "stringUnit": { + "state": "translated", + "value": "日本語" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "日本語" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日本語" } } } }, - "Language" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Language" + "Language": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Idioma" + "es": { + "stringUnit": { + "state": "translated", + "value": "Idioma" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "言語" + "ja": { + "stringUnit": { + "state": "translated", + "value": "言語" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "语言" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "语言" } } } }, - "Later" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Later" + "Later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Later" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Más tarde" + "es": { + "stringUnit": { + "state": "translated", + "value": "Más tarde" } }, - "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" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Launch at login" + "Launch at login": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch at login" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abrir al iniciar sesión" + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir al iniciar sesión" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ログイン時に起動" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログイン時に起動" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "登录时启动" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录时启动" } } } }, - "Logs" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Logs" + "Logs": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Logs" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Registros" + "es": { + "stringUnit": { + "state": "translated", + "value": "Registros" } }, - "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" + "Main Controls": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Main Controls" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Controles principales" + "es": { + "stringUnit": { + "state": "translated", + "value": "Controles principales" } }, - "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" + "Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Name" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nombre" + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "名称" + "ja": { + "stringUnit": { + "state": "translated", + "value": "名称" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "名称" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" } } } }, - "New File" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New File" + "New File": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New File" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nuevo archivo" + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo archivo" } }, - "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" + "New Version Available": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Version Available" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nueva versión disponible" + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva versión disponible" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "新しいバージョンがあります" + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいバージョンがあります" } }, - "zh-Hans" : { - "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." + "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": "No se encontró ninguna aplicación .app en el archivo ZIP." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ZIP ファイル内に .app アプリケーションが見つかりません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ZIP ファイル内に .app アプリケーションが見つかりません。" } }, - "zh-Hans" : { - "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." + "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." + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ningún paquete de aplicación .app.zip." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ".app.zip 形式のアプリケーションパッケージが見つかりません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": ".app.zip 形式のアプリケーションパッケージが見つかりません。" } }, - "zh-Hans" : { - "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." + "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." + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay actualizaciones disponibles." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "利用可能なアップデートはありません。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能なアップデートはありません。" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "没有可用的更新。" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有可用的更新。" } } } }, - "Not Authorized" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Not Authorized" + "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": "Sin autorización" } }, - "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" + "Notice": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notice" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aviso" + "es": { + "stringUnit": { + "state": "translated", + "value": "Aviso" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "お知らせ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "お知らせ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "提示" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提示" } } } }, - "OK" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "OK": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OK" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "es": { + "stringUnit": { + "state": "translated", + "value": "OK" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "OK" + "ja": { + "stringUnit": { + "state": "translated", + "value": "OK" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "确定" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确定" } } } }, - "Open Settings" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open Settings" + "Open Settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abrir Ajustes" + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定を開く" + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を開く" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开设置" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开设置" } } } }, - "Open With" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open With" + "Open With": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open With" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abrir con" + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir con" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "用...打开" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用...打开" } } } }, - "Permissions" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permissions" + "Permissions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Permisos" + "es": { + "stringUnit": { + "state": "translated", + "value": "Permisos" } }, - "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." + "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." + "es": { + "stringUnit": { + "state": "translated", + "value": "Selecciona la carpeta Aplicaciones correcta." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "正しいアプリケーションフォルダを選択してください。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "正しいアプリケーションフォルダを選択してください。" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "请选择正确的“应用程序”文件夹。" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请选择正确的“应用程序”文件夹。" } } } }, - "Preview:" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preview:" + "Preview:": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview:" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vista previa:" + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista previa:" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "プレビュー:" + "ja": { + "stringUnit": { + "state": "translated", + "value": "プレビュー:" } }, - "zh-Hans" : { - "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: %@" + "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: %@" + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pueden eliminar las carpetas protegidas del sistema: %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "保護されたシステムフォルダは削除できません: %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "保護されたシステムフォルダは削除できません: %@" } }, - "zh-Hans" : { - "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: %@" + "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": "No se pueden compartir las carpetas protegidas del sistema: %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "保護されたシステムフォルダは共有できません: %@" + "ja": { + "stringUnit": { + "state": "translated", + "value": "保護されたシステムフォルダは共有できません: %@" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无法分享系统保护文件夹:%@" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法分享系统保护文件夹:%@" } } } }, - "Quit" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quit" + "Quit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Quit" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Salir" + "es": { + "stringUnit": { + "state": "translated", + "value": "Salir" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "終了" + "ja": { + "stringUnit": { + "state": "translated", + "value": "終了" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "退出" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "退出" } } } }, - "RClick (loading...)" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick (loading...)" + "RClick (loading...)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "RClick (loading...)" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick (cargando...)" + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick (cargando...)" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick(読み込み中...)" + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick(読み込み中...)" } }, - "zh-Hans" : { - "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." + "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." + "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 はフォルダを開くアプリを追加できる右クリックメニュー拡張で、いくつかの便利な操作も含まれています。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick はフォルダを開くアプリを追加できる右クリックメニュー拡張で、いくつかの便利な操作も含まれています。" } }, - "zh-Hans" : { - "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" + "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" + "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 の横にあるスイッチをオンにします" + "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 旁边的开关" + "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick necesita Acceso total al disco para trabajar con archivos en directorios protegidos" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick が保護されたディレクトリ内のファイルを操作するにはフルディスクアクセスが必要です" + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick が保護されたディレクトリ内のファイルを操作するにはフルディスクアクセスが必要です" } }, - "zh-Hans" : { - "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." + "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": "RClick necesita permiso para instalar la actualización en tu carpeta Aplicaciones." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick がアップデートをアプリケーションフォルダにインストールするには権限が必要です。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick がアップデートをアプリケーションフォルダにインストールするには権限が必要です。" } }, - "zh-Hans" : { - "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" + "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ú" + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick: haz clic para ver las opciones del menú" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick: クリックしてメニューオプションを表示" + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick: クリックしてメニューオプションを表示" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "RClick:点按以显示菜单选项" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RClick:点按以显示菜单选项" } } } }, - "Reset" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reset" + "Reset": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Restablecer" + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" } }, - "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" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reset All Settings?" + "Reset All Settings?": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset All Settings?" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Restablecer todos los ajustes?" + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Restablecer todos los ajustes?" } }, - "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…" + "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": "Restablecer todos los ajustes…" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "すべての設定をリセット…" + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての設定をリセット…" } }, - "zh-Hans" : { - "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" + "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" + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer todos los ajustes restaura la configuración predeterminada y no se puede deshacer" } }, - "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" + "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": "Reiniciar más tarde" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "あとで再起動" + "ja": { + "stringUnit": { + "state": "translated", + "value": "あとで再起動" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "稍后重启" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "稍后重启" } } } }, - "Restart Now" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Restart Now" + "Restart Now": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart Now" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reiniciar ahora" + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar ahora" } }, - "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" + "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": "Restaurar valores predeterminados" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "デフォルトに戻す" + "ja": { + "stringUnit": { + "state": "translated", + "value": "デフォルトに戻す" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "恢复默认" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复默认" } } } }, - "Run Arguments" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Run Arguments" + "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": "Argumentos de ejecución" } }, - "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" + "Save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Guardar" + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "保存" + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" } }, - "zh-Hans" : { - "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 (;)" + "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": "Separa varios argumentos con punto y coma (;)" } }, - "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": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajustes" + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定" + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置" } } } }, - "Settings Management" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Settings Management" + "Settings Management": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings Management" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Administración de ajustes" + "es": { + "stringUnit": { + "state": "translated", + "value": "Administración de ajustes" } }, - "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…" + "Settings…": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajustes…" + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes…" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "設定…" + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定…" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "设置…" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置…" } } } }, - "SF Symbol Name" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SF Symbol Name" + "SF Symbol Name": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SF Symbol Name" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nombre del SF Symbol" + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre del SF Symbol" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "SF Symbol 名" + "ja": { + "stringUnit": { + "state": "translated", + "value": "SF Symbol 名" } }, - "zh-Hans" : { - "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" + "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": "Mostrar el icono en la barra de menús" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "メニューバーにアイコンを表示" + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバーにアイコンを表示" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "在菜单栏显示图标" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在菜单栏显示图标" } } } }, - "Simplified Chinese" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Simplified Chinese" + "Simplified Chinese": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simplified Chinese" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chino simplificado" + "es": { + "stringUnit": { + "state": "translated", + "value": "Chino simplificado" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "简体中文" + "ja": { + "stringUnit": { + "state": "translated", + "value": "简体中文" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "简体中文" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "简体中文" } } } }, - "Template" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Template" + "Template": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Template" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plantilla" + "es": { + "stringUnit": { + "state": "translated", + "value": "Plantilla" } }, - "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 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": "El paquete de la aplicación no es válido o está dañado." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "アプリケーションバンドルが無効または破損しています。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリケーションバンドルが無効または破損しています。" } }, - "zh-Hans" : { - "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 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" : "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." + "es": { + "stringUnit": { + "state": "translated", + "value": "La aplicación se actualizó correctamente. Reinicia la app para finalizar la actualización." } }, - "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 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": "No se puede eliminar la carpeta actual. En su lugar, selecciona archivos o subcarpetas." } }, - "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 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 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 compartir 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" : "現在のフォルダは共有できません。代わりにファイルまたはサブフォルダを選択してください。" + "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 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": "La versión actual ya está actualizada." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "現在のバージョンはすでに最新です。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のバージョンはすでに最新です。" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "当前版本已是最新,无需更新。" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前版本已是最新,无需更新。" } } } }, - "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 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" : "La carpeta seleccionada es una subcarpeta de otra que ya elegiste. Elige una carpeta diferente." + "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" : "選択したフォルダは既に選択済みのフォルダのサブフォルダです。別のフォルダを選択してください。" + "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 user cancelled authorization.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The user cancelled authorization." } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "El usuario canceló la autorización." + "es": { + "stringUnit": { + "state": "translated", + "value": "El usuario canceló la autorización." } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ユーザーが認証をキャンセルしました。" + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザーが認証をキャンセルしました。" } }, - "zh-Hans" : { - "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." + "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" : "Se eliminarán todas las configuraciones personalizadas y se restaurarán los valores predeterminados. Esta acción no se puede deshacer." + "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" : "この操作によりすべてのカスタム設定が削除され、既定値に戻ります。元に戻すことはできません。" + "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" + "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": "Carpeta sin autorización" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "未承認のフォルダ" + "ja": { + "stringUnit": { + "state": "translated", + "value": "未承認のフォルダ" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未授权文件夹" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未授权文件夹" } } } }, - "Unhide" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unhide" + "Unhide": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unhide" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mostrar" + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "再表示" + "ja": { + "stringUnit": { + "state": "translated", + "value": "再表示" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "显示" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示" } } } }, - "Unknown" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unknown" + "Unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Desconocido" + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" } }, - "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" + "Untitled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sin título" + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin título" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "名称未設定" + "ja": { + "stringUnit": { + "state": "translated", + "value": "名称未設定" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未命名" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未命名" } } } }, - "Up to Date" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Up to Date" + "Up to Date": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to Date" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Todo actualizado" + "es": { + "stringUnit": { + "state": "translated", + "value": "Todo actualizado" } }, - "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" + "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": "Actualización instalada" } }, - "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 %@" + "Version %@": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version %@" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versión %@" + "es": { + "stringUnit": { + "state": "translated", + "value": "Versión %@" } }, - "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 %@ (%@)" + "Version %@ (%@)": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version %@ (%@)" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Versión %@ (%@)" + "es": { + "stringUnit": { + "state": "translated", + "value": "Versión %@ (%@)" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バージョン %@ (%@)" + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョン %@ (%@)" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "版本 %@ (%@)" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "版本 %@ (%@)" } } } }, - "Version %@ is ignored" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Version %@ is ignored" + "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": "Se ignoró la versión %@" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "バージョン %@ は無視されています" + "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" + "Warning": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning" } }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Advertencia" + "es": { + "stringUnit": { + "state": "translated", + "value": "Advertencia" } }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "警告" + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告" } }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "警告" + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "警告" + } + } + } + }, + "RClick is not running": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "RClick is not running" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick no está en ejecución" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick が実行されていません" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RClick 未运行" + } + } + } + }, + "Restart RClick to restore the menu": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart RClick to restore the menu" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reinicia RClick para restaurar el menú" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick を再起動するとメニューが復元されます" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新启动 RClick 后菜单将恢复" + } + } + } + }, + "This action requires RClick to be running. Please launch RClick and try again.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This action requires RClick to be running. Please launch RClick and try again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta acción requiere que RClick esté en ejecución. Inicia RClick e inténtalo de nuevo." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この操作には RClick の実行が必要です。RClick を起動してもう一度お試しください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此操作需要 RClick 正在运行。请启动 RClick 后重试。" + } + } + } + }, + "RClick did not respond": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "RClick did not respond" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RClick no respondió" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick が応答しませんでした" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RClick 无响应" + } + } + } + }, + "The operation may not have been executed. Please check that RClick is running.": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The operation may not have been executed. Please check that RClick is running." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Es posible que la operación no se haya ejecutado. Comprueba que RClick esté en ejecución." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作が実行されていない可能性があります。RClick が実行されているか確認してください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作可能未执行。请确认 RClick 正在运行。" } } } } }, - "version" : "1.0" -} \ No newline at end of file + "version": "1.0" +} diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index 7de3660..5b5c019 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -560,13 +560,12 @@ CODE_SIGN_ENTITLEMENTS = RClick/RClickDebug.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260712003; + CURRENT_PROJECT_VERSION = 20260823001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; - ENABLE_APP_SANDBOX = NO; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = RClick; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -576,7 +575,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -598,13 +597,12 @@ CODE_SIGN_ENTITLEMENTS = RClick/RClick.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260712003; + CURRENT_PROJECT_VERSION = 20260823001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; - ENABLE_APP_SANDBOX = NO; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = RClick; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -614,7 +612,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -677,7 +675,7 @@ CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260712003; + CURRENT_PROJECT_VERSION = 20260823001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -693,7 +691,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; PRODUCT_BUNDLE_IDENTIFIER = cn.wflixu.RClick.FinderSyncExt; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -712,7 +710,7 @@ CODE_SIGN_ENTITLEMENTS = FinderSyncExt/FinderSyncExt.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 20260712003; + CURRENT_PROJECT_VERSION = 20260823001; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 4L3563XCBN; ENABLE_APP_SANDBOX = YES; @@ -728,7 +726,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; - MARKETING_VERSION = 2.1.0; + MARKETING_VERSION = 2.2.0; 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 4f9d323..4bd01d7 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -2,7 +2,8 @@ // AppState.swift // RClick // -// Created by 李旭 on 2024/9/26. +// 运行时状态:持有配置数据的内存快照、折叠开关、权限管理器。 +// 配置持久化(SwiftData load/save)已抽到 ConfigService。 // import Combine @@ -13,7 +14,7 @@ import OSLog @MainActor -class AppState: ObservableObject { +class AppState: ObservableObject, ActionStateProviding { static let shared = AppState() @AppLog(category: "AppState") @@ -38,19 +39,20 @@ class AppState: ObservableObject { // 菜单栏显示 @AppStorage(Key.showMenuBarExtra) var showMenuBar: Bool = true - // SwiftData ModelContext(lazy 复用单个实例) - private lazy var modelContext = ModelContext(SharedDataManager.sharedModelContainer) + /// 配置持久化服务 + let configService = ConfigService() init(inExt: Bool = false) { self.inExt = inExt Task { @MainActor in logger.debug("start load") - try? load() - bookmarkManager.restoreBookmarks(context: modelContext) + refresh() + bookmarkManager.restoreBookmarks(context: configService.modelContext) } } - // Apps + // MARK: - Apps + @MainActor func deleteApp(index: Int) { apps.remove(at: index) do { @@ -87,17 +89,19 @@ class AppState: ObservableObject { try? save() } } - + func getAppItem(rid: String) -> OpenWithApp? { return apps.first { rid.contains($0.id) } } - + func getFileType(rid: String) -> NewFile? { return newFiles.first(where: { nf in rid == nf.id }) } - + + // MARK: - NewFiles + @MainActor func deleteNewFile(id: String) { newFiles.removeAll { $0.id == id } do { @@ -111,12 +115,10 @@ class AppState: ObservableObject { @MainActor func addNewFile(_ item: NewFile) { logger.debug("start add new file type") newFiles.append(item) - + do { try save() - // 使用 result } catch { - // 处理错误 logger.info("save error: \(error.localizedDescription)") } } @@ -125,14 +127,15 @@ class AppState: ObservableObject { newFiles.move(fromOffsets: source, toOffset: destination) persistMenuOrder() } - + + // MARK: - Actions + func getActionItem(rid: String) -> RCAction? { actions.first(where: { rcAtion in rcAtion.id == rid }) } - - // Action + @MainActor func toggleActionItem() { try? save() NotificationCenter.default.post(name: .menuConfigShouldUpdate, object: nil) @@ -154,13 +157,19 @@ class AppState: ObservableObject { } @MainActor func refresh() { - try? load() + let data = configService.load() + apps = data.apps + actions = data.actions + newFiles = data.newFiles + cdirs = data.commonDirs } @MainActor func sync() { try? save() } + // MARK: - 持久化 + @MainActor private func persistMenuOrder() { reindexMenuItems() @@ -188,101 +197,7 @@ class AppState: ObservableObject { @MainActor private func save() throws { - let context = modelContext reindexMenuItems() - - // 保存 Apps - try context.delete(model: AppEntity.self) - for (index, app) in apps.enumerated() { - context.insert(AppEntity(from: app, sortOrder: index)) - } - - // 保存 Actions - try context.delete(model: ActionEntity.self) - for action in actions { - context.insert(ActionEntity(from: action)) - } - - // 保存 NewFiles - try context.delete(model: NewFileTypeEntity.self) - for newFile in newFiles { - context.insert(NewFileTypeEntity(from: newFile)) - } - - // 保存 CommonDirs - try context.delete(model: CommonDirEntity.self) - for commonDir in cdirs { - context.insert(CommonDirEntity(from: commonDir)) - } - - try context.save() - } - - @MainActor - private func load() throws { - let context = modelContext - - // 加载 Apps - let appDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) - apps = (try? context.fetch(appDescriptor))?.map { entity in - var app = OpenWithApp(id: entity.id, appURL: entity.url) - app.itemName = entity.itemName - app.inheritFromGlobalArguments = entity.inheritFromGlobalArguments - app.inheritFromGlobalEnvironment = entity.inheritFromGlobalEnvironment - app.arguments = entity.arguments - app.environment = entity.environment - return app - } ?? [] - - // 加载 Actions - let actionDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) - actions = (try? context.fetch(actionDescriptor))?.map { entity in - RCAction( - id: entity.id, - name: entity.name, - enabled: entity.isEnabled, - idx: entity.sortOrder, - icon: entity.icon - ) - } ?? [] - - // 加载 NewFiles - let newFileDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) - newFiles = (try? context.fetch(newFileDescriptor))?.map { entity in - NewFile( - ext: entity.fileExtension, - name: entity.name, - enabled: entity.isEnabled, - idx: entity.sortOrder, - icon: entity.icon - ) - } ?? [] - - // 加载 CommonDirs - let commonDirDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) - var needSaveCommonDirs = false - cdirs = (try? context.fetch(commonDirDescriptor))?.map { entity in - // 自动修复旧的通用图标 - let resolvedIcon: String - if entity.icon == "folder" || entity.icon.isEmpty { - let newIcon = iconForDirectory(url: entity.path) - entity.icon = newIcon - needSaveCommonDirs = true - resolvedIcon = newIcon - } else { - resolvedIcon = entity.icon - } - return CommonDir( - id: entity.id, - name: entity.name, - url: entity.path, - icon: resolvedIcon - ) - } ?? [] - if needSaveCommonDirs { - try? context.save() - } - - logger.debug("Load from SwiftData: \(self.apps.count) apps, \(self.actions.count) actions, \(self.newFiles.count) newFiles, \(self.cdirs.count) commonDirs") + try configService.save(AppConfigData(apps: apps, actions: actions, newFiles: newFiles, commonDirs: cdirs)) } } diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 5b3673f..005ffbd 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -4498,7 +4498,239 @@ } } } + }, + "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" + } + }, + "ja": { + "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" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Para iniciar sesión automáticamente, RClick debe instalarse en /Applications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログイン時に起動するには RClick を /Applications にインストールする必要があります" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开机启动需要将 RClick 安装到 /Applications 目录" + } + } + } + }, + "Update via Mac App Store": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update via Mac App Store" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar desde la Mac App Store" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac App Store からアップデート" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过 Mac App Store 更新" + } + } + } + }, + "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." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RClick は Mac App Store からインストールされています。App Store からアップデートしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RClick 是从 Mac App Store 安装的。请从 App Store 更新。" + } + } + } + }, + "Open App Store": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open App Store" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir App Store" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "App Store を開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 App Store" + } + } + } + }, + "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": "稍后" + } + } + } + }, + "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": "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": "RClick がこのフォルダ内のファイルを削除・作成・変更するにはアクセス権が必要です。このフォルダ、またはより上位のフォルダを選択するとサブフォルダもカバーされます。許可は設定 → フォルダ権限で管理できます。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "RClick 需要访问此文件夹以删除、创建和修改文件。请选择此文件夹或更上层的父文件夹以覆盖其子文件夹。可在 设置 → 文件夹权限 中管理这些授权。" + } + } + } + }, + "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" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このフォルダとそのすべてのサブフォルダを許可します" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权此文件夹及其所有子文件夹" + } + } + } } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/RClick/RClick.entitlements b/RClick/RClick.entitlements index 846ce1e..8835cda 100644 --- a/RClick/RClick.entitlements +++ b/RClick/RClick.entitlements @@ -2,6 +2,8 @@ + com.apple.security.app-sandbox + com.apple.security.application-groups group.cn.wflixu.RClick @@ -10,11 +12,7 @@ com.apple.security.files.user-selected.read-write - com.apple.security.temporary-exception.apple-events + com.apple.security.network.client - com.apple.security.temporary-exception.files.home-relative-path.read-write - - / - diff --git a/RClick/RClickApp.swift b/RClick/RClickApp.swift index 07a9918..01049fb 100644 --- a/RClick/RClickApp.swift +++ b/RClick/RClickApp.swift @@ -71,10 +71,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - 重连机制状态 - /// 菜单配置快照(真相源) - private var lastMenuSnapshot: Data? - /// 菜单版本号,用于防重复和防乱序 - private var menuVersion: Int = 0 /// 最大重试次数 private let maxRunningMessageRetryCount: Int = 6 @@ -92,39 +88,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // 在 app 启动后执行的函数 - if showInDock { NSApp.setActivationPolicy(.regular) } else { NSApp.setActivationPolicy(.accessory) } + // 迁移旧的"开机登录"设置(修复前该开关只写入 UserDefaults,未真正注册登录项) + migrateLegacyLaunchAtLoginSetting() + // 执行数据迁移 - // TODO: 需要在 Xcode 中将 DataMigrationManager.swift 添加到 RClick target 后取消注释 Task { @MainActor in - do { -// if DataMigrationManager.shared.needsMigration() { -// logger.info("检测到旧数据,开始迁移...") -// -// // 备份现有数据 -// if let backupURL = DataMigrationManager.shared.backupUserDefaults() { -// logger.info("数据已备份到:\(backupURL.path)") -// } -// -// // 执行迁移 -// let context = ModelContext(SharedDataManager.sharedModelContainer) -// try await DataMigrationManager.shared.migrateFromUserDefaults(context: context) -// -// logger.info("数据迁移成功完成") -// } else { -// logger.info("无需数据迁移") -// } - - // 初始化默认数据 - let context = ModelContext(SharedDataManager.sharedModelContainer) - await SharedDataManager.initializeDefaultData(context: context) - } + // 初始化默认数据 + let context = ModelContext(SharedDataManager.sharedModelContainer) + await SharedDataManager.initializeDefaultData(context: context) // Preload icons for all apps to improve performance Task { @MainActor in @@ -136,6 +113,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { messager.onExtensionMessage(.click) { [weak self] data in guard let self = self else { return } if let event: ClickEventPayload = messager.decodeSignedData(data) { + // 确认收到,供扩展判断主程序可通信性(操作成败另议) + messager.sendActionAck() Task { @MainActor in await self.handleClickEvent(event) } @@ -176,6 +155,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { logger.info("applicationWillTerminate") } + /// 迁移旧的"开机登录"设置。 + /// 修复前该开关仅写入 UserDefaults("launchAtLogin"),并未真正调用 SMAppService 注册登录项。 + /// 若检测到旧值为开启,则补齐注册,并清除旧标记避免重复迁移。 + private func migrateLegacyLaunchAtLoginSetting() { + let legacyKey = "launchAtLogin" + let defaults = UserDefaults.standard + guard defaults.bool(forKey: legacyKey) else { return } + + defaults.removeObject(forKey: legacyKey) + + if !LaunchAtLogin.isEnabled { + LaunchAtLogin.isEnabled = true + } + } + // MARK: - Message Handlers func sendObserveDirMessage() { @@ -190,48 +184,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func sendMenuConfigurationUpdate() { - // Build menu config from AppState - let actionMenuItems = appState.actions.filter(\.enabled).map { $0.toActionMenuItem() } - let appMenuItems = appState.apps.map { $0.toAppMenuItem() } - let newFileMenuItems = appState.newFiles.filter(\.enabled).map { NewFileMenuItem(id: $0.id, name: $0.name, ext: $0.ext, icon: $0.icon) } - let commonDirMenuItems = appState.showCommonDirs ? appState.cdirs.map { CommonDirMenuItem(id: $0.id, name: $0.displayName, icon: $0.icon, url: $0.url.path) } : [] - - // 更新版本号 - menuVersion += 1 - - let config = MenuConfigPayload( - version: menuVersion, - actions: actionMenuItems, - apps: appMenuItems, - newFiles: newFileMenuItems, - commonDirs: commonDirMenuItems, - actionsCollapsed: appState.foldActionsMenu, - appsCollapsed: appState.foldAppsMenu, - newFilesCollapsed: appState.foldNewFileMenu, - commonDirsCollapsed: appState.foldCommonDirMenu - ) - - // 更新快照 - lastMenuSnapshot = try? JSONEncoder().encode(config) - logger.debug("Menu config version updated to \(self.menuVersion)") - - // Send using type-safe API + let config = RCRuntime.shared.menuService.buildConfig(from: appState) messager.sendMenuConfig(config) - logger.debug("Sent menu configuration to extension: \(actionMenuItems.count) actions, \(appMenuItems.count) apps") + logger.debug("Sent menu configuration to extension: \(config.actions.count) actions, \(config.apps.count) apps") } func handleClickEvent(_ event: ClickEventPayload) async { logger.debug("Handling click event: \(event.itemId) type=\(event.itemType.rawValue) trigger=\(event.trigger.rawValue) target=\(event.target)") + let actionService = RCRuntime.shared.actionService + switch event.itemType { case .app: - self.openApp(rid: event.itemId, target: event.target) + await actionService.openApp(rid: event.itemId, target: event.target) case .action: - await self.actionHandler(rid: event.itemId, target: event.target, trigger: event.trigger.rawValue) + await actionService.actionHandler(rid: event.itemId, target: event.target, trigger: event.trigger.rawValue) case .newFile: - await self.createFile(rid: event.itemId, target: event.target) + await actionService.createFile(rid: event.itemId, target: event.target) case .commonDir: - self.openCommonDirs(target: event.target) + await actionService.openCommonDirs(target: event.target) } } @@ -270,464 +241,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - /// 执行重连:发送菜单配置请求 + /// 执行重连:重置 pluginRunning 状态,等待心跳恢复 @MainActor private func performReconnection() { logger.debug("Performing reconnection: requesting menu config from main app") - // 重置 pluginRunning 状态,等待心跳恢复 pluginRunning = false } - - // MARK: - Helper Methods - - func openCommonDirs(target: [String]) { - logger.debug("开始打开常用目录,目标路径:\(target)") - - for dirPath in target { - let path = dirPath.removingPercentEncoding ?? dirPath - let url = URL(fileURLWithPath: path, isDirectory: true) - - logger.debug("正在打开目录:\(path)") - NSWorkspace.shared.open(url) - } - - logger.debug("常用目录打开操作完成") - } - - func openApp(rid: String, target: [String]) { - guard let rcitem = appState.getAppItem(rid: rid) else { - logger.warning("when openapp, but not have app \(rid)") - return - } - - let appUrl = rcitem.url - logger.debug("openApp: rid=\(rid) app=\(appUrl.path) target=\(target)") - - for dirPath in target { - let dir = URL(fileURLWithPath: dirPath.removingPercentEncoding ?? dirPath, isDirectory: true) - - // 特殊处理:WezTerm - if appUrl.path.hasSuffix("WezTerm.app") { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/Users/lixu/play/rpm/target/debug/rpm") - process.arguments = ["--name", "arg2"] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - try process.run() - process.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - if let output = String(data: data, encoding: .utf8) { - print("Output: \(output)") - } - } catch { - print("Error: \(error)") - } - } - // 通用处理:使用 NSWorkspace 打开目录 - else { - let config = NSWorkspace.OpenConfiguration() - let logger = self.logger // 捕获 Sendable logger - NSWorkspace.shared.open([dir], withApplicationAt: appUrl, configuration: config) { runningApp, error in - if let error = error { - logger.error("Error opening with application: \(error.localizedDescription)") - logger.error("Error code: \((error as NSError).code), domain: \((error as NSError).domain)") - } else if let runningApp = runningApp { - logger.debug("Successfully opened with application: \(runningApp.localizedName ?? "Unknown")") - } - } - } - } - } - - // MARK: - Helper Methods - - func getUniqueFilePath(dir: String, ext: String) -> String { - let fileManager = FileManager.default - let dirURL = URL(fileURLWithPath: dir.hasSuffix("/") ? String(dir.dropLast()) : dir) - let baseFileName = AppLocalization.localized("Untitled") - var filePath = dirURL.appendingPathComponent("\(baseFileName)\(ext)").path - var counter = 1 - - while fileManager.fileExists(atPath: filePath) { - let newFileName = "\(baseFileName)\(counter)" - filePath = dirURL.appendingPathComponent("\(newFileName)\(ext)").path - counter += 1 - } - - return filePath - } - - private func targetDirectoryForNewFile(_ target: [String]) -> String? { - guard let rawPath = target.first else { return nil } - let path = rawPath.removingPercentEncoding ?? rawPath - var isDirectory: ObjCBool = false - - if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) { - if isDirectory.boolValue { - return path - } - return URL(fileURLWithPath: path).deletingLastPathComponent().path - } - - return path - } - - private func revealInFinderAndRename(_ fileURL: URL) { - NSWorkspace.shared.activateFileViewerSelecting([fileURL]) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [logger] in - let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary - guard AXIsProcessTrustedWithOptions(options) else { - logger.warning("Accessibility permission is required to trigger Finder rename for \(fileURL.path)") - return - } - - NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first?.activate() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - let keyCodeReturn: CGKeyCode = 36 - let source = CGEventSource(stateID: .hidSystemState) - let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCodeReturn, keyDown: true) - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCodeReturn, keyDown: false) - keyDown?.post(tap: .cghidEventTap) - keyUp?.post(tap: .cghidEventTap) - } - } - } - - func actionHandler(rid: String, target: [String], trigger: String) async { - guard let rcitem = appState.getActionItem(rid: rid) else { - logger.warning("when createFile, but not have fileType ") - return - } - - switch rcitem.id { - case "copy-path": - copyPath(target) - case "delete-direct": - await deleteFoldorFile(target, trigger) - case "unhide": - await unhideFilesAndDirs(target, trigger) - case "hide": - await hideFilesAndDirs(target, trigger) - case "airdrop": - await showAirDrop(target, trigger) - default: - logger.warning("no action id matched") - } - } - - func showAirDrop(_ target: [String], _ trigger: String) async { - logger.info("---- showAirDrop trigger:\(trigger)") - let fm = FileManager.default - var fileURLs: [URL] = [] - - if trigger == "ctx-container" { - let alert = NSAlert() - alert.messageText = AppLocalization.localized("Warning") - alert.informativeText = AppLocalization.localized("The current folder cannot be shared. Please select files or subfolders instead.") - alert.alertStyle = .warning - alert.addButton(withTitle: AppLocalization.localized("OK")) - alert.runModal() - return - } - - for item in target { - let decodedPath = item.removingPercentEncoding ?? item - logger.info("airdrop path \(decodedPath)") - - if Utils.isProtectedFolder(decodedPath) { - let alert = NSAlert() - alert.messageText = AppLocalization.localized("Warning") - alert.informativeText = String(format: AppLocalization.localized("Protected system folders cannot be shared: %@"), decodedPath) - alert.alertStyle = .warning - alert.addButton(withTitle: AppLocalization.localized("OK")) - alert.runModal() - logger.warning("试图分享受保护的系统文件夹,操作已被阻止:\(decodedPath)") - continue - } - - var isDir: ObjCBool = false - if fm.fileExists(atPath: decodedPath, isDirectory: &isDir) { - if isDir.boolValue { - logger.warning("不能通过 AirDrop 分享文件夹:\(decodedPath)") - let alert = NSAlert() - alert.messageText = AppLocalization.localized("Notice") - alert.informativeText = String(format: AppLocalization.localized("Folders cannot be shared via AirDrop: %@"), decodedPath) - alert.alertStyle = .informational - alert.addButton(withTitle: AppLocalization.localized("OK")) - alert.runModal() - continue - } else { - // 确保有文件所在目录的访问权限 - let fileURL = URL(fileURLWithPath: decodedPath) - let dirURL = fileURL.deletingLastPathComponent() - if !appState.bookmarkManager.hasAccess(to: dirURL) { - guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { - logger.warning("用户取消授权,跳过 AirDrop:\(decodedPath)") - continue - } - } - fileURLs.append(fileURL) - } - } - } - - if !fileURLs.isEmpty { - if let airDropService = NSSharingService(named: .sendViaAirDrop) { - airDropService.perform(withItems: fileURLs) - logger.info("已通过 AirDrop 分享文件:\(fileURLs.map { $0.path }.joined(separator: ", "))") - } else { - logger.warning("无法获取 AirDrop 服务") - } - } - } - - func unhideFilesAndDirs(_ target: [String], _ trigger: String) async { - logger.info("开始取消隐藏文件和目录,目标路径:\(target)") - if let dirPath = target.first { - let fileManager = FileManager.default - let path = dirPath.removingPercentEncoding ?? dirPath - logger.info("处理主目录:\(path)") - let url = URL(fileURLWithPath: path) - - // 确保有目录的访问权限 - if !appState.bookmarkManager.hasAccess(to: url) { - guard let _ = await appState.bookmarkManager.promptForPermission(for: url) else { - logger.warning("用户取消授权,跳过取消隐藏:\(path)") - return - } - } - - do { - let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isHiddenKey], options: [.skipsPackageDescendants]) - for case var fileURL in contents { - do { - var resourceValues = URLResourceValues() - resourceValues.isHidden = false - try fileURL.setResourceValues(resourceValues) - logger.info("成功取消隐藏:\(fileURL.path)") - } catch { - logger.error("取消隐藏失败:\(fileURL.path): \(error)") - } - } - } catch { - logger.error("获取目录内容失败:\(error)") - } - - do { - var resourceValues = URLResourceValues() - resourceValues.isHidden = false - var targetURL = url - try targetURL.setResourceValues(resourceValues) - logger.info("成功取消隐藏主目录:\(path)") - } catch { - logger.error("取消隐藏主目录失败:\(path): \(error)") - } - logger.info("取消隐藏操作完成,共处理目录:\(path)") - } - } - - func hideFilesAndDirs(_ target: [String], _ trigger: String) async { - logger.info("开始隐藏文件和目录,目标路径:\(target), 触发器:\(trigger)") - let fileManager = FileManager.default - - if trigger == "ctx-container", let dirPath = target.first { - let path = dirPath.removingPercentEncoding ?? dirPath - logger.info("处理主目录:\(path)") - let url = URL(fileURLWithPath: path) - - // 确保有目录的访问权限 - if !appState.bookmarkManager.hasAccess(to: url) { - guard let _ = await appState.bookmarkManager.promptForPermission(for: url) else { - logger.warning("用户取消授权,跳过隐藏:\(path)") - return - } - } - - do { - let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) - for case var fileURL in contents { - if Utils.isProtectedFolder(fileURL.path) { - logger.warning("跳过受保护的文件路径:\(fileURL.path)") - continue - } - do { - var resourceValues = URLResourceValues() - resourceValues.isHidden = true - try fileURL.setResourceValues(resourceValues) - logger.info("成功隐藏:\(fileURL.path)") - } catch { - logger.error("隐藏失败:\(fileURL.path): \(error)") - } - } - } catch { - logger.error("获取目录内容失败:\(error)") - } - } else if trigger == "ctx-items" { - for dirPath in target { - let path = dirPath.removingPercentEncoding ?? dirPath - logger.info("处理路径:\(path)") - let url = URL(fileURLWithPath: path) - - if Utils.isProtectedFolder(path) { - logger.warning("跳过受保护的文件路径:\(path)") - continue - } - - // 确保有文件所在目录的访问权限 - let dirURL = url.deletingLastPathComponent() - if !appState.bookmarkManager.hasAccess(to: dirURL) { - guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { - logger.warning("用户取消授权,跳过隐藏:\(path)") - continue - } - } - - do { - var resourceValues = URLResourceValues() - resourceValues.isHidden = true - var targetURL = url - try targetURL.setResourceValues(resourceValues) - logger.info("成功隐藏:\(path)") - } catch { - logger.error("隐藏失败:\(path): \(error)") - } - } - } - logger.info("隐藏操作完成") - } - - func copyPath(_ target: [String]) { - if let dirPath = target.first { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(dirPath.removingPercentEncoding ?? dirPath, forType: .string) - } - } - - func deleteFoldorFile(_ target: [String], _ trigger: String) async { - logger.info("---- deleteFoldorFile trigger:\(trigger)") - let fm = FileManager.default - - if trigger == "ctx-container" { - let alert = NSAlert() - alert.messageText = AppLocalization.localized("Warning") - alert.informativeText = AppLocalization.localized("The current folder cannot be deleted. Please select files or subfolders instead.") - alert.alertStyle = .warning - alert.addButton(withTitle: AppLocalization.localized("OK")) - alert.runModal() - return - } - - for item in target { - let decodedPath = item.removingPercentEncoding ?? item - - if Utils.isProtectedFolder(decodedPath) { - let alert = NSAlert() - alert.messageText = AppLocalization.localized("Warning") - alert.informativeText = String(format: AppLocalization.localized("Protected system folders cannot be deleted: %@"), decodedPath) - alert.alertStyle = .warning - alert.addButton(withTitle: AppLocalization.localized("OK")) - alert.runModal() - logger.warning("试图删除受保护的系统文件夹,操作已被阻止:\(decodedPath)") - continue - } - - // 确保有父目录的访问权限 - let url = URL(fileURLWithPath: decodedPath) - let dirURL = url.deletingLastPathComponent() - if !appState.bookmarkManager.hasAccess(to: dirURL) { - guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { - logger.warning("用户取消授权,跳过删除:\(decodedPath)") - continue - } - } - - do { - try fm.removeItem(atPath: decodedPath) - } catch { - logger.error("delete \(target) file run error \(error)") - } - } - } - - func createFile(rid: String, target: [String]) async { - guard let dirPath = targetDirectoryForNewFile(target) else { - logger.warning("when createFile, but not have target directory") - return - } - - let dirURL = URL(fileURLWithPath: dirPath) - - // 确保有目标目录的访问权限 - if !appState.bookmarkManager.hasAccess(to: dirURL) { - guard let _ = await appState.bookmarkManager.promptForPermission(for: dirURL) else { - logger.warning("用户取消授权,跳过创建文件") - return - } - } - - if rid == NewFileMenuItem.customFileId { - let filePath = getUniqueFilePath(dir: dirPath, ext: "") - let fileURL = URL(fileURLWithPath: filePath) - do { - try Data().write(to: fileURL) - logger.info("created editable file: \(fileURL.path)") - revealInFinderAndRename(fileURL) - } catch { - logger.error("create editable file error: \(error.localizedDescription)") - } - return - } - - guard let rcitem = appState.getFileType(rid: rid) else { - logger.warning("when createFile, but not have fileType \(rid) ") - return - } - - let ext = rcitem.ext - logger.info("create file dir:\(dirPath) -- ext \(ext)") - let filePath = getUniqueFilePath(dir: dirPath, ext: ext) - let fileURL = URL(fileURLWithPath: filePath) - - do { - let fileManager = FileManager.default - - if let templateUrl = rcitem.template { - try fileManager.copyItem(at: templateUrl, to: fileURL) - logger.info("已成功复制模板到目标路径:\(fileURL.path)") - } else { - if let defaultTemplateURL = Bundle.main.url(forResource: "template", withExtension: ext.replacingOccurrences(of: ".", with: "")) { - logger.info("使用模板创建文件,模板路径:\(defaultTemplateURL.path)") - try fileManager.copyItem(at: defaultTemplateURL, to: fileURL) - logger.info("已成功复制模板到目标路径:\(fileURL.path)") - } else { - logger.warning("模板文件不存在:\(ext)") - try Data().write(to: fileURL) - } - } - revealInFinderAndRename(fileURL) - } catch let error as NSError { - switch error.domain { - case NSCocoaErrorDomain: - switch error.code { - case NSFileNoSuchFileError: - logger.error("文件不存在:\(filePath)") - case NSFileWriteOutOfSpaceError: - logger.error("磁盘空间不足") - case NSFileWriteNoPermissionError: - logger.error("没有写入权限:\(filePath)") - default: - logger.error("创建文件错误:\(error.localizedDescription) (错误码:\(error.code))") - } - default: - logger.error("未处理的错误:\(error.localizedDescription) (错误码:\(error.code))") - } - } - } } diff --git a/RClick/RClickDebug.entitlements b/RClick/RClickDebug.entitlements index ee2a137..8835cda 100644 --- a/RClick/RClickDebug.entitlements +++ b/RClick/RClickDebug.entitlements @@ -2,15 +2,17 @@ + com.apple.security.app-sandbox + com.apple.security.application-groups group.cn.wflixu.RClick com.apple.security.files.bookmarks.app-scope - com.apple.security.temporary-exception.files.home-relative-path.read-write - - / - + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + diff --git a/RClick/Runtime/ActionService.swift b/RClick/Runtime/ActionService.swift new file mode 100644 index 0000000..7f0b120 --- /dev/null +++ b/RClick/Runtime/ActionService.swift @@ -0,0 +1,505 @@ +// +// ActionService.swift +// RClick +// +// 文件操作 / 动作执行服务。从原 AppDelegate 迁移而来,是主 app 的"动作执行层"。 +// 依赖 AppState(只读状态)与 PermissionService(经协议注入,可 mock 单测)。 +// 注意:第一版不过度拆分,用私有方法 + MARK 分组(FileActions / AppActions / FinderActions / SystemActions)。 +// + +import AppKit +import ApplicationServices +import Foundation +import os.log + +@MainActor +final class ActionService { + @AppLog(category: "ActionService") + private var logger + + private let state: ActionStateProviding + private let permission: PermissionProviding + + init(state: ActionStateProviding, permission: PermissionProviding) { + self.state = state + self.permission = permission + } + + // MARK: - SystemActions + + /// 复制路径到剪贴板 + func copyPath(_ target: [String]) { + if let dirPath = target.first { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(dirPath.removingPercentEncoding ?? dirPath, forType: .string) + } + } + + // MARK: - AppActions + + /// 用指定应用打开选中文件/目录(沙盒门控:先 hasAccess,失败兜底再授权重试) + func openApp(rid: String, target: [String]) async { + guard let rcitem = state.getAppItem(rid: rid) else { + logger.warning("when openapp, but not have app \(rid)") + return + } + + let appUrl = rcitem.url + logger.debug("openApp: rid=\(rid) app=\(appUrl.path) target=\(target)") + + for dirPath in target { + let decodedPath = dirPath.removingPercentEncoding ?? dirPath + let url = URL(fileURLWithPath: decodedPath) + await openWithApp(url, appUrl: appUrl) + } + } + + private func openWithApp(_ url: URL, appUrl: URL) async { + let logger = self.logger // 捕获 Sendable logger,供完成回调使用 + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) + let accessURL = (exists && isDir.boolValue) ? url : url.deletingLastPathComponent() + + // 门控先行:未授权先弹窗,避免"只是打开一个文件"也要用户授权整个父目录的弹窗偏重 + if !permission.hasAccess(to: accessURL) { + guard await permission.promptForPermission(for: accessURL) != nil else { + logger.warning("用户取消授权,跳过打开:\(url.path)") + return + } + } + + let config = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([url], withApplicationAt: appUrl, configuration: config) { [weak self] runningApp, error in + guard let self else { return } + if let error { + // 失败兜底:再弹授权并重试一次 + Task { @MainActor in + await self.openRetryAfterPermission(url: url, appUrl: appUrl, accessURL: accessURL, firstError: error) + } + } else if let runningApp { + logger.debug("Successfully opened with application: \(runningApp.localizedName ?? "Unknown")") + } + } + } + + private func openRetryAfterPermission(url: URL, appUrl: URL, accessURL: URL, firstError: Error) async { + let logger = self.logger // 捕获 Sendable logger + guard await permission.promptForPermission(for: accessURL) != nil else { + logger.error("打开失败且用户取消授权:\(url.path) — \(firstError.localizedDescription)") + return + } + let config = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([url], withApplicationAt: appUrl, configuration: config) { _, retryError in + if let retryError { + logger.error("重试打开仍失败:\(url.path) — \(retryError.localizedDescription)") + } + } + } + + /// 打开常用目录(沙盒门控:目录需已授权,B4 之后添加时即存 bookmark,此处为兜底) + func openCommonDirs(target: [String]) async { + logger.debug("开始打开常用目录,目标路径:\(target)") + + for dirPath in target { + let path = dirPath.removingPercentEncoding ?? dirPath + let url = URL(fileURLWithPath: path, isDirectory: true) + + if !permission.hasAccess(to: url) { + guard await permission.promptForPermission(for: url) != nil else { + logger.warning("用户取消授权,跳过打开目录:\(path)") + continue + } + } + NSWorkspace.shared.open(url) + } + } + + // MARK: - FileActions + + /// 动作分发入口 + func actionHandler(rid: String, target: [String], trigger: String) async { + guard let rcitem = state.getActionItem(rid: rid) else { + logger.warning("when actionHandler, but not have action \(rid)") + return + } + + switch rcitem.id { + case "copy-path": + copyPath(target) + case "delete-direct": + await deleteFoldorFile(target, trigger) + case "unhide": + await unhideFilesAndDirs(target, trigger) + case "hide": + await hideFilesAndDirs(target, trigger) + case "airdrop": + await showAirDrop(target, trigger) + default: + logger.warning("no action id matched") + } + } + + func showAirDrop(_ target: [String], _ trigger: String) async { + logger.info("---- showAirDrop trigger:\(trigger)") + let fm = FileManager.default + var fileURLs: [URL] = [] + + if trigger == "ctx-container" { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Warning") + alert.informativeText = AppLocalization.localized("The current folder cannot be shared. Please select files or subfolders instead.") + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + return + } + + for item in target { + let decodedPath = item.removingPercentEncoding ?? item + logger.info("airdrop path \(decodedPath)") + + if Utils.isProtectedFolder(decodedPath) { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Warning") + alert.informativeText = String(format: AppLocalization.localized("Protected system folders cannot be shared: %@"), decodedPath) + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + logger.warning("试图分享受保护的系统文件夹,操作已被阻止:\(decodedPath)") + continue + } + + var isDir: ObjCBool = false + if fm.fileExists(atPath: decodedPath, isDirectory: &isDir) { + if isDir.boolValue { + logger.warning("不能通过 AirDrop 分享文件夹:\(decodedPath)") + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Notice") + alert.informativeText = String(format: AppLocalization.localized("Folders cannot be shared via AirDrop: %@"), decodedPath) + alert.alertStyle = .informational + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + continue + } else { + // 确保有文件所在目录的访问权限 + let fileURL = URL(fileURLWithPath: decodedPath) + let dirURL = fileURL.deletingLastPathComponent() + if !permission.hasAccess(to: dirURL) { + guard await permission.promptForPermission(for: dirURL) != nil else { + logger.warning("用户取消授权,跳过 AirDrop:\(decodedPath)") + continue + } + } + fileURLs.append(fileURL) + } + } + } + + if !fileURLs.isEmpty { + if let airDropService = NSSharingService(named: .sendViaAirDrop) { + airDropService.perform(withItems: fileURLs) + logger.info("已通过 AirDrop 分享文件:\(fileURLs.map { $0.path }.joined(separator: ", "))") + } else { + logger.warning("无法获取 AirDrop 服务") + } + } + } + + func unhideFilesAndDirs(_ target: [String], _ trigger: String) async { + logger.info("开始取消隐藏文件和目录,目标路径:\(target)") + if let dirPath = target.first { + let fileManager = FileManager.default + let path = dirPath.removingPercentEncoding ?? dirPath + logger.info("处理主目录:\(path)") + let url = URL(fileURLWithPath: path) + + // 确保有目录的访问权限 + if !permission.hasAccess(to: url) { + guard await permission.promptForPermission(for: url) != nil else { + logger.warning("用户取消授权,跳过取消隐藏:\(path)") + return + } + } + + do { + let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isHiddenKey], options: [.skipsPackageDescendants]) + for case var fileURL in contents { + do { + var resourceValues = URLResourceValues() + resourceValues.isHidden = false + try fileURL.setResourceValues(resourceValues) + logger.info("成功取消隐藏:\(fileURL.path)") + } catch { + logger.error("取消隐藏失败:\(fileURL.path): \(error)") + } + } + } catch { + logger.error("获取目录内容失败:\(error)") + } + + do { + var resourceValues = URLResourceValues() + resourceValues.isHidden = false + var targetURL = url + try targetURL.setResourceValues(resourceValues) + logger.info("成功取消隐藏主目录:\(path)") + } catch { + logger.error("取消隐藏主目录失败:\(path): \(error)") + } + logger.info("取消隐藏操作完成,共处理目录:\(path)") + } + } + + func hideFilesAndDirs(_ target: [String], _ trigger: String) async { + logger.info("开始隐藏文件和目录,目标路径:\(target), 触发器:\(trigger)") + let fileManager = FileManager.default + + if trigger == "ctx-container", let dirPath = target.first { + let path = dirPath.removingPercentEncoding ?? dirPath + logger.info("处理主目录:\(path)") + let url = URL(fileURLWithPath: path) + + // 确保有目录的访问权限 + if !permission.hasAccess(to: url) { + guard await permission.promptForPermission(for: url) != nil else { + logger.warning("用户取消授权,跳过隐藏:\(path)") + return + } + } + + do { + let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsPackageDescendants]) + for case var fileURL in contents { + if Utils.isProtectedFolder(fileURL.path) { + logger.warning("跳过受保护的文件路径:\(fileURL.path)") + continue + } + do { + var resourceValues = URLResourceValues() + resourceValues.isHidden = true + try fileURL.setResourceValues(resourceValues) + logger.info("成功隐藏:\(fileURL.path)") + } catch { + logger.error("隐藏失败:\(fileURL.path): \(error)") + } + } + } catch { + logger.error("获取目录内容失败:\(error)") + } + } else if trigger == "ctx-items" { + for dirPath in target { + let path = dirPath.removingPercentEncoding ?? dirPath + logger.info("处理路径:\(path)") + let url = URL(fileURLWithPath: path) + + if Utils.isProtectedFolder(path) { + logger.warning("跳过受保护的文件路径:\(path)") + continue + } + + // 确保有文件所在目录的访问权限 + let dirURL = url.deletingLastPathComponent() + if !permission.hasAccess(to: dirURL) { + guard await permission.promptForPermission(for: dirURL) != nil else { + logger.warning("用户取消授权,跳过隐藏:\(path)") + continue + } + } + + do { + var resourceValues = URLResourceValues() + resourceValues.isHidden = true + var targetURL = url + try targetURL.setResourceValues(resourceValues) + logger.info("成功隐藏:\(path)") + } catch { + logger.error("隐藏失败:\(path): \(error)") + } + } + } + logger.info("隐藏操作完成") + } + + func deleteFoldorFile(_ target: [String], _ trigger: String) async { + logger.info("---- deleteFoldorFile trigger:\(trigger)") + let fm = FileManager.default + + if trigger == "ctx-container" { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Warning") + alert.informativeText = AppLocalization.localized("The current folder cannot be deleted. Please select files or subfolders instead.") + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + return + } + + for item in target { + let decodedPath = item.removingPercentEncoding ?? item + + if Utils.isProtectedFolder(decodedPath) { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Warning") + alert.informativeText = String(format: AppLocalization.localized("Protected system folders cannot be deleted: %@"), decodedPath) + alert.alertStyle = .warning + alert.addButton(withTitle: AppLocalization.localized("OK")) + alert.runModal() + logger.warning("试图删除受保护的系统文件夹,操作已被阻止:\(decodedPath)") + continue + } + + // 确保有父目录的访问权限 + let url = URL(fileURLWithPath: decodedPath) + let dirURL = url.deletingLastPathComponent() + if !permission.hasAccess(to: dirURL) { + guard await permission.promptForPermission(for: dirURL) != nil else { + logger.warning("用户取消授权,跳过删除:\(decodedPath)") + continue + } + } + + do { + try fm.removeItem(atPath: decodedPath) + } catch { + logger.error("delete \(target) file run error \(error)") + } + } + } + + // MARK: - NewFileActions + + func createFile(rid: String, target: [String]) async { + guard let dirPath = targetDirectoryForNewFile(target) else { + logger.warning("when createFile, but not have target directory") + return + } + + let dirURL = URL(fileURLWithPath: dirPath) + + // 确保有目标目录的访问权限 + if !permission.hasAccess(to: dirURL) { + guard await permission.promptForPermission(for: dirURL) != nil else { + logger.warning("用户取消授权,跳过创建文件") + return + } + } + + if rid == NewFileMenuItem.customFileId { + let filePath = getUniqueFilePath(dir: dirPath, ext: "") + let fileURL = URL(fileURLWithPath: filePath) + do { + try Data().write(to: fileURL) + logger.info("created editable file: \(fileURL.path)") + revealInFinderAndRename(fileURL) + } catch { + logger.error("create editable file error: \(error.localizedDescription)") + } + return + } + + guard let rcitem = state.getFileType(rid: rid) else { + logger.warning("when createFile, but not have fileType \(rid) ") + return + } + + let ext = rcitem.ext + logger.info("create file dir:\(dirPath) -- ext \(ext)") + let filePath = getUniqueFilePath(dir: dirPath, ext: ext) + let fileURL = URL(fileURLWithPath: filePath) + + do { + let fileManager = FileManager.default + + if let templateUrl = rcitem.template { + try fileManager.copyItem(at: templateUrl, to: fileURL) + logger.info("已成功复制模板到目标路径:\(fileURL.path)") + } else { + if let defaultTemplateURL = Bundle.main.url(forResource: "template", withExtension: ext.replacingOccurrences(of: ".", with: "")) { + logger.info("使用模板创建文件,模板路径:\(defaultTemplateURL.path)") + try fileManager.copyItem(at: defaultTemplateURL, to: fileURL) + logger.info("已成功复制模板到目标路径:\(fileURL.path)") + } else { + logger.warning("模板文件不存在:\(ext)") + try Data().write(to: fileURL) + } + } + revealInFinderAndRename(fileURL) + } catch let error as NSError { + switch error.domain { + case NSCocoaErrorDomain: + switch error.code { + case NSFileNoSuchFileError: + logger.error("文件不存在:\(filePath)") + case NSFileWriteOutOfSpaceError: + logger.error("磁盘空间不足") + case NSFileWriteNoPermissionError: + logger.error("没有写入权限:\(filePath)") + default: + logger.error("创建文件错误:\(error.localizedDescription) (错误码:\(error.code))") + } + default: + logger.error("未处理的错误:\(error.localizedDescription) (错误码:\(error.code))") + } + } + } + + // MARK: - 辅助 + + /// 生成目录内唯一的文件名(避免重名冲突) + func getUniqueFilePath(dir: String, ext: String) -> String { + let fileManager = FileManager.default + let dirURL = URL(fileURLWithPath: dir.hasSuffix("/") ? String(dir.dropLast()) : dir) + let baseFileName = AppLocalization.localized("Untitled") + var filePath = dirURL.appendingPathComponent("\(baseFileName)\(ext)").path + var counter = 1 + + while fileManager.fileExists(atPath: filePath) { + let newFileName = "\(baseFileName)\(counter)" + filePath = dirURL.appendingPathComponent("\(newFileName)\(ext)").path + counter += 1 + } + + return filePath + } + + /// 解析新建文件的目标目录(目录直接用;文件取父目录) + private func targetDirectoryForNewFile(_ target: [String]) -> String? { + guard let rawPath = target.first else { return nil } + let path = rawPath.removingPercentEncoding ?? rawPath + var isDirectory: ObjCBool = false + + if FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) { + if isDirectory.boolValue { + return path + } + return URL(fileURLWithPath: path).deletingLastPathComponent().path + } + + return path + } + + /// 在 Finder 中选中新文件并触发重命名(需要辅助功能权限) + private func revealInFinderAndRename(_ fileURL: URL) { + NSWorkspace.shared.activateFileViewerSelecting([fileURL]) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [logger] in + let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary + guard AXIsProcessTrustedWithOptions(options) else { + logger.warning("Accessibility permission is required to trigger Finder rename for \(fileURL.path)") + return + } + + NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.finder").first?.activate() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + let keyCodeReturn: CGKeyCode = 36 + let source = CGEventSource(stateID: .hidSystemState) + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCodeReturn, keyDown: true) + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCodeReturn, keyDown: false) + keyDown?.post(tap: .cghidEventTap) + keyUp?.post(tap: .cghidEventTap) + } + } + } +} diff --git a/RClick/Runtime/ActionStateProviding.swift b/RClick/Runtime/ActionStateProviding.swift new file mode 100644 index 0000000..3498cbd --- /dev/null +++ b/RClick/Runtime/ActionStateProviding.swift @@ -0,0 +1,22 @@ +// +// ActionStateProviding.swift +// RClick +// +// ActionService 读取配置状态所需的最小接口。 +// AppState 遵守该协议;测试时可注入 mock。 +// + +import Foundation + +/// ActionService 读取状态所需接口 +@MainActor +protocol ActionStateProviding: AnyObject { + func getAppItem(rid: String) -> OpenWithApp? + func getActionItem(rid: String) -> RCAction? + func getFileType(rid: String) -> NewFile? + var apps: [OpenWithApp] { get } + var actions: [RCAction] { get } + var newFiles: [NewFile] { get } + var cdirs: [CommonDir] { get } + var showCommonDirs: Bool { get } +} diff --git a/RClick/Runtime/ConfigService.swift b/RClick/Runtime/ConfigService.swift new file mode 100644 index 0000000..4fdc784 --- /dev/null +++ b/RClick/Runtime/ConfigService.swift @@ -0,0 +1,124 @@ +// +// ConfigService.swift +// RClick +// +// 配置持久化服务:负责配置数据的读取、保存(SwiftData)。 +// 与 AppState 分离:AppState 持有运行时状态(内存数组),ConfigService 负责持久化。 +// + +import Foundation +import SwiftData +import os.log + +/// 配置数据快照(AppState 持有的内存配置) +struct AppConfigData { + var apps: [OpenWithApp] = [] + var actions: [RCAction] = [] + var newFiles: [NewFile] = [] + var commonDirs: [CommonDir] = [] +} + +@MainActor +final class ConfigService { + @AppLog(category: "ConfigService") + private var logger + + let modelContext = ModelContext(SharedDataManager.sharedModelContainer) + + /// 从 SwiftData 读取全部配置 + func load() -> AppConfigData { + var data = AppConfigData() + + // Apps + let appDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) + data.apps = (try? modelContext.fetch(appDescriptor))?.map { entity in + var app = OpenWithApp(id: entity.id, appURL: entity.url) + app.itemName = entity.itemName + app.inheritFromGlobalArguments = entity.inheritFromGlobalArguments + app.inheritFromGlobalEnvironment = entity.inheritFromGlobalEnvironment + app.arguments = entity.arguments + app.environment = entity.environment + return app + } ?? [] + + // Actions + let actionDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) + data.actions = (try? modelContext.fetch(actionDescriptor))?.map { entity in + RCAction( + id: entity.id, + name: entity.name, + enabled: entity.isEnabled, + idx: entity.sortOrder, + icon: entity.icon + ) + } ?? [] + + // NewFiles + let newFileDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) + data.newFiles = (try? modelContext.fetch(newFileDescriptor))?.map { entity in + NewFile( + ext: entity.fileExtension, + name: entity.name, + enabled: entity.isEnabled, + idx: entity.sortOrder, + icon: entity.icon + ) + } ?? [] + + // CommonDirs(含旧图标自动修复) + let commonDirDescriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) + var needSaveCommonDirs = false + data.commonDirs = (try? modelContext.fetch(commonDirDescriptor))?.map { entity in + let resolvedIcon: String + if entity.icon == "folder" || entity.icon.isEmpty { + let newIcon = iconForDirectory(url: entity.path) + entity.icon = newIcon + needSaveCommonDirs = true + resolvedIcon = newIcon + } else { + resolvedIcon = entity.icon + } + return CommonDir( + id: entity.id, + name: entity.name, + url: entity.path, + icon: resolvedIcon + ) + } ?? [] + if needSaveCommonDirs { + try? modelContext.save() + } + + logger.debug("Load from SwiftData: \(data.apps.count) apps, \(data.actions.count) actions, \(data.newFiles.count) newFiles, \(data.commonDirs.count) commonDirs") + return data + } + + /// 将配置数据写回 SwiftData(全量替换) + func save(_ data: AppConfigData) throws { + // Apps + try modelContext.delete(model: AppEntity.self) + for (index, app) in data.apps.enumerated() { + modelContext.insert(AppEntity(from: app, sortOrder: index)) + } + + // Actions + try modelContext.delete(model: ActionEntity.self) + for action in data.actions { + modelContext.insert(ActionEntity(from: action)) + } + + // NewFiles + try modelContext.delete(model: NewFileTypeEntity.self) + for newFile in data.newFiles { + modelContext.insert(NewFileTypeEntity(from: newFile)) + } + + // CommonDirs + try modelContext.delete(model: CommonDirEntity.self) + for commonDir in data.commonDirs { + modelContext.insert(CommonDirEntity(from: commonDir)) + } + + try modelContext.save() + } +} diff --git a/RClick/Runtime/MenuService.swift b/RClick/Runtime/MenuService.swift new file mode 100644 index 0000000..656b257 --- /dev/null +++ b/RClick/Runtime/MenuService.swift @@ -0,0 +1,44 @@ +// +// MenuService.swift +// RClick +// +// 菜单配置构建服务:从 AppState 生成发往 Finder Extension 的 MenuConfigPayload。 +// 版本号在这里维护,用于防重复和防乱序。 +// + +import Foundation + +@MainActor +final class MenuService { + /// 菜单版本号(防重复 / 防乱序) + private var menuVersion = 0 + /// 最近一次构建的配置快照(编码后,供诊断/兜底) + private var lastMenuSnapshot: Data? + + var lastSnapshot: Data? { lastMenuSnapshot } + + /// 从 AppState 实时构建菜单配置 + func buildConfig(from state: AppState) -> 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 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, + actions: actionMenuItems, + apps: appMenuItems, + newFiles: newFileMenuItems, + commonDirs: commonDirMenuItems, + actionsCollapsed: state.foldActionsMenu, + appsCollapsed: state.foldAppsMenu, + newFilesCollapsed: state.foldNewFileMenu, + commonDirsCollapsed: state.foldCommonDirMenu + ) + + lastMenuSnapshot = try? JSONEncoder().encode(config) + return config + } +} diff --git a/RClick/Runtime/PermissionService.swift b/RClick/Runtime/PermissionService.swift new file mode 100644 index 0000000..ba3e97d --- /dev/null +++ b/RClick/Runtime/PermissionService.swift @@ -0,0 +1,43 @@ +// +// PermissionService.swift +// RClick +// +// 权限服务层:对上层(ActionService 等)隐藏 Bookmark 的实现细节。 +// 今天是 Security-Scoped Bookmark,未来可能是 User Selected / Container / +// V3 非沙盒的 Direct File Access —— 上层不应感知实现。 +// + +import Foundation + +/// 权限服务协议(供 ActionService 注入 mock,便于单测) +@MainActor +protocol PermissionProviding: AnyObject { + /// 检查某个文件/目录是否在已授权目录下 + func hasAccess(to url: URL) -> Bool + /// 弹出 NSOpenPanel 请求目录访问权限 + func promptForPermission(for url: URL) async -> URL? + /// 保存目录的 security-scoped bookmark + func saveBookmark(for url: URL) +} + +/// 权限服务:内部委托 BookmarkManager +@MainActor +final class PermissionService: PermissionProviding { + private let bookmarkManager: BookmarkManager + + init(bookmarkManager: BookmarkManager) { + self.bookmarkManager = bookmarkManager + } + + func hasAccess(to url: URL) -> Bool { + bookmarkManager.hasAccess(to: url) + } + + func promptForPermission(for url: URL) async -> URL? { + await bookmarkManager.promptForPermission(for: url) + } + + func saveBookmark(for url: URL) { + bookmarkManager.saveBookmark(for: url) + } +} diff --git a/RClick/Runtime/RCRuntime.swift b/RClick/Runtime/RCRuntime.swift new file mode 100644 index 0000000..7771c6c --- /dev/null +++ b/RClick/Runtime/RCRuntime.swift @@ -0,0 +1,36 @@ +// +// RCRuntime.swift +// RClick +// +// 进程内 Runtime 依赖容器:持有 AppState 与各 Service,AppDelegate 按需取。 +// 注意:这是 Main App 进程内的架构层,不是独立进程 / Helper。 +// + +import Foundation + +@MainActor +final class RCRuntime { + static let shared = RCRuntime() + + /// 运行时状态(配置内存快照 + 折叠开关 + 权限管理器) + let state: AppState + /// 配置持久化 + let configService: ConfigService + /// 菜单构建 + let menuService: MenuService + /// 权限(委托 BookmarkManager) + let permissionService: PermissionService + /// 动作执行(文件操作) + let actionService: ActionService + /// IPC + let messager: Messager + + private init() { + self.state = AppState.shared + self.configService = state.configService + self.permissionService = PermissionService(bookmarkManager: state.bookmarkManager) + self.menuService = MenuService() + self.actionService = ActionService(state: state, permission: permissionService) + self.messager = Messager.shared + } +} diff --git a/RClick/Settings/CommonDirsSettingTabView.swift b/RClick/Settings/CommonDirsSettingTabView.swift index 1372de9..7eb72f0 100644 --- a/RClick/Settings/CommonDirsSettingTabView.swift +++ b/RClick/Settings/CommonDirsSettingTabView.swift @@ -76,6 +76,8 @@ struct CommonDirsSettingTabView: View { if let url = urls.first { let commonDir = CommonDir(id: UUID().uuidString, name: url.lastPathComponent, url: url, icon: iconForDirectory(url: url)) if !store.cdirs.contains(where: { $0.url == commonDir.url }) { + // 添加常用目录时即存 bookmark:沙盒下避免每次点击打开都弹授权 + store.bookmarkManager.saveBookmark(for: url) store.cdirs.append(commonDir) store.sync() } diff --git a/RClick/Settings/FolderPermissionsSheetView.swift b/RClick/Settings/FolderPermissionsSheetView.swift index 04fd61b..b7d76d4 100644 --- a/RClick/Settings/FolderPermissionsSheetView.swift +++ b/RClick/Settings/FolderPermissionsSheetView.swift @@ -35,13 +35,22 @@ struct FolderPermissionsSheetView: View { } else { List { ForEach(bookmarkManager.authorizedDirectories, id: \.path) { url in - HStack { + HStack(alignment: .center) { Image(systemName: "folder.fill") .foregroundColor(.accentColor) - Text(url.path) - .font(.body) - .lineLimit(1) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: 2) { + Text(url.lastPathComponent) + .font(.body) + .lineLimit(1) + Text(url.path) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Text(AppLocalization.localized("Authorizes this folder and all its subfolders")) + .font(.caption2) + .foregroundStyle(.tertiary) + } Spacer() Button { bookmarkManager.removeDirectory(url) @@ -52,6 +61,7 @@ struct FolderPermissionsSheetView: View { .buttonStyle(.borderless) .help(AppLocalization.localized("Remove folder permission")) } + .padding(.vertical, 2) } } .frame(minHeight: 200) diff --git a/RClick/Settings/GeneralSettingsTabView.swift b/RClick/Settings/GeneralSettingsTabView.swift index dc4f129..a0fe54b 100644 --- a/RClick/Settings/GeneralSettingsTabView.swift +++ b/RClick/Settings/GeneralSettingsTabView.swift @@ -17,7 +17,6 @@ struct GeneralSettingsTabView: View { @AppLog(category: "settings-general") private var logger - @AppStorage("launchAtLogin") private var launchAtLogin = false @AppStorage(Key.showMenuBarExtra, store: .group) private var showMenuBarExtra = true @EnvironmentObject var store: AppState @ObservedObject private var bookmarkManager = AppState.shared.bookmarkManager @@ -57,7 +56,7 @@ struct GeneralSettingsTabView: View { Text(appLocalized: "Show icon in menu bar") } - Toggle(isOn: $launchAtLogin) { + LaunchAtLogin.Toggle { Text(appLocalized: "Launch at login") } } header: { diff --git a/RClick/Shared/BookmarkManager.swift b/RClick/Shared/BookmarkManager.swift index 8f55709..d20c045 100644 --- a/RClick/Shared/BookmarkManager.swift +++ b/RClick/Shared/BookmarkManager.swift @@ -38,43 +38,52 @@ final class BookmarkManager: ObservableObject { guard let entities = try? context.fetch(descriptor) else { return } for entity in entities { + // resolve 抛错 = bookmark 真失效(路径被删/移动导致无法解析)→ 删除脏数据 + let url: URL + var isStale = false do { - var isStale = false - let url = try URL( + url = try URL( resolvingBookmarkData: entity.bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale ) + } catch { + logger.error("恢复 bookmark 失败,删除脏数据:\(entity.pathString) — \(error.localizedDescription)") + context.delete(entity) + continue + } - if isStale { - // 刷新 bookmark - guard url.startAccessingSecurityScopedResource() else { - logger.warning("无法恢复 bookmark(startAccessing 失败):\(entity.pathString)") - continue - } + if isStale { + // 刷新 bookmark + guard url.startAccessingSecurityScopedResource() else { + logger.warning("无法访问 bookmark(startAccessing 失败,可能是外置盘未挂载,跳过不删):\(entity.pathString)") + continue + } + do { let newData = try url.bookmarkData( options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil ) entity.bookmarkData = newData - url.stopAccessingSecurityScopedResource() + } catch { + logger.error("刷新 bookmark 失败,保留旧数据:\(entity.pathString) — \(error.localizedDescription)") } + url.stopAccessingSecurityScopedResource() + } - guard url.startAccessingSecurityScopedResource() else { - logger.warning("无法恢复 bookmark(startAccessing 失败):\(entity.pathString)") - continue - } + // startAccessing 失败 = 临时不可达(外置盘未挂载等),不立即删,下次启动再试 + guard url.startAccessingSecurityScopedResource() else { + logger.warning("无法访问 bookmark(startAccessing 失败,跳过不删):\(entity.pathString)") + continue + } - let normalized = url.resolvingSymlinksInPath() - if !authorizedDirectories.contains(normalized) { - authorizedDirectories.append(normalized) - } - logger.debug("已恢复 bookmark:\(entity.pathString)") - } catch { - logger.error("恢复 bookmark 失败:\(entity.pathString) — \(error.localizedDescription)") + let normalized = url.resolvingSymlinksInPath() + if !authorizedDirectories.contains(normalized) { + authorizedDirectories.append(normalized) } + logger.debug("已恢复 bookmark:\(entity.pathString)") } try? context.save() logger.info("Bookmark 恢复完成:\(self.authorizedDirectories.count) 个目录") @@ -105,7 +114,7 @@ final class BookmarkManager: ObservableObject { defer { isPrompting = false } let panel = NSOpenPanel() - panel.message = AppLocalization.localized("Grant RClick access to this folder to perform file operations.") + panel.message = AppLocalization.localized("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.") panel.prompt = AppLocalization.localized("Grant Access") panel.canChooseFiles = false panel.canChooseDirectories = true diff --git a/RClick/Shared/LaunchAtLogin.swift b/RClick/Shared/LaunchAtLogin.swift index 861fe4e..e15e249 100644 --- a/RClick/Shared/LaunchAtLogin.swift +++ b/RClick/Shared/LaunchAtLogin.swift @@ -102,7 +102,35 @@ extension LaunchAtLogin { } public var body: some View { - SwiftUI.Toggle(isOn: $launchAtLogin.isEnabled) { label } + VStack(alignment: .leading, spacing: 4) { + SwiftUI.Toggle(isOn: $launchAtLogin.isEnabled) { label } + if let warningKey = warningMessageKey { + SwiftUI.Label { + Text(AppLocalization.localized(warningKey)) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.footnote) + .foregroundColor(.yellow) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// 当登录项实际未生效时给出提示: + /// - .requiresApproval:macOS 13+ 首次启用需用户在系统设置批准 + /// - 其他非 enabled:多半是 app 不在 /Applications(Debug 从 Xcode 跑就是这种) + private var warningMessageKey: String? { + switch SMAppService.mainApp.status { + case .enabled: + return nil + case .requiresApproval: + return "Launch at login is pending approval in System Settings" + case .notRegistered, .notFound: + return "Launch at login requires RClick to be installed in /Applications" + @unknown default: + return nil + } } } } diff --git a/RClick/Shared/Updater.swift b/RClick/Shared/Updater.swift index 0b632b5..010b0c8 100644 --- a/RClick/Shared/Updater.swift +++ b/RClick/Shared/Updater.swift @@ -183,7 +183,14 @@ class UpdateManager: ObservableObject { private let githubChecker: GitHubReleaseChecker private let preferences: UpdatePreferences private let currentVersion: String - + + /// 是否来自 Mac App Store(通过 `Contents/_MASReceipt/receipt` 是否存在判断)。 + /// App Store 构建禁止自更新(MAS 审核要求),改为引导用户去 App Store 更新。 + private var isAppStoreBuild: Bool { + let receiptPath = Bundle.main.bundlePath + "/Contents/_MASReceipt/receipt" + return FileManager.default.fileExists(atPath: receiptPath) + } + init(owner: String, repo: String, currentVersion: String) { self.githubChecker = GitHubReleaseChecker(owner: owner, repo: repo) self.preferences = UpdatePreferences() @@ -225,6 +232,13 @@ class UpdateManager: ObservableObject { func downloadAndInstallUpdate() async { print("start downloadAndInstallUpdate") + + // App Store 构建:禁止自更新,引导用户去 Mac App Store + guard !isAppStoreBuild else { + showAppStoreUpdateAlert() + return + } + guard let release = availableUpdate else { updateError = AppLocalization.localized("No update is available.") print("没有可用的更新") @@ -410,6 +424,20 @@ class UpdateManager: ObservableObject { } + // MARK: - App Store 更新引导 + + /// App Store 构建:提示用户通过 Mac App Store 更新 + private func showAppStoreUpdateAlert() { + let alert = NSAlert() + alert.messageText = AppLocalization.localized("Update via Mac App Store") + alert.informativeText = AppLocalization.localized("RClick is installed from the Mac App Store. Please update it from the App Store.") + alert.addButton(withTitle: AppLocalization.localized("Open App Store")) + alert.addButton(withTitle: AppLocalization.localized("Later")) + if alert.runModal() == .alertFirstButtonReturn { + NSWorkspace.shared.open(URL(string: "macappstore://")!) + } + } + // MARK: - 显示安装完成提示 private func showInstallationCompleteAlert() { diff --git a/RClickTests/ActionServiceTests.swift b/RClickTests/ActionServiceTests.swift new file mode 100644 index 0000000..2926dca --- /dev/null +++ b/RClickTests/ActionServiceTests.swift @@ -0,0 +1,79 @@ +// +// ActionServiceTests.swift +// RClickTests +// +// ActionService 单测:用 mock 的 PermissionProviding / ActionStateProviding +// 验证"未授权 + 用户取消"时文件操作被跳过。 +// + +import Foundation +import Testing +@testable import RClick + +@MainActor +final class ActionServiceTests { + + @MainActor + private final class MockPermission: PermissionProviding { + var hasAccessResult = false + var promptResult: URL? + var saveCalls: [URL] = [] + + func hasAccess(to url: URL) -> Bool { hasAccessResult } + func promptForPermission(for url: URL) async -> URL? { promptResult } + func saveBookmark(for url: URL) { saveCalls.append(url) } + } + + @MainActor + private final class MockState: ActionStateProviding { + var apps: [OpenWithApp] = [] + var actions: [RCAction] = [] + var newFiles: [NewFile] = [] + var cdirs: [CommonDir] = [] + var showCommonDirs = false + + func getAppItem(rid: String) -> OpenWithApp? { nil } + func getActionItem(rid: String) -> RCAction? { nil } + func getFileType(rid: String) -> NewFile? { nil } + } + + @Test func deleteIsSkippedWhenAuthorizationIsCancelled() async throws { + // 准备临时文件 + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("dummy.txt") + try Data("x".utf8).write(to: file) + + let permission = MockPermission() + permission.hasAccessResult = false // 未授权 + permission.promptResult = nil // 用户取消授权 + let service = ActionService(state: MockState(), permission: permission) + + await service.deleteFoldorFile([file.path], "ctx-items") + + // 未授权且用户取消 → 文件应保留 + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test func deleteProceedsWhenAuthorizationGranted() async throws { + // 准备临时文件 + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("rclick-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("dummy.txt") + try Data("x".utf8).write(to: file) + + let permission = MockPermission() + permission.hasAccessResult = false // 未授权,但... + permission.promptResult = file.deletingLastPathComponent() // ...用户授权了目录 + let service = ActionService(state: MockState(), permission: permission) + + await service.deleteFoldorFile([file.path], "ctx-items") + + // 授权后应执行删除 → 文件被移除 + #expect(!FileManager.default.fileExists(atPath: file.path)) + } +} diff --git a/SECURITY.md b/SECURITY.md index b280446..42ad44d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,7 +45,7 @@ We follow a coordinated disclosure process: As a macOS Finder extension, RClick has these security-sensitive areas: -- **File System Access** — RClick uses security-scoped bookmarks and requires Full Disk Access permissions. We follow Apple's sandboxing guidelines. +- **File System Access** — RClick is sandboxed and uses security-scoped bookmarks for per-folder authorization: file operations check `PermissionService.hasAccess(to:)`; un-authorized folders trigger a one-time `NSOpenPanel` grant that is cached as a bookmark. Full Disk Access is not used. - **Inter-Process Communication** — Messages between the main app and FinderSync Extension use `DistributedNotificationCenter`. All message payloads are validated. - **Third-Party App Launching** — RClick can launch external applications with custom arguments. Arguments are properly escaped to prevent injection. diff --git a/Shared/MessageSecurity.swift b/Shared/MessageSecurity.swift index 89e71e2..bc283de 100644 --- a/Shared/MessageSecurity.swift +++ b/Shared/MessageSecurity.swift @@ -105,36 +105,6 @@ public struct SignedPayload: Codable { } } -// MARK: - 发送者验证 - -/// 验证消息发送者身份 -public class SenderValidator { - - /// 允许的 Bundle ID 列表 - private static let allowedBundleIDs = [ - "com.lixu.RClick", // 主程序 - "com.lixu.RClick.FinderSyncExt" // FinderSync 扩展 - ] - - /// 验证发送者 Bundle ID 是否合法 - /// - Returns: 验证是否通过 - public static func verifyCurrentProcess() -> Bool { - guard let bundleID = Bundle.main.bundleIdentifier else { - os_log("Failed to get bundle identifier", log: OSLog.default, type: .error) - return false - } - - return allowedBundleIDs.contains(bundleID) - } - - /// 检查 Bundle ID 是否在白名单中 - /// - Parameter bundleID: 需要检查的 Bundle ID - /// - Returns: 是否合法 - public static func isAllowedBundleID(_ bundleID: String) -> Bool { - return allowedBundleIDs.contains(bundleID) - } -} - // MARK: - 消息完整性检查 extension MessageSecurity { diff --git a/Shared/Messager.swift b/Shared/Messager.swift index 44fec28..ce9e617 100644 --- a/Shared/Messager.swift +++ b/Shared/Messager.swift @@ -21,8 +21,8 @@ enum MainToExtensionAction: String, Codable { case running = "running" /// 主程序退出通知 case quit = "quit" - /// 响应菜单配置请求(携带菜单配置) - case requestConfig = "request-config" + /// 确认已收到点击事件(用于扩展判断主程序是否可通信) + case actionAck = "action-ack" } /// Extension 发送给主程序的消息类型 @@ -223,8 +223,9 @@ class Messager: @unchecked Sendable { static let shared = Messager() // 消息处理器存储 - // 安全说明:仅在 init/启动阶段写入一次,之后只读不写 - // 写入发生在分布式通知到达之前,无并发读写风险 + // 写入:启动阶段主线程注册;读取:DistributedNotificationCenter 回调线程。 + // Swift 6 严格并发下用锁保护,避免数据竞争(原 nonisolated(unsafe) 无同步)。 + private let handlersLock = NSLock() nonisolated(unsafe) private var mainToExtensionHandlers: [MainToExtensionAction: (Data?) -> Void] = [:] nonisolated(unsafe) private var extensionToMainHandlers: [ExtensionToMainAction: (Data?) -> Void] = [:] @@ -299,12 +300,16 @@ class Messager: @unchecked Sendable { /// Extension 注册主程序消息处理器 func onMainMessage(_ action: MainToExtensionAction, handler: @escaping (Data?) -> Void) { + handlersLock.lock() mainToExtensionHandlers[action] = handler + handlersLock.unlock() } /// 主程序注册 Extension 消息处理器 func onExtensionMessage(_ action: ExtensionToMainAction, handler: @escaping (Data?) -> Void) { + handlersLock.lock() extensionToMainHandlers[action] = handler + handlersLock.unlock() } // MARK: - 处理消息 @@ -320,7 +325,11 @@ class Messager: @unchecked Sendable { let message = try JSONDecoder().decode(MainToExtensionMessage.self, from: jsonData) logger.debug("Received main-to-extension message: \(message.action.rawValue)") - if let handler = mainToExtensionHandlers[message.action] { + handlersLock.lock() + let handler = mainToExtensionHandlers[message.action] + handlersLock.unlock() + + if let handler { handler(message.signedData) } else { logger.warning("No handler registered for action: \(message.action.rawValue)") @@ -341,7 +350,11 @@ class Messager: @unchecked Sendable { let message = try JSONDecoder().decode(ExtensionToMainMessage.self, from: jsonData) logger.debug("Received extension-to-main message: \(message.action.rawValue)") - if let handler = extensionToMainHandlers[message.action] { + handlersLock.lock() + let handler = extensionToMainHandlers[message.action] + handlersLock.unlock() + + if let handler { handler(message.signedData) } else { logger.warning("No handler registered for action: \(message.action.rawValue)") @@ -369,6 +382,11 @@ class Messager: @unchecked Sendable { sendToExtension(.quit, data: Optional.none) } + /// 发送点击事件确认(主程序已收到 click,供扩展判断可通信性) + func sendActionAck() { + sendToExtension(.actionAck, data: Optional.none) + } + /// Extension 发送心跳 func sendHeartbeat() { sendToMain(.heartbeat, data: Optional.none) @@ -384,11 +402,6 @@ class Messager: @unchecked Sendable { sendToMain(.click, data: event) } - /// 主程序响应菜单配置请求 - func respondMenuConfigRequest(_ config: MenuConfigPayload) { - sendToExtension(.requestConfig, data: config) - } - // MARK: - 解码辅助 /// 解码数据为指定类型 diff --git a/specs/design-bookmark-permission.md b/specs/design-bookmark-permission.md index c9118de..68b912f 100644 --- a/specs/design-bookmark-permission.md +++ b/specs/design-bookmark-permission.md @@ -267,11 +267,12 @@ LabeledContent { ## 七、保持不变的部分 -- **entitlements**:保留 `home-relative-path.read-write` 作为非 TCC 目录的后备 - **Accessibility 权限**:`revealInFinderAndRename` 仍然需要,不动 - **Finder Extension 权限**:不动 - **CommonDirEntity**:不动,独立于 Bookmark +> ⚠️ **更新(v2.2.0)**:原"保留 `home-relative-path.read-write` 作为非 TCC 目录后备"的约定已作废 —— App Store 审核不接受临时例外。主 app 已开启沙盒(`com.apple.security.app-sandbox`)并移除全部 `temporary-exception.*`,bookmark 成为唯一文件夹授权途径。另补 `com.apple.security.network.client`(更新检查需要)。 + --- ## 八、涉及的本地化字符串 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" new file mode 100644 index 0000000..90cc50b --- /dev/null +++ "b/specs/v2.2.0-\351\207\215\346\236\204\346\203\263\346\263\225.md" @@ -0,0 +1,388 @@ +对,如果确定采用 **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" new file mode 100644 index 0000000..0c6606e --- /dev/null +++ "b/specs/v2.2.0-\351\207\215\346\236\204\350\256\241\345\210\222.md" @@ -0,0 +1,189 @@ +# 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/\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" index 054d394..2bb1e4c 100644 --- "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" @@ -1,8 +1,8 @@ # RClick 架构重构设计 > 最后更新:2026-04-18 -> 状态:设计文档(已根据专家审核更新) -> 架构方向:**瘦 Extension**(Slim Extension)+ **全盘监听** + **完全磁盘访问权限** +> 状态:⚠️ **方向已过时** —— "完全磁盘访问权限(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 授权** --- From 627cf9182a0d0c5c9d544ec2886978fa8ad52d60 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 23 Aug 2026 19:57:58 +0800 Subject: [PATCH 4/5] feat: add per-app "open in a new window" option - Add OpenWithApp.opensNewInstance (persisted in AppEntity) - EditAppSheetView: new "Open in a new window" toggle - ActionService: set NSWorkspace.OpenConfiguration.createsNewApplicationInstance - Fix SwiftData migration: new @Model field must have a default value (non-optional field without default breaks migration for existing data) - Add unit test for opensNewInstance persistence Co-Authored-By: Claude --- RClick/AppState.swift | 3 +- RClick/Localizable.xcstrings | 87 ++++++++++++++++++++++++++ RClick/Model/AppEntity.swift | 9 ++- RClick/Runtime/ActionService.swift | 13 ++-- RClick/Runtime/ConfigService.swift | 1 + RClick/Settings/EditAppSheetView.swift | 16 ++++- RClickTests/ActionServiceTests.swift | 18 ++++++ Shared/RCBase.swift | 2 + 8 files changed, 140 insertions(+), 9 deletions(-) diff --git a/RClick/AppState.swift b/RClick/AppState.swift index 4bd01d7..ad3ad48 100644 --- a/RClick/AppState.swift +++ b/RClick/AppState.swift @@ -79,12 +79,13 @@ class AppState: ObservableObject, ActionStateProviding { } @MainActor - func updateApp(id: String, itemName: String, arguments: [String], environment: [String: String]) { + func updateApp(id: String, itemName: String, arguments: [String], environment: [String: String], opensNewInstance: Bool = false) { if let index = apps.firstIndex(where: { $0.id == id }) { var updatedApp = apps[index] updatedApp.itemName = itemName updatedApp.arguments = arguments updatedApp.environment = environment + updatedApp.opensNewInstance = opensNewInstance apps[index] = updatedApp try? save() } diff --git a/RClick/Localizable.xcstrings b/RClick/Localizable.xcstrings index 005ffbd..2fdabb0 100644 --- a/RClick/Localizable.xcstrings +++ b/RClick/Localizable.xcstrings @@ -4730,6 +4730,93 @@ } } } + }, + "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" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいウィンドウで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在新窗口中打开" + } + } + } + }, + "Window": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Window" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ventana" + } + }, + "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" + } + }, + "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": "ブラウザ系アプリの場合、起動中のインスタンスに新しいタブを開く代わりに、新しいインスタンスを起動します" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "对浏览器类 app,改为启动新实例,而不是在已运行的实例中新开标签页" + } + } + } } }, "version": "1.0" diff --git a/RClick/Model/AppEntity.swift b/RClick/Model/AppEntity.swift index cbc97a0..7279649 100644 --- a/RClick/Model/AppEntity.swift +++ b/RClick/Model/AppEntity.swift @@ -20,6 +20,8 @@ final class AppEntity { var environmentData: Data? // 编码后的字典 var sortOrder: Int var isEnabled: Bool + /// 注意:必须带默认值,否则 SwiftData 迁移旧库(已有数据)时无法为旧行赋值而失败 + var opensNewInstance: Bool = false var createdAt: Date var updatedAt: Date @@ -31,7 +33,8 @@ final class AppEntity { arguments: [String] = [], environment: [String: String] = [:], sortOrder: Int = 0, - isEnabled: Bool = true) { + isEnabled: Bool = true, + opensNewInstance: Bool = false) { self.id = id self.urlString = url.path(percentEncoded: false) self.itemName = itemName @@ -39,6 +42,7 @@ final class AppEntity { self.inheritFromGlobalEnvironment = inheritFromGlobalEnvironment self.sortOrder = sortOrder self.isEnabled = isEnabled + self.opensNewInstance = opensNewInstance self.createdAt = Date() self.updatedAt = Date() @@ -85,7 +89,8 @@ final class AppEntity { inheritFromGlobalEnvironment: openWithApp.inheritFromGlobalEnvironment, arguments: openWithApp.arguments, environment: openWithApp.environment, - sortOrder: sortOrder + sortOrder: sortOrder, + opensNewInstance: openWithApp.opensNewInstance ) } } diff --git a/RClick/Runtime/ActionService.swift b/RClick/Runtime/ActionService.swift index 7f0b120..46cc2a6 100644 --- a/RClick/Runtime/ActionService.swift +++ b/RClick/Runtime/ActionService.swift @@ -46,16 +46,16 @@ final class ActionService { } let appUrl = rcitem.url - logger.debug("openApp: rid=\(rid) app=\(appUrl.path) target=\(target)") + logger.debug("openApp: rid=\(rid) app=\(appUrl.path) target=\(target) opensNewInstance=\(rcitem.opensNewInstance)") for dirPath in target { let decodedPath = dirPath.removingPercentEncoding ?? dirPath let url = URL(fileURLWithPath: decodedPath) - await openWithApp(url, appUrl: appUrl) + await openWithApp(url, appUrl: appUrl, opensNewInstance: rcitem.opensNewInstance) } } - private func openWithApp(_ url: URL, appUrl: URL) async { + private func openWithApp(_ url: URL, appUrl: URL, opensNewInstance: Bool) async { let logger = self.logger // 捕获 Sendable logger,供完成回调使用 var isDir: ObjCBool = false let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) @@ -70,12 +70,14 @@ final class ActionService { } let config = NSWorkspace.OpenConfiguration() + // 以新实例打开(浏览器类 app 会新开窗口而非新 tab) + config.createsNewApplicationInstance = opensNewInstance NSWorkspace.shared.open([url], withApplicationAt: appUrl, configuration: config) { [weak self] runningApp, error in guard let self else { return } if let error { // 失败兜底:再弹授权并重试一次 Task { @MainActor in - await self.openRetryAfterPermission(url: url, appUrl: appUrl, accessURL: accessURL, firstError: error) + await self.openRetryAfterPermission(url: url, appUrl: appUrl, accessURL: accessURL, opensNewInstance: opensNewInstance, firstError: error) } } else if let runningApp { logger.debug("Successfully opened with application: \(runningApp.localizedName ?? "Unknown")") @@ -83,13 +85,14 @@ final class ActionService { } } - private func openRetryAfterPermission(url: URL, appUrl: URL, accessURL: URL, firstError: Error) async { + private func openRetryAfterPermission(url: URL, appUrl: URL, accessURL: URL, opensNewInstance: Bool, firstError: Error) async { let logger = self.logger // 捕获 Sendable logger guard await permission.promptForPermission(for: accessURL) != nil else { logger.error("打开失败且用户取消授权:\(url.path) — \(firstError.localizedDescription)") return } let config = NSWorkspace.OpenConfiguration() + config.createsNewApplicationInstance = opensNewInstance NSWorkspace.shared.open([url], withApplicationAt: appUrl, configuration: config) { _, retryError in if let retryError { logger.error("重试打开仍失败:\(url.path) — \(retryError.localizedDescription)") diff --git a/RClick/Runtime/ConfigService.swift b/RClick/Runtime/ConfigService.swift index 4fdc784..e8ca654 100644 --- a/RClick/Runtime/ConfigService.swift +++ b/RClick/Runtime/ConfigService.swift @@ -38,6 +38,7 @@ final class ConfigService { app.inheritFromGlobalEnvironment = entity.inheritFromGlobalEnvironment app.arguments = entity.arguments app.environment = entity.environment + app.opensNewInstance = entity.opensNewInstance return app } ?? [] diff --git a/RClick/Settings/EditAppSheetView.swift b/RClick/Settings/EditAppSheetView.swift index 1e39c49..130fcd8 100644 --- a/RClick/Settings/EditAppSheetView.swift +++ b/RClick/Settings/EditAppSheetView.swift @@ -16,6 +16,7 @@ struct EditAppSheetView: View { @State private var itemName: String @State private var arguments: String @State private var environment: String + @State private var opensNewInstance: Bool let messager = Messager.shared @@ -25,6 +26,7 @@ struct EditAppSheetView: View { _itemName = State(initialValue: app.itemName) _arguments = State(initialValue: app.arguments.joined(separator: "; ")) _environment = State(initialValue: app.environment.map { "\($0.key)=\($0.value)" }.joined(separator: "\n")) + _opensNewInstance = State(initialValue: app.opensNewInstance) } var body: some View { @@ -63,6 +65,17 @@ struct EditAppSheetView: View { Text(appLocalized: "Format: KEY=VALUE, one per line") .foregroundColor(.secondary) } + + Section { + Toggle(isOn: $opensNewInstance) { + Text(appLocalized: "Open in a new window") + } + } header: { + Text(appLocalized: "Window") + } footer: { + Text(appLocalized: "For browser-like apps, launch a new instance instead of opening a new tab in the running one") + .foregroundColor(.secondary) + } } .formStyle(.grouped) @@ -88,7 +101,8 @@ struct EditAppSheetView: View { id: app.id, itemName: itemName, arguments: arguments.components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }, - environment: parseEnvironmentVariables(environment) + environment: parseEnvironmentVariables(environment), + opensNewInstance: opensNewInstance ) messager.sendRunningNotification() } diff --git a/RClickTests/ActionServiceTests.swift b/RClickTests/ActionServiceTests.swift index 2926dca..aeff1ac 100644 --- a/RClickTests/ActionServiceTests.swift +++ b/RClickTests/ActionServiceTests.swift @@ -7,6 +7,7 @@ // import Foundation +import SwiftData import Testing @testable import RClick @@ -76,4 +77,21 @@ final class ActionServiceTests { // 授权后应执行删除 → 文件被移除 #expect(!FileManager.default.fileExists(atPath: file.path)) } + + @Test func appEntityPersistsOpensNewInstance() throws { + // in-memory 容器,避免触碰真实 App Group 库 + let container = try ModelContainer( + for: AppEntity.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let context = container.mainContext + + var app = OpenWithApp(appURL: URL(fileURLWithPath: "/Applications/Safari.app")) + app.opensNewInstance = true + context.insert(AppEntity(from: app, sortOrder: 0)) + try context.save() + + let fetched = try context.fetch(FetchDescriptor()) + #expect(fetched.first?.opensNewInstance == true) + } } diff --git a/Shared/RCBase.swift b/Shared/RCBase.swift index 9d666bc..b7868f5 100644 --- a/Shared/RCBase.swift +++ b/Shared/RCBase.swift @@ -26,6 +26,8 @@ struct OpenWithApp: @MainActor RCBase { var inheritFromGlobalEnvironment = true var arguments: [String] = [] var environment: [String: String] = [:] + /// 是否以新实例(新窗口)打开(对应 NSWorkspace.OpenConfiguration.createsNewApplicationInstance) + var opensNewInstance = false nonisolated var appName: String { FileManager.default.displayName(atPath: url.path) From a5885efb4b8739105487760b3211177d0942c3a5 Mon Sep 17 00:00:00 2001 From: wflixu Date: Sun, 23 Aug 2026 22:55:35 +0800 Subject: [PATCH 5/5] feat(settings): redesign settings window UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar: fully custom VStack rows (icon-colored rounded squares + larger text) with explicit spacing, no hidden native List insets - Sidebar: window title now follows selected tab via navigationTitle - Sidebar: keep native macOS collapse toggle button - General: remove redundant "Enable RClick" toggle; add Settings… button to Finder Extension status row - Actions / New File: replace oversized switches with compact checkboxes - Actions list: increase row height Co-Authored-By: Claude --- RClick.xcodeproj/project.pbxproj | 4 +- .../xcschemes/FinderSyncExt.xcscheme | 2 +- .../xcshareddata/xcschemes/RClick.xcscheme | 2 +- RClick/Settings/ActionSettingsTabView.swift | 5 +- RClick/Settings/GeneralSettingsTabView.swift | 29 ++--- RClick/Settings/NewFileSettingsTabView.swift | 4 +- RClick/Settings/SettingsView.swift | 104 +++++++++++++----- RClick/Settings/SettingsWindow.swift | 1 + 8 files changed, 93 insertions(+), 58 deletions(-) diff --git a/RClick.xcodeproj/project.pbxproj b/RClick.xcodeproj/project.pbxproj index 5b5c019..5029b17 100644 --- a/RClick.xcodeproj/project.pbxproj +++ b/RClick.xcodeproj/project.pbxproj @@ -294,7 +294,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2640; - LastUpgradeCheck = 2650; + LastUpgradeCheck = 2660; TargetAttributes = { 5C8A28D92F83D65900FE687C = { CreatedOnToolsVersion = 26.4; @@ -432,6 +432,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -498,6 +499,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; diff --git a/RClick.xcodeproj/xcshareddata/xcschemes/FinderSyncExt.xcscheme b/RClick.xcodeproj/xcshareddata/xcschemes/FinderSyncExt.xcscheme index 128ef55..52484b8 100644 --- a/RClick.xcodeproj/xcshareddata/xcschemes/FinderSyncExt.xcscheme +++ b/RClick.xcodeproj/xcshareddata/xcschemes/FinderSyncExt.xcscheme @@ -1,6 +1,6 @@ some View { + let isSelected = selectedTab == tab + return Button { + selectedTab = tab + } label: { + HStack(spacing: 6) { + Image(systemName: tab.icon) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(tab.iconColor) + ) + + Text(appLocalized: tab.rawValue) + .font(.system(size: 14, weight: isSelected ? .semibold : .medium)) + .foregroundStyle(isSelected ? .primary : .secondary) + + Spacer() } - .padding(.horizontal) - .padding(.vertical, 24) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? tab.iconColor.opacity(0.15) : .clear) + ) + .contentShape(Rectangle()) } - .toolbar(removing: .sidebarToggle) + .buttonStyle(.plain) } @ViewBuilder var detailView: some View { @@ -91,7 +136,6 @@ struct SettingsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .frame(minWidth: 450) - .padding() } var body: some View { @@ -100,6 +144,8 @@ struct SettingsView: View { } detail: { self.detailView } + .navigationTitle(AppLocalization.localized(selectedTab.rawValue)) + .background(Color(NSColor.windowBackgroundColor)) } func getAppVersion() -> String { diff --git a/RClick/Settings/SettingsWindow.swift b/RClick/Settings/SettingsWindow.swift index 4d37a11..91736de 100644 --- a/RClick/Settings/SettingsWindow.swift +++ b/RClick/Settings/SettingsWindow.swift @@ -26,6 +26,7 @@ struct SettingsWindow: Scene { UpdateView(updateManager: updateManager) } } + .commandsRemoved() .windowResizability(.contentSize) .defaultSize(width: 700, height: 480) }