diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml index 1b26ffe3..a1000767 100644 --- a/.github/workflows/plugin-release.yml +++ b/.github/workflows/plugin-release.yml @@ -89,19 +89,20 @@ jobs: print(next(iter(versions))) PY )" - if [[ "$PLUGIN_KIT_VERSION" == "2" ]]; then - PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/catalog.json" - PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.1.6" + if (( PLUGIN_KIT_VERSION < 5 )); then + echo "PluginKit versions below 5 use immutable legacy catalogs and cannot be released by the schema-3 workflow." >&2 + exit 1 elif [[ "$PLUGIN_KIT_VERSION" == "5" ]]; then - PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v5/catalog.json" - PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.0" + PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v5/schema3/catalog.json" + PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.1" else PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v${PLUGIN_KIT_VERSION}/catalog.json" - PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.1.6" + PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.1" fi { echo "TAG=$TAG" echo "PLUGIN_RELEASE_MODE=$MODE" + echo "PLUGIN_RELEASE_REQUIRE_VERSION_BUMP=false" echo "PLUGIN_RELEASE_SELECTION=$PLUGIN_SELECTION" echo "PLUGIN_RELEASE_TITLE=MacTools Plugins ${RELEASE_SUFFIX}" echo "PLUGIN_RELEASE_NOTES_URL=https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" @@ -119,6 +120,11 @@ jobs: git fetch --force --tags origin +refs/heads/main:refs/remotes/origin/main if git show "origin/main:${PLUGIN_CATALOG_RELATIVE_PATH}" > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then echo "Using ${PLUGIN_CATALOG_RELATIVE_PATH} from origin/main as the previous production catalog." + elif [[ "$PLUGIN_KIT_VERSION" == "5" ]] && \ + git show origin/main:docs/plugins/v5/catalog.json > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then + echo "Using docs/plugins/v5/catalog.json from origin/main as the same-ABI schema migration baseline." + echo "PLUGIN_RELEASE_MODE=all" >> "$GITHUB_ENV" + echo "PLUGIN_RELEASE_REQUIRE_VERSION_BUMP=true" >> "$GITHUB_ENV" elif PREVIOUS_VERSIONED_CATALOG="$(git ls-tree -r --name-only origin/main docs/plugins | \ python3 -c 'import re,sys; current=int(sys.argv[1]); paths=[path.strip() for path in sys.stdin if re.fullmatch(r"docs/plugins/v\d+/catalog\.json", path.strip())]; candidates=[(int(re.search(r"/v(\d+)/", path).group(1)), path) for path in paths if int(re.search(r"/v(\d+)/", path).group(1)) < current]; print(max(candidates)[1] if candidates else "")' \ "$PLUGIN_KIT_VERSION")" && \ @@ -147,6 +153,9 @@ jobs: if [[ -n "$PLUGIN_RELEASE_SELECTION" ]]; then plan_args+=(--plugins "$PLUGIN_RELEASE_SELECTION") fi + if [[ "$PLUGIN_RELEASE_REQUIRE_VERSION_BUMP" == "true" ]]; then + plan_args+=(--require-version-bump) + fi scripts/plugins/plan-plugin-release.py "${plan_args[@]}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cce14c04..2c898d72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,8 @@ Thanks for your interest in MacTools. Please keep each contribution small and cl - Custom plugin settings views must reuse `MacToolsPluginKit.PluginSettingsTheme` and `.pluginSettingsCardBackground(.standard/.recessed)`. Do not copy private plugin settings styles, and do not make plugins depend on `Sources/App/SettingsStyle.swift`. - Call `onStateChange?()` after plugin state changes. Long-running scans, file system work, and system calls should not block the main thread for extended periods. - User-facing copy is primarily Chinese. Keep it concise, clear, and close to native macOS wording. -- Localize user-facing copy with `.xcstrings`. App/Core copy belongs under `Sources/Resources/Localization`, PluginKit copy under `Sources/MacToolsPluginKit/Resources`, and plugin copy under `Plugins//Resources`. Plugin `plugin.json` files should keep `displayName`/`summary` as fallbacks and add `localizedMetadata` for marketplace and unloaded-plugin presentation. +- Localize user-facing copy with `.xcstrings`. App/Core copy belongs under `Sources/Resources/Localization`, PluginKit copy under `Sources/MacToolsPluginKit/Resources`, and plugin copy under `Plugins//Resources`. Plugin `plugin.json` files should keep `displayName`/`summary` as fallbacks and add `localizedMetadata` for marketplace and unloaded-plugin presentation. Pre-install product, capability, privacy, setup, and relationship metadata belongs in the same `plugin.json`; follow `docs/plugins/plugin-manifest.schema.json`. Declare localized product copy once under the source-only `productStrings` table, using `@displayName`, `@summary`, `@localizable.`, `@standardAction.`, `@standardSetup.requirements.`, or all 11 locale values, and make every localized product field reference `@productStrings.`. Keep referenced screenshots under `MarketplaceAssets/`, and never add a parallel marketplace manifest or machine-local dynamic action entries. +- Keep current `plugin.json` runtime envelopes complete. Generated package manifests must contain expanded localization values and match their source metadata; do not edit package copies independently. Legacy manifests must still include runtime-decodable `capabilities` and `permissions`; omitting newer product fields is supported only for PluginKit versions below 5 through the explicit local-debug compatibility flag and must never be used for release catalog generation. - New plugins should provide localization whenever practical, at minimum for panel copy, settings copy, permission text, and plugin metadata. - Prefer Apple native frameworks. When adding system frameworks, private include paths, or helper executables inside a plugin bundle, declare the smallest necessary differences in the plugin's own `project.yml`. Bundle resource executables that need separate signing should be listed in `plugin.json.package.signPaths`. - Plugins that use private Apple frameworks must load them dynamically at runtime and validate the required classes and selectors. Do not statically link private frameworks, and surface unsupported-system errors instead of crashing. @@ -68,6 +69,7 @@ Thanks for your interest in MacTools. Please keep each contribution small and cl - User-visible behavior changes are reflected in `README.md` or the relevant design documentation. - User-visible app or plugin changes include a concise English changelog fragment in `changes/unreleased/*.md`. - Plugin manifest `capabilities.settings` (`none`, `form`, or `workspace`) matches the runtime `settingsPage` layout. +- Rich manifest static and dynamic action descriptors match the runtime provider/action identity, risk, permissions, external policy, automation eligibility, and parameter portability. - High-risk features cover safety checks, error states, and missing-permission cases. - The PR does not include unrelated formatting, generated files, local configuration, certificates, or release credentials. @@ -84,5 +86,5 @@ Thanks for your interest in MacTools. Please keep each contribution small and cl - If Apple notarization is needed, store credentials first with `xcrun notarytool store-credentials`. - Version numbers default to `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `Configs/AppVersion.xcconfig`. - Local production builds can still use the lower-level script: `./scripts/release-local.sh`; before publishing to GitHub Releases, run `gh auth login`, then `./scripts/release-local.sh --publish`. -- Plugin library releases are triggered by `plugins-*` batch tags through the `Plugin Release` workflow. Within one PluginKit ABI line, plugins with bumped versions are built and uploaded, then merged into that line's catalog. Changes under `Sources/MacToolsPluginKit/` require rebuilding and bumping every plugin so the catalog cannot retain binaries linked against an older shared framework. The standard `make release` flow performs these manifest bumps in the release commit; feature PRs should not pre-bump unrelated plugins. The first release of a new ABI also rebuilds every plugin and writes a versioned catalog. MacTools through 1.1.6 keeps reading the immutable PluginKit v4 catalog at `docs/plugins/v4/catalog.json`; MacTools 1.2 and later use PluginKit v5 at `docs/plugins/v5/catalog.json`. Publish the v5 plugin batch and catalog first, wait for Pages to serve the committed signed catalog, and only then prepare or publish the 1.2 app. The app release helper and final release workflow fail closed unless that deployed catalog exactly matches the committed catalog and has a valid signature. The catalog private key, Developer ID certificate, and GitHub token must come from CI secrets or local environment variables. +- Plugin library releases are triggered by `plugins-*` batch tags through the `Plugin Release` workflow. Within one PluginKit ABI and catalog-schema compatibility line, plugins with bumped versions are built and uploaded, then merged into that line's catalog. Changes under `Sources/MacToolsPluginKit/` require rebuilding and bumping every plugin so the catalog cannot retain binaries linked against an older shared framework. The standard `make release` flow performs these manifest bumps in the release commit; feature PRs should not pre-bump unrelated plugins. The first release of a new ABI or schema line also rebuilds every plugin and writes a separate catalog. MacTools through 1.1.6 keeps reading the immutable PluginKit v4 catalog at `docs/plugins/v4/catalog.json`; MacTools 1.2.0 keeps reading PluginKit v5 schema 2 at `docs/plugins/v5/catalog.json`; schema-3 hosts read `docs/plugins/v5/schema3/catalog.json`. Publish the compatible plugin batch and catalog first, wait for Pages to serve the committed signed catalog, and only then prepare or publish the corresponding app. The app release helper and final release workflow fail closed unless that deployed catalog exactly matches the committed catalog and has a valid signature. The catalog private key, Developer ID certificate, and GitHub token must come from CI secrets or local environment variables. - GitHub Actions build and release configuration is documented in `docs/github-actions.md`; plugin catalog, package structure, and batch release flows are documented in `docs/plugins/plugin-catalog.md`. diff --git a/Makefile b/Makefile index 80759148..ccf0e927 100644 --- a/Makefile +++ b/Makefile @@ -36,8 +36,8 @@ PLUGIN_RELEASE_DIST_DIR ?= build/PluginRelease PLUGIN_RELEASE_ASSETS_DIR ?= $(PLUGIN_RELEASE_DIST_DIR)/Assets PLUGIN_RELEASE_CATALOG ?= $(PLUGIN_RELEASE_DIST_DIR)/catalog.json PLUGIN_KIT_VERSION ?= $(shell $(PYTHON3) -c 'import glob,json; versions={json.load(open(path, encoding="utf-8"))["pluginKitVersion"] for path in glob.glob("Plugins/*/plugin.json")}; print(next(iter(versions)) if len(versions) == 1 else "")') -PLUGIN_RELEASE_SIGNED_CATALOG ?= $(if $(filter 2,$(PLUGIN_KIT_VERSION)),docs/plugins/catalog.json,docs/plugins/v$(PLUGIN_KIT_VERSION)/catalog.json) -PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= $(if $(filter 5,$(PLUGIN_KIT_VERSION)),1.2.0,1.1.6) +PLUGIN_RELEASE_SIGNED_CATALOG ?= $(if $(filter 5,$(PLUGIN_KIT_VERSION)),docs/plugins/v5/schema3/catalog.json,$(if $(filter 2,$(PLUGIN_KIT_VERSION)),docs/plugins/catalog.json,docs/plugins/v$(PLUGIN_KIT_VERSION)/catalog.json)) +PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= 1.2.1 PLUGIN_RELEASE_BASE_URL ?= https://github.com/$(PLUGIN_RELEASE_REPO)/releases/download/$(PLUGIN_RELEASE_TAG) E2E_SCRIPT := scripts/e2e/mactools-e2e.sh E2E_SESSION ?= @@ -128,6 +128,12 @@ generate-icon-gallery: --output-dir "$(LOCAL_ICON_GALLERY_DIR)" package-plugins-release: generate + @case " 1 2 3 4 " in \ + *" $(PLUGIN_KIT_VERSION) "*) \ + echo "PluginKit versions below 5 use immutable legacy catalogs and cannot be released by the schema-3 tooling." >&2; \ + exit 1; \ + ;; \ + esac @./scripts/plugins/build-plugin-release-assets.sh \ --source-dir "$(LOCAL_PLUGIN_SOURCE_DIR)" \ --build-dir "$(PLUGIN_RELEASE_BUILD_DIR)" \ diff --git a/Plugins/ActionGrid/plugin.json b/Plugins/ActionGrid/plugin.json index b30b3dfc..97f8e53c 100644 --- a/Plugins/ActionGrid/plugin.json +++ b/Plugins/ActionGrid/plugin.json @@ -48,6 +48,10 @@ "summary": "在指標附近開啟使用者設定的常用操作網格。" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.0.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,151 @@ "settings": "workspace" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "action", + "grid", + "system" + ], + "localizedSynonyms": { + "ar": [ + "شبكة الإجراءات" + ], + "de": [ + "Aktionsraster" + ], + "en": [ + "Action Grid" + ], + "es": [ + "Cuadrícula de acciones" + ], + "fr": [ + "Grille d’actions" + ], + "ja": [ + "アクショングリッド" + ], + "ko": [ + "동작 그리드" + ], + "pt": [ + "Grade de ações" + ], + "ru": [ + "Сетка действий" + ], + "zh-Hans": [ + "操作网格" + ], + "zh-Hant": [ + "操作網格" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "action-grid", + "kind": "static", + "staticActions": [ + { + "id": "show", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "操作网格", + "显示操作网格", + "action", + "grid", + "launcher", + "show" + ], + "systemImage": "square.grid.3x3", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift b/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift index 7a684665..ead19781 100644 --- a/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift +++ b/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift @@ -1,4 +1,5 @@ import AppKit +import CoreGraphics import SwiftUI import MacToolsPluginKit @@ -23,11 +24,16 @@ private struct ActivityBarPluginProvider: PluginProvider { } @MainActor -final class ActivityBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginComponentPanel, PluginActionProviding { +final class ActivityBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginComponentPanel, + PluginActionProviding, PluginActionPermissionProviding +{ private enum ActionID { static let setTrackingEnabled = "set-tracking-enabled" static let resetToday = "reset-today" } + private enum PermissionID { + static let inputMonitoring = "inputMonitoring" + } private struct SettingsStatus { let text: String @@ -112,6 +118,20 @@ final class ActivityBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginCompone ) } + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.inputMonitoring, + kind: .inputMonitoring, + title: localization.string("settings.inputMonitoring.title", defaultValue: "输入监控"), + description: localization.string( + "settings.inputMonitoring.description", + defaultValue: "用于统计键盘、鼠标点击和滚动事件。" + ) + ), + ] + } + var settingsPage: PluginSettingsPage? { .form( description: metadata.defaultDescription, @@ -274,10 +294,25 @@ final class ActivityBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginCompone } func permissionState(for permissionID: String) -> PluginPermissionState { - PluginPermissionState(isGranted: true, footnote: nil) + guard permissionID == PermissionID.inputMonitoring else { + return PluginPermissionState(isGranted: true, footnote: nil) + } + return PluginPermissionState( + isGranted: CGPreflightListenEventAccess(), + footnote: controller.inputMonitoringFootnote + ) } - func handlePermissionAction(id: String) {} + func handlePermissionAction(id: String) { + guard id == PermissionID.inputMonitoring else { return } + controller.openInputMonitoringSettings() + } + + func permissionRequirementIDs(for actionKey: ActionKey) -> [String] { + guard actionKey.providerID == metadata.id, + actionKey.actionID == ActionID.setTrackingEnabled else { return [] } + return [PermissionID.inputMonitoring] + } func handleSettingsAction(_ action: PluginSettingsAction) { guard case let .invoke(controlID) = action else { return } diff --git a/Plugins/ActivityBar/plugin.json b/Plugins/ActivityBar/plugin.json index 43a953f6..4d891ca1 100644 --- a/Plugins/ActivityBar/plugin.json +++ b/Plugins/ActivityBar/plugin.json @@ -48,6 +48,15 @@ "summary": "統計輸入、前臺應用使用時長和 AI 編程活動" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.set-tracking-enabled.title": "@standardAction.set-enabled.title", + "action.set-tracking-enabled.description": "@standardAction.set-enabled.description", + "action.set-tracking-enabled.parameter-summary": "@standardAction.set-enabled.description", + "action.reset-today.title": "@localizable.panel.action.resetToday", + "action.reset-today.description": "@localizable.panel.action.resetToday" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +71,203 @@ "componentPanel": true, "settings": "form" }, - "permissions": [], - "category": "monitoring" + "permissions": [ + "inputMonitoring" + ], + "category": "monitoring", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "activity", + "bar", + "stats", + "monitoring" + ], + "localizedSynonyms": { + "ar": [ + "إحصاءات النشاط" + ], + "de": [ + "Aktivitätsstatistik" + ], + "en": [ + "Activity Stats" + ], + "es": [ + "Estadísticas de actividad" + ], + "fr": [ + "Statistiques d’activité" + ], + "ja": [ + "アクティビティ統計" + ], + "ko": [ + "활동 통계" + ], + "pt": [ + "Estatísticas de atividade" + ], + "ru": [ + "Статистика активности" + ], + "zh-Hans": [ + "活动统计" + ], + "zh-Hant": [ + "活動統計" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "monitoring" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "inputMonitoring" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "foreground-application", + "input-events", + "usage-statistics", + "ai-prompt-content", + "project-working-directories" + ], + "dataPersisted": [ + "plugin-configuration", + "usage-statistics", + "coding-session-statistics" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "activity-bar", + "kind": "static", + "staticActions": [ + { + "id": "set-tracking-enabled", + "title": "@productStrings.action.set-tracking-enabled.title", + "description": "@productStrings.action.set-tracking-enabled.description", + "keywords": [ + "活动统计", + "统计输入、前台应用使用时长和 AI 编程活动", + "activity", + "bar", + "set", + "tracking", + "enabled" + ], + "systemImage": "chart.bar.xaxis", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-tracking-enabled.parameter-summary", + "permissionIDs": [ + "inputMonitoring" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "reset-today", + "title": "@productStrings.action.reset-today.title", + "description": "@productStrings.action.reset-today.description", + "keywords": [ + "活动统计", + "清空今日统计", + "activity", + "bar", + "reset", + "today" + ], + "systemImage": "arrow.counterclockwise", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/AppHotkey/plugin.json b/Plugins/AppHotkey/plugin.json index 1e246279..149a892f 100644 --- a/Plugins/AppHotkey/plugin.json +++ b/Plugins/AppHotkey/plugin.json @@ -48,6 +48,13 @@ "summary": "為常用應用綁定全局快速鍵,快速打開或切換到前臺" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.launch.title": "@localizable.action.launch.title", + "action.launch.description": "@localizable.action.launch.description", + "action.launch.parameter-summary": "@localizable.action.launch.app" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +70,162 @@ "settings": "form" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "app", + "hotkey", + "hotkeys", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "اختصارات التطبيقات" + ], + "de": [ + "App-Kurzbefehle" + ], + "en": [ + "App Hotkeys" + ], + "es": [ + "Atajos de apps" + ], + "fr": [ + "Raccourcis d’apps" + ], + "ja": [ + "アプリショートカット" + ], + "ko": [ + "앱 단축키" + ], + "pt": [ + "Atalhos de apps" + ], + "ru": [ + "Горячие клавиши приложений" + ], + "zh-Hans": [ + "应用快捷键" + ], + "zh-Hant": [ + "應用程式快速鍵" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "installed-applications", + "foreground-application" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "app-hotkey", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "launch", + "title": "@productStrings.action.launch.title", + "description": "@productStrings.action.launch.description", + "entrySource": "installed-applications", + "parameters": [ + { + "id": "entryID", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + } + ], + "parameterSummary": "@productStrings.action.launch.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "app", + "hotkey", + "launch", + "installed", + "applications" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ] + } + ] + } } diff --git a/Plugins/AppVolume/Tests/AppVolumePluginTests.swift b/Plugins/AppVolume/Tests/AppVolumePluginTests.swift index 9f263ed6..431d3f7b 100644 --- a/Plugins/AppVolume/Tests/AppVolumePluginTests.swift +++ b/Plugins/AppVolume/Tests/AppVolumePluginTests.swift @@ -6,6 +6,16 @@ import MacToolsPluginKit @MainActor final class AppVolumePluginTests: XCTestCase { + func testManifestDynamicActionMatchesRuntimePolicy() throws { + let plugin = makePlugin() + + try PluginManifestActionAssertions.assertConsistency( + pluginDirectoryName: "AppVolume", + definitions: plugin.actionDefinitions, + permissionIDs: plugin.permissionRequirementIDs(for:) + ) + } + func testMetadataAndPermissionRequirement() { let plugin = makePlugin() diff --git a/Plugins/AppVolume/plugin.json b/Plugins/AppVolume/plugin.json index eee12ee0..b8ef5656 100644 --- a/Plugins/AppVolume/plugin.json +++ b/Plugins/AppVolume/plugin.json @@ -48,6 +48,112 @@ "summary": "分別調整正在播放音訊的應用程式音量" } }, + "productStrings": { + "long-description": { + "ar": "تحكّم في مستوى صوت كل تطبيق يشغّل الصوت على حدة، بما في ذلك الكتم ومستويات الصوت القابلة لإعادة الاستخدام.", + "de": "Steuere die Lautstärke jeder aktiven Audio-App einzeln, einschließlich Stummschaltung und wiederverwendbarer Pegel.", + "en": "Control each audio-playing app independently, including mute and reusable volume levels.", + "es": "Controla por separado cada aplicación que reproduce audio, incluido el silencio y los niveles reutilizables.", + "fr": "Contrôlez séparément chaque app qui diffuse du son, avec sourdine et niveaux réutilisables.", + "ja": "音声を再生中のアプリごとに、ミュートや再利用可能な音量を個別に調整します。", + "ko": "오디오를 재생 중인 앱마다 음소거 및 재사용 가능한 음량을 개별 제어합니다.", + "pt": "Controle separadamente cada app que reproduz áudio, incluindo silêncio e níveis reutilizáveis.", + "ru": "Управляйте громкостью каждого приложения со звуком отдельно, включая отключение и повторное использование уровней.", + "zh-Hans": "分别控制每个正在播放音频的应用,包括静音和可复用的音量级别。", + "zh-Hant": "分別控制每個正在播放音訊的 App,包括靜音和可重複使用的音量級別。" + }, + "example-meeting-volume": { + "ar": "اخفض صوت تطبيق الموسيقى مع إبقاء تطبيق الاجتماع مرتفعًا.", + "de": "Senke die Musik-App ab, während die Meeting-App laut bleibt.", + "en": "Lower a music app while keeping your meeting app loud.", + "es": "Baja una aplicación de música y mantén alto el volumen de la reunión.", + "fr": "Baissez une app musicale tout en gardant l’app de réunion audible.", + "ja": "会議アプリの音量を保ったまま、音楽アプリだけ下げます。", + "ko": "회의 앱 음량은 유지하면서 음악 앱 음량만 낮춥니다.", + "pt": "Baixe um app de música mantendo o app da reunião audível.", + "ru": "Уменьшите громкость музыки, сохранив громкость приложения для встречи.", + "zh-Hans": "调低音乐应用,同时保持会议应用音量。", + "zh-Hant": "調低音樂 App,同時保持會議 App 音量。" + }, + "use-case-balance-app-audio": { + "ar": "موازنة صوت التطبيقات", + "de": "App-Audio ausgleichen", + "en": "Balance app audio", + "es": "Equilibrar el audio de aplicaciones", + "fr": "Équilibrer le son des apps", + "ja": "アプリ音声のバランス調整", + "ko": "앱 오디오 균형 조절", + "pt": "Equilibrar áudio dos apps", + "ru": "Сбалансировать звук приложений", + "zh-Hans": "平衡应用音量", + "zh-Hant": "平衡 App 音量" + }, + "template-app-volume-set-volume-title": { + "ar": "تعيين مستوى صوت تطبيق نشط", + "de": "Lautstärke einer aktiven App festlegen", + "en": "Set Active App Volume", + "es": "Establecer volumen de una aplicación activa", + "fr": "Régler le volume d’une app active", + "ja": "再生中アプリの音量を設定", + "ko": "활성 앱 음량 설정", + "pt": "Definir volume de um app ativo", + "ru": "Установить громкость активного приложения", + "zh-Hans": "设置活动应用音量", + "zh-Hant": "設定活動 App 音量" + }, + "template-app-volume-set-volume-description": { + "ar": "يضبط مستوى صوت تطبيق يشغّل الصوت حاليًا على هذا الـ Mac.", + "de": "Legt die Lautstärke einer App fest, die gerade auf diesem Mac Audio wiedergibt.", + "en": "Set the volume of an app currently playing audio on this Mac.", + "es": "Ajusta el volumen de una aplicación que reproduce audio en este Mac.", + "fr": "Règle le volume d’une app qui diffuse actuellement du son sur ce Mac.", + "ja": "この Mac で現在音声を再生しているアプリの音量を設定します。", + "ko": "이 Mac에서 현재 오디오를 재생 중인 앱의 음량을 설정합니다.", + "pt": "Define o volume de um app que está a reproduzir áudio neste Mac.", + "ru": "Задаёт громкость приложения, которое сейчас воспроизводит звук на этом Mac.", + "zh-Hans": "设置当前在这台 Mac 上播放音频的应用音量。", + "zh-Hant": "設定目前在這台 Mac 上播放音訊的 App 音量。" + }, + "template-app-volume-set-volume-parameter-summary": { + "ar": "معرّف تطبيق محلي ومستوى صوت من 0 إلى 1", + "de": "Lokale App-ID und Lautstärke von 0 bis 1", + "en": "Local app identifier and volume from 0 to 1", + "es": "Identificador local de aplicación y volumen de 0 a 1", + "fr": "Identifiant local de l’app et volume de 0 à 1", + "ja": "ローカルアプリ識別子と 0〜1 の音量", + "ko": "로컬 앱 식별자 및 0~1 음량", + "pt": "Identificador local do app e volume de 0 a 1", + "ru": "Локальный идентификатор приложения и громкость от 0 до 1", + "zh-Hans": "本地应用标识符和 0 到 1 的音量", + "zh-Hant": "本機 App 識別碼和 0 到 1 的音量" + }, + "setup-grant-system-audio-title": { + "ar": "السماح بصوت النظام", + "de": "Systemaudio erlauben", + "en": "Allow System Audio", + "es": "Permitir audio del sistema", + "fr": "Autoriser l’audio système", + "ja": "システムオーディオを許可", + "ko": "시스템 오디오 허용", + "pt": "Permitir áudio do sistema", + "ru": "Разрешить системный звук", + "zh-Hans": "允许系统音频", + "zh-Hant": "允許系統音訊" + }, + "setup-grant-system-audio-description": { + "ar": "امنح الإذن عند أول تعديل لمستوى الصوت.", + "de": "Erteile die Berechtigung bei der ersten Lautstärkeänderung.", + "en": "Grant permission when changing an app volume for the first time.", + "es": "Concede permiso al cambiar por primera vez el volumen de una aplicación.", + "fr": "Accordez l’autorisation au premier changement de volume d’une app.", + "ja": "初めてアプリ音量を変更するときに許可します。", + "ko": "앱 음량을 처음 변경할 때 권한을 허용합니다.", + "pt": "Conceda permissão ao alterar o volume de um app pela primeira vez.", + "ru": "Предоставьте разрешение при первом изменении громкости приложения.", + "zh-Hans": "首次更改应用音量时授予权限。", + "zh-Hant": "首次更改 App 音量時授予權限。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,7 +169,202 @@ "settings": "form" }, "permissions": [ - "screen-recording" + "system-audio-recording" ], - "category": "audio" + "category": "audio", + "presentation": { + "longDescription": "@productStrings.long-description", + "examples": [ + { + "id": "meeting-volume", + "text": "@productStrings.example-meeting-volume" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "application volume", + "audio", + "mute", + "mixer" + ], + "localizedSynonyms": { + "ar": [ + "صوت التطبيقات", + "خالط الصوت" + ], + "de": [ + "App-Lautstärke", + "Audiomixer" + ], + "en": [ + "app volume", + "audio mixer" + ], + "es": [ + "volumen por aplicación", + "mezclador" + ], + "fr": [ + "volume des apps", + "mélangeur audio" + ], + "ja": [ + "アプリ音量", + "オーディオミキサー" + ], + "ko": [ + "앱 음량", + "오디오 믹서" + ], + "pt": [ + "volume por app", + "misturador de áudio" + ], + "ru": [ + "громкость приложений", + "аудиомикшер" + ], + "zh-Hans": [ + "应用音量", + "音频混音" + ], + "zh-Hant": [ + "App 音量", + "音訊混音" + ] + }, + "useCases": [ + { + "id": "balance-app-audio", + "title": "@productStrings.use-case-balance-app-audio" + } + ], + "goalCategories": [ + "audio", + "focus", + "automation" + ], + "relatedPluginIDs": [ + "system-mute" + ], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "15.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "audio-output" + ], + "applications": [], + "executables": [], + "permissionIDs": [ + "system-audio-recording" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "active-audio-applications", + "application-audio-levels" + ], + "dataPersisted": [ + "application-identifiers", + "preferred-volume-levels" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "actions": { + "providers": [ + { + "id": "app-volume", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "set-volume", + "title": "@productStrings.template-app-volume-set-volume-title", + "description": "@productStrings.template-app-volume-set-volume-description", + "entrySource": "active-audio-applications", + "keywords": [ + "application volume", + "audio", + "mute" + ], + "parameters": [ + { + "id": "application", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + }, + { + "id": "volume", + "kind": "double", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.template-app-volume-set-volume-parameter-summary", + "localOnlyIdentity": true, + "permissionIDs": [ + "system-audio-recording" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ] + } + ] + }, + "setup": { + "steps": [ + { + "id": "grant-system-audio", + "title": "@productStrings.setup-grant-system-audio-title", + "description": "@productStrings.setup-grant-system-audio-description" + } + ], + "optionalSurfaces": [ + "global-shortcut", + "workflow", + "automatic-rule", + "action-grid" + ] + }, + "relationships": { + "relatedPluginIDs": [ + "system-mute" + ], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/Appearance/Resources/Localizable.xcstrings b/Plugins/Appearance/Resources/Localizable.xcstrings index 0db536ac..c6dc0ec9 100644 --- a/Plugins/Appearance/Resources/Localizable.xcstrings +++ b/Plugins/Appearance/Resources/Localizable.xcstrings @@ -442,6 +442,289 @@ } } } + }, + "permission.automation.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب تبديل مظهر النظام التحكم في أحداث النظام." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Wechseln des System-Erscheinungsbilds muss „System Events“ gesteuert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Switching system appearance requires controlling System Events." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar la apariencia del sistema requiere controlar Eventos del Sistema." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Changer l’apparence du système nécessite de contrôler Événements système." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムの外観を切り替えるには、「システムイベント」の制御が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 모드를 전환하려면 ‘시스템 이벤트’를 제어해야 합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alterar a aparência do sistema requer o controle dos Eventos do Sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для переключения оформления системы требуется управление приложением «Системные события»." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换系统外观时需要控制系统事件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換系統外觀時需要控制「系統事件」。" + } + } + } + }, + "permission.automation.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيطلب macOS إذن الأتمتة عند الاستخدام للمرة الأولى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS fragt bei der ersten Verwendung nach der Automation-Berechtigung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS will request Automation access the first time this is used." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "macOS solicitará acceso de automatización la primera vez que se use." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "macOS demandera l’autorisation d’automatisation lors de la première utilisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "初回使用時に、macOSがオートメーションの許可を求めます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "처음 사용할 때 macOS가 자동화 권한을 요청합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O macOS solicitará acesso à Automação na primeira utilização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При первом использовании macOS запросит доступ к автоматизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "macOS 会在首次使用时请求自动化授权。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "macOS 會在首次使用時要求自動化授權。" + } + } + } + }, + "permission.automation.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأتمتة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オートメーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Automação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматизация" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化" + } + } + } + }, + "permission.automation.status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد عند الاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigung bei Verwendung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm on Use" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar al usar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmation à l’utilisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用時に確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 시 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ao usar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтверждение при использовании" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按需确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按需確認" + } + } + } } }, "version": "1.0" diff --git a/Plugins/Appearance/Sources/AppearancePlugin.swift b/Plugins/Appearance/Sources/AppearancePlugin.swift index 7e1ab9b3..89aed7fa 100644 --- a/Plugins/Appearance/Sources/AppearancePlugin.swift +++ b/Plugins/Appearance/Sources/AppearancePlugin.swift @@ -20,11 +20,16 @@ private struct AppearancePluginProvider: PluginProvider { } @MainActor -final class AppearancePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionProviding { +final class AppearancePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionProviding, + PluginActionPermissionProviding +{ private enum ActionID { static let setEnabled = "set-enabled" static let toggle = "toggle" } + private enum PermissionID { + static let automation = "automation" + } let metadata: PluginMetadata let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( @@ -78,7 +83,19 @@ final class AppearancePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionPr ) } - var permissionRequirements: [PluginPermissionRequirement] { [] } + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.automation, + kind: .automation, + title: localization.string("permission.automation.title", defaultValue: "自动化"), + description: localization.string( + "permission.automation.description", + defaultValue: "切换系统外观时需要控制系统事件。" + ) + ), + ] + } var shortcutDefinitions: [PluginShortcutDefinition] { [] } var actionDefinitions: [ActionDefinition] { @@ -147,10 +164,30 @@ final class AppearancePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionPr } func permissionState(for permissionID: String) -> PluginPermissionState { - PluginPermissionState(isGranted: true, footnote: nil) + PluginPermissionState( + isGranted: false, + footnote: permissionID == PermissionID.automation + ? localization.string( + "permission.automation.footnote", + defaultValue: "macOS 会在首次使用时请求自动化授权。" + ) + : nil, + statusText: permissionID == PermissionID.automation + ? localization.string("permission.automation.status", defaultValue: "按需确认") + : nil, + statusSystemImage: permissionID == PermissionID.automation ? "cursorarrow.click.2" : nil, + statusTone: permissionID == PermissionID.automation ? .neutral : nil + ) } - func handlePermissionAction(id: String) {} + func handlePermissionAction(id: String) { + guard id == PermissionID.automation else { return } + requestPermissionGuidance?(PermissionID.automation) + } + func permissionRequirementIDs(for actionKey: ActionKey) -> [String] { + guard actionKey.providerID == metadata.id else { return [] } + return [PermissionID.automation] + } func handleSettingsAction(_ action: PluginSettingsAction) {} func handleShortcutAction(id: String) {} diff --git a/Plugins/Appearance/Tests/AppearancePluginTests.swift b/Plugins/Appearance/Tests/AppearancePluginTests.swift index ff0e2a16..b1b1cced 100644 --- a/Plugins/Appearance/Tests/AppearancePluginTests.swift +++ b/Plugins/Appearance/Tests/AppearancePluginTests.swift @@ -4,6 +4,16 @@ import MacToolsPluginKit @MainActor final class AppearancePluginTests: XCTestCase { + func testManifestActionsMatchRuntimePolicy() throws { + let plugin = AppearancePlugin() + + try PluginManifestActionAssertions.assertConsistency( + pluginDirectoryName: "Appearance", + definitions: plugin.actionDefinitions, + permissionIDs: plugin.permissionRequirementIDs(for:) + ) + } + func testPublishesIdempotentLightAndDarkActions() { let plugin = AppearancePlugin() @@ -24,4 +34,12 @@ final class AppearancePluginTests: XCTestCase { .allowed ) } + + func testAutomationPermissionIsReportedAsOnDemand() { + let state = AppearancePlugin().permissionState(for: "automation") + + XCTAssertFalse(state.isGranted) + XCTAssertEqual(state.statusText, "按需确认") + XCTAssertEqual(state.statusTone, .neutral) + } } diff --git a/Plugins/Appearance/plugin.json b/Plugins/Appearance/plugin.json index be9cbeb3..a992fe20 100644 --- a/Plugins/Appearance/plugin.json +++ b/Plugins/Appearance/plugin.json @@ -48,6 +48,112 @@ "summary": "切換系統亮色與深色外觀" } }, + "productStrings": { + "long-description": { + "ar": "بدّل مظهر macOS بين الوضعين الفاتح والداكن من أي سطح إجراءات في MacTools.", + "de": "Wechsle die macOS-Darstellung über jede MacTools-Aktionsoberfläche zwischen Hell und Dunkel.", + "en": "Switch macOS between light and dark appearance from any MacTools action surface.", + "es": "Cambia macOS entre la apariencia clara y oscura desde cualquier superficie de acciones de MacTools.", + "fr": "Basculez macOS entre les apparences claire et sombre depuis toute surface d’action MacTools.", + "ja": "MacTools の各アクション画面から macOS のライト/ダーク外観を切り替えます。", + "ko": "MacTools의 모든 동작 화면에서 macOS의 라이트 및 다크 모드를 전환합니다.", + "pt": "Alterne o macOS entre os modos claro e escuro em qualquer superfície de ações do MacTools.", + "ru": "Переключайте светлое и тёмное оформление macOS с любой поверхности действий MacTools.", + "zh-Hans": "从 MacTools 的任意操作界面切换 macOS 的浅色与深色外观。", + "zh-Hant": "從 MacTools 的任意操作介面切換 macOS 的淺色與深色外觀。" + }, + "example-evening-workflow": { + "ar": "شغّل الوضع الداكن تلقائيًا عند بدء روتين المساء.", + "de": "Aktiviere den Dunkelmodus automatisch, wenn deine Abendroutine beginnt.", + "en": "Turn on Dark Mode automatically when your evening routine begins.", + "es": "Activa el modo oscuro automáticamente al comenzar tu rutina nocturna.", + "fr": "Activez automatiquement le mode sombre au début de votre routine du soir.", + "ja": "夜のルーティン開始時にダークモードを自動で有効にします。", + "ko": "저녁 루틴이 시작되면 다크 모드를 자동으로 켭니다.", + "pt": "Ative o modo escuro automaticamente quando começar a rotina noturna.", + "ru": "Автоматически включайте тёмный режим в начале вечернего сценария.", + "zh-Hans": "在晚间流程开始时自动开启深色模式。", + "zh-Hant": "在晚間流程開始時自動開啟深色模式。" + }, + "use-case-switch-system-appearance": { + "ar": "تبديل مظهر النظام", + "de": "Systemdarstellung wechseln", + "en": "Switch system appearance", + "es": "Cambiar la apariencia del sistema", + "fr": "Changer l’apparence du système", + "ja": "システム外観を切り替える", + "ko": "시스템 화면 모드 전환", + "pt": "Alternar a aparência do sistema", + "ru": "Переключить оформление системы", + "zh-Hans": "切换系统外观", + "zh-Hant": "切換系統外觀" + }, + "action-appearance-toggle-title": { + "ar": "تبديل المظهر", + "de": "Darstellung wechseln", + "en": "Toggle Appearance", + "es": "Alternar apariencia", + "fr": "Basculer l’apparence", + "ja": "外観を切り替える", + "ko": "화면 모드 전환", + "pt": "Alternar aparência", + "ru": "Переключить оформление", + "zh-Hans": "切换外观", + "zh-Hant": "切換外觀" + }, + "action-appearance-toggle-description": { + "ar": "التبديل بين المظهر الفاتح والداكن.", + "de": "Zwischen heller und dunkler Darstellung wechseln.", + "en": "Switch between light and dark appearance.", + "es": "Cambia entre la apariencia clara y oscura.", + "fr": "Bascule entre les apparences claire et sombre.", + "ja": "ライトとダークの外観を切り替えます。", + "ko": "라이트 및 다크 모드를 전환합니다.", + "pt": "Alterne entre os modos claro e escuro.", + "ru": "Переключает светлое и тёмное оформление.", + "zh-Hans": "在浅色与深色外观之间切换。", + "zh-Hant": "在淺色與深色外觀之間切換。" + }, + "action-appearance-set-enabled-title": { + "ar": "تعيين الوضع الداكن", + "de": "Dunkelmodus festlegen", + "en": "Set Dark Mode", + "es": "Establecer modo oscuro", + "fr": "Définir le mode sombre", + "ja": "ダークモードを設定", + "ko": "다크 모드 설정", + "pt": "Definir modo escuro", + "ru": "Установить тёмный режим", + "zh-Hans": "设置深色模式", + "zh-Hant": "設定深色模式" + }, + "action-appearance-set-enabled-description": { + "ar": "تشغيل الوضع الداكن أو إيقافه صراحةً.", + "de": "Dunkelmodus ausdrücklich ein- oder ausschalten.", + "en": "Explicitly turn Dark Mode on or off.", + "es": "Activa o desactiva explícitamente el modo oscuro.", + "fr": "Active ou désactive explicitement le mode sombre.", + "ja": "ダークモードを明示的にオンまたはオフにします。", + "ko": "다크 모드를 명시적으로 켜거나 끕니다.", + "pt": "Ative ou desative explicitamente o modo escuro.", + "ru": "Явно включает или выключает тёмный режим.", + "zh-Hans": "明确开启或关闭深色模式。", + "zh-Hant": "明確開啟或關閉深色模式。" + }, + "action-appearance-set-enabled-parameter-summary": { + "ar": "ما إذا كان الوضع الداكن مفعّلًا", + "de": "Ob der Dunkelmodus aktiviert ist", + "en": "Whether Dark Mode is enabled", + "es": "Si el modo oscuro está activado", + "fr": "Indique si le mode sombre est activé", + "ja": "ダークモードを有効にするかどうか", + "ko": "다크 모드 활성화 여부", + "pt": "Se o modo escuro está ativo", + "ru": "Включён ли тёмный режим", + "zh-Hans": "是否开启深色模式", + "zh-Hant": "是否開啟深色模式" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +168,217 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], - "category": "display" + "permissions": [ + "automation" + ], + "category": "display", + "presentation": { + "longDescription": "@productStrings.long-description", + "examples": [ + { + "id": "evening-workflow", + "text": "@productStrings.example-evening-workflow" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "appearance", + "dark mode", + "light mode", + "theme" + ], + "localizedSynonyms": { + "ar": [ + "المظهر", + "الوضع الداكن" + ], + "de": [ + "Darstellung", + "Dunkelmodus" + ], + "en": [ + "appearance", + "dark mode" + ], + "es": [ + "apariencia", + "modo oscuro" + ], + "fr": [ + "apparence", + "mode sombre" + ], + "ja": [ + "外観", + "ダークモード" + ], + "ko": [ + "화면 모드", + "다크 모드" + ], + "pt": [ + "aparência", + "modo escuro" + ], + "ru": [ + "оформление", + "тёмный режим" + ], + "zh-Hans": [ + "外观", + "深色模式" + ], + "zh-Hant": [ + "外觀", + "深色模式" + ] + }, + "useCases": [ + { + "id": "switch-system-appearance", + "title": "@productStrings.use-case-switch-system-appearance" + } + ], + "goalCategories": [ + "personalization", + "automation" + ], + "relatedPluginIDs": [ + "night-shift" + ], + "alternativePluginIDs": [] + }, + "requirements": { + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "automation" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-appearance" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "actions": { + "providers": [ + { + "id": "appearance", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action-appearance-toggle-title", + "description": "@productStrings.action-appearance-toggle-description", + "keywords": [ + "appearance", + "toggle", + "theme" + ], + "systemImage": "circle.lefthalf.filled", + "parameters": [], + "permissionIDs": [ + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action-appearance-set-enabled-title", + "description": "@productStrings.action-appearance-set-enabled-description", + "keywords": [ + "appearance", + "dark mode", + "set" + ], + "systemImage": "circle.lefthalf.filled", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action-appearance-set-enabled-parameter-summary", + "permissionIDs": [ + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + }, + "setup": { + "steps": [], + "suggestedTestAction": { + "providerID": "appearance", + "actionID": "toggle" + }, + "optionalSurfaces": [ + "global-shortcut", + "workflow", + "automatic-rule", + "action-grid" + ] + }, + "relationships": { + "relatedPluginIDs": [ + "night-shift" + ], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/AppleShortcuts/Resources/Localizable.xcstrings b/Plugins/AppleShortcuts/Resources/Localizable.xcstrings index bc3b5536..b5aff4c0 100644 --- a/Plugins/AppleShortcuts/Resources/Localizable.xcstrings +++ b/Plugins/AppleShortcuts/Resources/Localizable.xcstrings @@ -2323,6 +2323,172 @@ } } } + }, + "permission.automation.description": { + "extractionState": "manual", + "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "يتطلب تحميل أيقونات الاختصارات التحكم في تطبيق الاختصارات من Apple." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Zum Laden von Kurzbefehlssymbolen muss Apple Kurzbefehle gesteuert werden." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Para cargar los iconos de los atajos es necesario controlar Atajos de Apple." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Le chargement des icônes de raccourcis nécessite de contrôler Raccourcis d’Apple." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ショートカットのアイコンを読み込むには、Appleの「ショートカット」の制御が必要です。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "단축어 아이콘을 불러오려면 Apple 단축어 앱을 제어해야 합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Carregar ícones de atalhos requer o controlo da app Atalhos da Apple." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Для загрузки значков команд требуется управление приложением Apple «Команды»." } }, + + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading shortcut icons requires controlling Apple Shortcuts." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取快捷指令图标时需要控制 Apple“快捷指令”。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取捷徑圖示時需要控制 Apple「捷徑」。" + } + } + } + }, + "permission.automation.footnote": { + "extractionState": "manual", + "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "سيطلب macOS إذن الأتمتة عند تحميل أيقونات الاختصارات لأول مرة." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "macOS fragt beim ersten Laden von Kurzbefehlssymbolen nach der Automation-Berechtigung." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "macOS solicitará acceso de automatización la primera vez que se carguen los iconos de los atajos." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "macOS demandera l’accès à l’automatisation lors du premier chargement des icônes de raccourcis." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ショートカットのアイコンを初めて読み込むときに、macOS がオートメーションの許可を求めます。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "단축어 아이콘을 처음 불러올 때 macOS가 자동화 권한을 요청합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "O macOS pedirá acesso à Automação quando os ícones de atalhos forem carregados pela primeira vez." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "При первой загрузке значков команд macOS запросит доступ к автоматизации." } }, + + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS will request Automation access the first time shortcut icons are loaded." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "macOS 会在首次读取快捷指令图标时请求自动化授权。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "macOS 會在首次讀取捷徑圖示時要求自動化授權。" + } + } + } + }, + "permission.automation.title": { + "extractionState": "manual", + "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الأتمتة" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Automation" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Automatización" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Automatisation" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "オートメーション" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "자동화" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Automação" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Автоматизация" } }, + + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化" + } + } + } + }, + "permission.automation.status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد عند الاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigung bei Verwendung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm on Use" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar al usar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmation à l’utilisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用時に確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 시 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ao usar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтверждение при использовании" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按需确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按需確認" + } + } + } } }, "version": "1.0" diff --git a/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift b/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift index e8147b48..8ad2c314 100644 --- a/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift +++ b/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift @@ -31,6 +31,10 @@ final class AppleShortcutsPlugin: PluginActionExposureProviding, PluginSettingsSearchFocusing { + private enum PermissionID { + static let automation = "automation" + } + let metadata: PluginMetadata let store: AppleShortcutsStore let controller: AppleShortcutsController @@ -108,6 +112,42 @@ final class AppleShortcutsPlugin: } } + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.automation, + kind: .automation, + title: localization.string("permission.automation.title", defaultValue: "自动化"), + description: localization.string( + "permission.automation.description", + defaultValue: "读取快捷指令图标时需要控制 Apple“快捷指令”。" + ) + ), + ] + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState( + isGranted: false, + footnote: permissionID == PermissionID.automation + ? localization.string( + "permission.automation.footnote", + defaultValue: "macOS 会在首次读取快捷指令图标时请求自动化授权。" + ) + : nil, + statusText: permissionID == PermissionID.automation + ? localization.string("permission.automation.status", defaultValue: "按需确认") + : nil, + statusSystemImage: permissionID == PermissionID.automation ? "cursorarrow.click.2" : nil, + statusTone: permissionID == PermissionID.automation ? .neutral : nil + ) + } + + func handlePermissionAction(id: String) { + guard id == PermissionID.automation else { return } + requestPermissionGuidance?(PermissionID.automation) + } + func focusSettingsSearch() { settingsSearchFocusController.requestFocus() } diff --git a/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift b/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift index e38b22dd..639f4b76 100644 --- a/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift +++ b/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift @@ -5,6 +5,16 @@ import XCTest @MainActor final class AppleShortcutsPluginTests: XCTestCase { + func testPublishesOptionalAutomationRequirement() { + let plugin = makePlugin(runner: AppleShortcutsRunnerStub(shortcuts: [])) + + XCTAssertEqual(plugin.permissionRequirements.map(\.id), ["automation"]) + let state = plugin.permissionState(for: "automation") + XCTAssertFalse(state.isGranted) + XCTAssertEqual(state.statusText, "按需确认") + XCTAssertEqual(state.statusTone, .neutral) + } + func testPublishesEveryDiscoveredShortcutAcrossRename() async throws { let id = UUID() let runner = AppleShortcutsRunnerStub(shortcuts: [AppleShortcutItem(id: id, name: "Old")]) @@ -63,14 +73,26 @@ final class AppleShortcutsPluginTests: XCTestCase { await plugin.controller.performRefresh() var definition = try XCTUnwrap(plugin.actionDefinitions.first) + let template = try PluginManifestActionAssertions.dynamicTemplate( + pluginDirectoryName: "AppleShortcuts", + id: "run-shortcut" + ) + XCTAssertEqual(template["riskVariesByEntry"] as? Bool, true) + XCTAssertNil(template["automaticEligibilityVariesByEntry"]) + XCTAssertEqual(template["automaticEligible"] as? Bool, false) + let surfaces = Set(template["surfaces"] as? [String] ?? []) + XCTAssertFalse(surfaces.contains("automatic-rule")) + XCTAssertFalse(surfaces.contains("app-intent")) XCTAssertEqual(definition.risk, .confirmationRequired) XCTAssertEqual(definition.externalInvocationPolicy, .confirmAlways) + XCTAssertFalse(definition.capabilities.contains(.automatic)) XCTAssertNotNil(definition.confirmation) try plugin.store.setRequiresConfirmation(false, for: id).get() definition = try XCTUnwrap(plugin.actionDefinitions.first) XCTAssertEqual(definition.risk, .safe) XCTAssertEqual(definition.externalInvocationPolicy, .confirmAlways) + XCTAssertFalse(definition.capabilities.contains(.automatic)) XCTAssertNotNil(definition.confirmation) } diff --git a/Plugins/AppleShortcuts/plugin.json b/Plugins/AppleShortcuts/plugin.json index ffb837db..e981a38e 100644 --- a/Plugins/AppleShortcuts/plugin.json +++ b/Plugins/AppleShortcuts/plugin.json @@ -3,17 +3,69 @@ "displayName": "Apple 快捷指令", "summary": "发现并运行现有的 Apple 快捷指令。", "localizedMetadata": { - "ar": { "displayName": "اختصارات Apple", "summary": "اكتشف اختصارات Apple الحالية وشغّلها." }, - "de": { "displayName": "Apple-Kurzbefehle", "summary": "Vorhandene Apple-Kurzbefehle entdecken und ausführen." }, - "en": { "displayName": "Apple Shortcuts", "summary": "Discover and run existing Apple Shortcuts." }, - "es": { "displayName": "Atajos de Apple", "summary": "Descubre y ejecuta atajos de Apple existentes." }, - "fr": { "displayName": "Raccourcis Apple", "summary": "Découvrez et exécutez vos raccourcis Apple." }, - "ja": { "displayName": "Appleショートカット", "summary": "既存のAppleショートカットを見つけて実行します。" }, - "ko": { "displayName": "Apple 단축어", "summary": "기존 Apple 단축어를 찾아 실행합니다." }, - "pt": { "displayName": "Atalhos da Apple", "summary": "Descubra e execute atalhos existentes da Apple." }, - "ru": { "displayName": "Быстрые команды Apple", "summary": "Находите и запускайте существующие быстрые команды Apple." }, - "zh-Hans": { "displayName": "Apple 快捷指令", "summary": "发现并运行现有的 Apple 快捷指令。" }, - "zh-Hant": { "displayName": "Apple 捷徑", "summary": "探索並執行現有的 Apple 捷徑。" } + "ar": { + "displayName": "اختصارات Apple", + "summary": "اكتشف اختصارات Apple الحالية وشغّلها." + }, + "de": { + "displayName": "Apple-Kurzbefehle", + "summary": "Vorhandene Apple-Kurzbefehle entdecken und ausführen." + }, + "en": { + "displayName": "Apple Shortcuts", + "summary": "Discover and run existing Apple Shortcuts." + }, + "es": { + "displayName": "Atajos de Apple", + "summary": "Descubre y ejecuta atajos de Apple existentes." + }, + "fr": { + "displayName": "Raccourcis Apple", + "summary": "Découvrez et exécutez vos raccourcis Apple." + }, + "ja": { + "displayName": "Appleショートカット", + "summary": "既存のAppleショートカットを見つけて実行します。" + }, + "ko": { + "displayName": "Apple 단축어", + "summary": "기존 Apple 단축어를 찾아 실행합니다." + }, + "pt": { + "displayName": "Atalhos da Apple", + "summary": "Descubra e execute atalhos existentes da Apple." + }, + "ru": { + "displayName": "Быстрые команды Apple", + "summary": "Находите и запускайте существующие быстрые команды Apple." + }, + "zh-Hans": { + "displayName": "Apple 快捷指令", + "summary": "发现并运行现有的 Apple 快捷指令。" + }, + "zh-Hant": { + "displayName": "Apple 捷徑", + "summary": "探索並執行現有的 Apple 捷徑。" + } + }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.run-shortcut.parameter-summary": { + "ar": "اختر اختصار Apple المخزن على هذا الـ Mac لتشغيله.", + "de": "Wähle den auf diesem Mac gespeicherten Apple-Kurzbefehl aus, der ausgeführt werden soll.", + "en": "Select the Apple Shortcut stored on this Mac to run.", + "es": "Selecciona el atajo de Apple guardado en este Mac que quieres ejecutar.", + "fr": "Sélectionnez le raccourci Apple enregistré sur ce Mac à exécuter.", + "ja": "この Mac に保存されている Apple ショートカットから、実行する項目を選択します。", + "ko": "이 Mac에 저장된 Apple 단축어 중 실행할 항목을 선택하세요.", + "pt": "Selecione o Atalho Apple salvo neste Mac para executar.", + "ru": "Выберите сохранённую на этом Mac быструю команду Apple для запуска.", + "zh-Hans": "选择存储在此 Mac 上、要运行的 Apple 快捷指令。", + "zh-Hant": "選擇儲存在此 Mac 上、要執行的 Apple 捷徑。" + } }, "version": "1.0.0", "minHostVersion": "1.2.0", @@ -29,6 +81,169 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], - "category": "productivity" + "permissions": [ + "automation" + ], + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "apple", + "shortcuts", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "اختصارات Apple" + ], + "de": [ + "Apple-Kurzbefehle" + ], + "en": [ + "Apple Shortcuts" + ], + "es": [ + "Atajos de Apple" + ], + "fr": [ + "Raccourcis Apple" + ], + "ja": [ + "Appleショートカット" + ], + "ko": [ + "Apple 단축어" + ], + "pt": [ + "Atalhos da Apple" + ], + "ru": [ + "Быстрые команды Apple" + ], + "zh-Hans": [ + "Apple 快捷指令" + ], + "zh-Hant": [ + "Apple 捷徑" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [ + { + "bundleID": "com.apple.shortcuts", + "name": "Shortcuts" + } + ], + "executables": [], + "permissionIDs": [ + "automation" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "shortcut-names", + "shortcut-folders" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "apple-shortcuts", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "run-shortcut", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "entrySource": "apple-shortcuts", + "parameters": [], + "parameterSummary": "@productStrings.action.run-shortcut.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "apple", + "shortcuts", + "run", + "shortcut" + ], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "confirmAlways", + "riskVariesByEntry": true + } + ] + } + ] + } } diff --git a/Plugins/AutoHideDock/Resources/Localizable.xcstrings b/Plugins/AutoHideDock/Resources/Localizable.xcstrings index acab2ace..0c5858ea 100644 --- a/Plugins/AutoHideDock/Resources/Localizable.xcstrings +++ b/Plugins/AutoHideDock/Resources/Localizable.xcstrings @@ -497,6 +497,289 @@ } } } + }, + "permission.automation.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب تغيير الإخفاء التلقائي لـ Dock التحكم في أحداث النظام." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Ändern des automatischen Ausblendens des Docks muss „System Events“ gesteuert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Changing Dock auto-hide requires controlling System Events." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar la ocultación automática del Dock requiere controlar Eventos del Sistema." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier le masquage automatique du Dock nécessite de contrôler Événements système." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dockの自動的な表示/非表示を切り替えるには、「システムイベント」の制御が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dock 자동 가리기를 변경하려면 ‘시스템 이벤트’를 제어해야 합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alterar a ocultação automática do Dock requer o controle dos Eventos do Sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для изменения автоскрытия Dock требуется управление приложением «Системные события»." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换程序坞自动隐藏时需要控制系统事件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換 Dock 自動隱藏時需要控制「系統事件」。" + } + } + } + }, + "permission.automation.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيطلب macOS إذن الأتمتة عند الاستخدام للمرة الأولى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS fragt bei der ersten Verwendung nach der Automation-Berechtigung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS will request Automation access the first time this is used." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "macOS solicitará acceso de automatización la primera vez que se use." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "macOS demandera l’autorisation d’automatisation lors de la première utilisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "初回使用時に、macOSがオートメーションの許可を求めます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "처음 사용할 때 macOS가 자동화 권한을 요청합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O macOS solicitará acesso à Automação na primeira utilização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При первом использовании macOS запросит доступ к автоматизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "macOS 会在首次使用时请求自动化授权。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "macOS 會在首次使用時要求自動化授權。" + } + } + } + }, + "permission.automation.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأتمتة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オートメーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Automação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматизация" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化" + } + } + } + }, + "permission.automation.status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد عند الاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigung bei Verwendung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm on Use" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar al usar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmation à l’utilisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用時に確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 시 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ao usar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтверждение при использовании" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按需确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按需確認" + } + } + } } }, "version": "1.0" diff --git a/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift b/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift index 67a64852..fc8b8d19 100644 --- a/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift +++ b/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift @@ -62,11 +62,16 @@ private struct AutoHideDockPluginProvider: PluginProvider { } @MainActor -final class AutoHideDockPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionProviding { +final class AutoHideDockPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionProviding, + PluginActionPermissionProviding +{ private enum ActionID { static let setEnabled = "set-enabled" static let toggle = "toggle" } + private enum PermissionID { + static let automation = "automation" + } let metadata: PluginMetadata let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( @@ -122,7 +127,19 @@ final class AutoHideDockPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginAction ) } - var permissionRequirements: [PluginPermissionRequirement] { [] } + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.automation, + kind: .automation, + title: localization.string("permission.automation.title", defaultValue: "自动化"), + description: localization.string( + "permission.automation.description", + defaultValue: "切换程序坞自动隐藏时需要控制系统事件。" + ) + ), + ] + } var shortcutDefinitions: [PluginShortcutDefinition] { [] } var actionDefinitions: [ActionDefinition] { @@ -201,10 +218,30 @@ final class AutoHideDockPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginAction } func permissionState(for permissionID: String) -> PluginPermissionState { - PluginPermissionState(isGranted: true, footnote: nil) + PluginPermissionState( + isGranted: false, + footnote: permissionID == PermissionID.automation + ? localization.string( + "permission.automation.footnote", + defaultValue: "macOS 会在首次使用时请求自动化授权。" + ) + : nil, + statusText: permissionID == PermissionID.automation + ? localization.string("permission.automation.status", defaultValue: "按需确认") + : nil, + statusSystemImage: permissionID == PermissionID.automation ? "cursorarrow.click.2" : nil, + statusTone: permissionID == PermissionID.automation ? .neutral : nil + ) } - func handlePermissionAction(id: String) {} + func handlePermissionAction(id: String) { + guard id == PermissionID.automation else { return } + requestPermissionGuidance?(PermissionID.automation) + } + func permissionRequirementIDs(for actionKey: ActionKey) -> [String] { + guard actionKey.providerID == metadata.id else { return [] } + return [PermissionID.automation] + } func handleSettingsAction(_ action: PluginSettingsAction) {} func handleShortcutAction(id: String) {} diff --git a/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift b/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift index a659ca6c..d6346204 100644 --- a/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift +++ b/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift @@ -14,6 +14,17 @@ final class AutoHideDockPluginTests: XCTestCase { XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") } + func testAutomationPermissionIsReportedAsOnDemand() { + let state = AutoHideDockPlugin( + commandRunner: MockDockCommandRunner(), + stateReader: { false } + ).permissionState(for: "automation") + + XCTAssertFalse(state.isGranted) + XCTAssertEqual(state.statusText, "按需确认") + XCTAssertEqual(state.statusTone, .neutral) + } + func testSwitchUpdatesDockState() { let runner = MockDockCommandRunner() let plugin = AutoHideDockPlugin(commandRunner: runner, stateReader: { false }) diff --git a/Plugins/AutoHideDock/plugin.json b/Plugins/AutoHideDock/plugin.json index 4eabc538..ca996ebc 100644 --- a/Plugins/AutoHideDock/plugin.json +++ b/Plugins/AutoHideDock/plugin.json @@ -48,6 +48,15 @@ "summary": "自動隱藏Dock,提供更乾淨的桌面環境" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +71,200 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], - "category": "display" + "permissions": [ + "automation" + ], + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "auto", + "hide", + "dock", + "display" + ], + "localizedSynonyms": { + "ar": [ + "إخفاء Dock تلقائيًا" + ], + "de": [ + "Dock automatisch ausblenden" + ], + "en": [ + "Auto-hide Dock" + ], + "es": [ + "Ocultar Dock automáticamente" + ], + "fr": [ + "Masquer le Dock automatiquement" + ], + "ja": [ + "Dockを自動的に隠す" + ], + "ko": [ + "Dock 자동 숨기기" + ], + "pt": [ + "Ocultar Dock automaticamente" + ], + "ru": [ + "Автоскрытие Dock" + ], + "zh-Hans": [ + "自动隐藏程序坞" + ], + "zh-Hant": [ + "自動隱藏 Dock" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "automation" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "auto-hide-dock", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "自动隐藏程序坞", + "Dock", + "auto", + "hide", + "dock", + "toggle" + ], + "systemImage": "rectangle.bottomthird.inset.filled", + "parameters": [], + "permissionIDs": [ + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "自动隐藏程序坞", + "Dock", + "auto", + "hide", + "dock", + "set", + "enabled" + ], + "systemImage": "rectangle.bottomthird.inset.filled", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [ + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings b/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings index bc068601..5224625d 100644 --- a/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings +++ b/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings @@ -497,6 +497,289 @@ } } } + }, + "permission.automation.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب تغيير الإخفاء التلقائي لشريط القوائم التحكم في أحداث النظام." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Ändern des automatischen Ausblendens der Menüleiste muss „System Events“ gesteuert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Changing menu bar auto-hide requires controlling System Events." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambiar la ocultación automática de la barra de menús requiere controlar Eventos del Sistema." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier le masquage automatique de la barre des menus nécessite de contrôler Événements système." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニューバーの自動的な表示/非表示を切り替えるには、「システムイベント」の制御が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메뉴 막대 자동 가리기를 변경하려면 ‘시스템 이벤트’를 제어해야 합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alterar a ocultação automática da barra de menus requer o controle dos Eventos do Sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для изменения автоскрытия строки меню требуется управление приложением «Системные события»." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换菜单栏自动隐藏时需要控制系统事件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換選單列自動隱藏時需要控制「系統事件」。" + } + } + } + }, + "permission.automation.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيطلب macOS إذن الأتمتة عند الاستخدام للمرة الأولى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS fragt bei der ersten Verwendung nach der Automation-Berechtigung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS will request Automation access the first time this is used." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "macOS solicitará acceso de automatización la primera vez que se use." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "macOS demandera l’autorisation d’automatisation lors de la première utilisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "初回使用時に、macOSがオートメーションの許可を求めます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "처음 사용할 때 macOS가 자동화 권한을 요청합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O macOS solicitará acesso à Automação na primeira utilização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При первом использовании macOS запросит доступ к автоматизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "macOS 会在首次使用时请求自动化授权。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "macOS 會在首次使用時要求自動化授權。" + } + } + } + }, + "permission.automation.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأتمتة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オートメーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Automação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматизация" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化" + } + } + } + }, + "permission.automation.status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد عند الاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigung bei Verwendung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm on Use" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar al usar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmation à l’utilisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用時に確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 시 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ao usar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтверждение при использовании" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按需确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按需確認" + } + } + } } }, "version": "1.0" diff --git a/Plugins/AutoHideMenuBar/Sources/AutoHideMenuBarPlugin.swift b/Plugins/AutoHideMenuBar/Sources/AutoHideMenuBarPlugin.swift index f22438cf..8b55841c 100644 --- a/Plugins/AutoHideMenuBar/Sources/AutoHideMenuBarPlugin.swift +++ b/Plugins/AutoHideMenuBar/Sources/AutoHideMenuBarPlugin.swift @@ -62,11 +62,16 @@ private struct AutoHideMenuBarPluginProvider: PluginProvider { } @MainActor -final class AutoHideMenuBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionProviding { +final class AutoHideMenuBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginActionProviding, + PluginActionPermissionProviding +{ private enum ActionID { static let setEnabled = "set-enabled" static let toggle = "toggle" } + private enum PermissionID { + static let automation = "automation" + } let metadata: PluginMetadata let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( @@ -122,7 +127,19 @@ final class AutoHideMenuBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginAct ) } - var permissionRequirements: [PluginPermissionRequirement] { [] } + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.automation, + kind: .automation, + title: localization.string("permission.automation.title", defaultValue: "自动化"), + description: localization.string( + "permission.automation.description", + defaultValue: "切换菜单栏自动隐藏时需要控制系统事件。" + ) + ), + ] + } var shortcutDefinitions: [PluginShortcutDefinition] { [] } var actionDefinitions: [ActionDefinition] { @@ -204,10 +221,30 @@ final class AutoHideMenuBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginAct } func permissionState(for permissionID: String) -> PluginPermissionState { - PluginPermissionState(isGranted: true, footnote: nil) + PluginPermissionState( + isGranted: false, + footnote: permissionID == PermissionID.automation + ? localization.string( + "permission.automation.footnote", + defaultValue: "macOS 会在首次使用时请求自动化授权。" + ) + : nil, + statusText: permissionID == PermissionID.automation + ? localization.string("permission.automation.status", defaultValue: "按需确认") + : nil, + statusSystemImage: permissionID == PermissionID.automation ? "cursorarrow.click.2" : nil, + statusTone: permissionID == PermissionID.automation ? .neutral : nil + ) } - func handlePermissionAction(id: String) {} + func handlePermissionAction(id: String) { + guard id == PermissionID.automation else { return } + requestPermissionGuidance?(PermissionID.automation) + } + func permissionRequirementIDs(for actionKey: ActionKey) -> [String] { + guard actionKey.providerID == metadata.id else { return [] } + return [PermissionID.automation] + } func handleSettingsAction(_ action: PluginSettingsAction) {} func handleShortcutAction(id: String) {} diff --git a/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift b/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift index 842cb12b..d91c4825 100644 --- a/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift +++ b/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift @@ -11,6 +11,14 @@ final class AutoHideMenuBarPluginTests: XCTestCase { XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") } + func testAutomationPermissionIsReportedAsOnDemand() { + let state = makePlugin().permissionState(for: "automation") + + XCTAssertFalse(state.isGranted) + XCTAssertEqual(state.statusText, "按需确认") + XCTAssertEqual(state.statusTone, .neutral) + } + func testSwitchUpdatesMenuBarState() { let runner = MockMenuBarCommandRunner() let plugin = makePlugin(runner: runner) diff --git a/Plugins/AutoHideMenuBar/plugin.json b/Plugins/AutoHideMenuBar/plugin.json index 9187cf27..30e871a6 100644 --- a/Plugins/AutoHideMenuBar/plugin.json +++ b/Plugins/AutoHideMenuBar/plugin.json @@ -48,6 +48,15 @@ "summary": "自動隱藏選單列,提供更完整的螢幕顯示空間" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +71,203 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], - "category": "display" + "permissions": [ + "automation" + ], + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "auto", + "hide", + "menu", + "bar", + "display" + ], + "localizedSynonyms": { + "ar": [ + "إخفاء شريط القوائم تلقائيًا" + ], + "de": [ + "Menüleiste automatisch ausblenden" + ], + "en": [ + "Auto-hide Menu Bar" + ], + "es": [ + "Ocultar barra de menús automáticamente" + ], + "fr": [ + "Masquer la barre des menus automatiquement" + ], + "ja": [ + "メニューバーを自動的に隠す" + ], + "ko": [ + "메뉴 막대 자동 숨기기" + ], + "pt": [ + "Ocultar barra de menus automaticamente" + ], + "ru": [ + "Автоскрытие строки меню" + ], + "zh-Hans": [ + "自动隐藏菜单栏" + ], + "zh-Hant": [ + "自動隱藏選單列" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "automation" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "auto-hide-menu-bar", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "自动隐藏菜单栏", + "自动隐藏菜单栏,提供更完整的屏幕显示空间", + "auto", + "hide", + "menu", + "bar", + "toggle" + ], + "systemImage": "menubar.rectangle", + "parameters": [], + "permissionIDs": [ + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "自动隐藏菜单栏", + "自动隐藏菜单栏,提供更完整的屏幕显示空间", + "auto", + "hide", + "menu", + "bar", + "set", + "enabled" + ], + "systemImage": "menubar.rectangle", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [ + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/AutoInput/plugin.json b/Plugins/AutoInput/plugin.json index dc6ace1e..671fef6f 100644 --- a/Plugins/AutoInput/plugin.json +++ b/Plugins/AutoInput/plugin.json @@ -48,6 +48,20 @@ "summary": "按應用程式記住並自動切換輸入法" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description", + "action.select-input-source.title": "@localizable.action.selectSource.definitionTitle", + "action.select-input-source.description": "@localizable.action.selectSource.description", + "action.select-input-source.parameter-summary": "@localizable.action.selectSource.parameterTitle" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -65,5 +79,237 @@ "permissions": [ "accessibility" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "auto", + "input", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "تبديل مصادر الإدخال تلقائيًا" + ], + "de": [ + "Eingabequelle automatisch wechseln" + ], + "en": [ + "Auto Input" + ], + "es": [ + "Cambio automático de entrada" + ], + "fr": [ + "Changement automatique de saisie" + ], + "ja": [ + "入力ソースの自動切り替え" + ], + "ko": [ + "입력 소스 자동 전환" + ], + "pt": [ + "Troca automática de entrada" + ], + "ru": [ + "Автопереключение источника ввода" + ], + "zh-Hans": [ + "自动切换输入法" + ], + "zh-Hant": [ + "自動切換輸入法" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "foreground-application", + "input-source" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "auto-input", + "kind": "mixed", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "自动切换输入法", + "按应用记住并自动切换输入法", + "auto", + "input", + "toggle" + ], + "systemImage": "keyboard", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "自动切换输入法", + "按应用记住并自动切换输入法", + "auto", + "input", + "set", + "enabled" + ], + "systemImage": "keyboard", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [ + { + "id": "select-input-source", + "title": "@productStrings.action.select-input-source.title", + "description": "@productStrings.action.select-input-source.description", + "entrySource": "installed-input-sources", + "parameters": [ + { + "id": "inputSourceID", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + } + ], + "parameterSummary": "@productStrings.action.select-input-source.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "auto", + "input", + "select", + "source", + "installed", + "sources" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "automatic-rule", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ] + } + ] + } } diff --git a/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings b/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings index 7ef2a554..feed8efb 100644 --- a/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings +++ b/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings @@ -2936,6 +2936,76 @@ } } } + }, + "action.manifest.discharge.title": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفريغ البطارية إلى حد الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bis zur Ladegrenze entladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Discharge to Charge Limit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar hasta el límite de carga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Décharger jusqu’à la limite de charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電上限まで放電" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 한도까지 방전" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Descarregar até ao limite de carga" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрядить до предела зарядки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放电至充电上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放電至充電上限" + } + } + } } }, "version": "1.0" diff --git a/Plugins/BatteryChargeLimit/plugin.json b/Plugins/BatteryChargeLimit/plugin.json index 5f7e2ec8..a9888fed 100644 --- a/Plugins/BatteryChargeLimit/plugin.json +++ b/Plugins/BatteryChargeLimit/plugin.json @@ -48,6 +48,74 @@ "summary": "設定電池充電上限,達到上限後停止充電;不自動恢復,由用戶決定何時繼續充電" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.install-helper.title": { + "ar": "تثبيت أداة مساعدة ذات صلاحيات مميّزة", + "de": "Privilegiertes Hilfsprogramm installieren", + "en": "Install Privileged Helper", + "es": "Instalar herramienta auxiliar con privilegios", + "fr": "Installer l’utilitaire privilégié", + "ja": "特権ヘルパーをインストール", + "ko": "권한 있는 도우미 설치", + "pt": "Instalar auxiliar privilegiado", + "ru": "Установить привилегированную службу", + "zh-Hans": "安装特权辅助工具", + "zh-Hant": "安裝特權輔助工具" + }, + "setup.install-helper.description": { + "ar": "عند الاستخدام الأول، يطلب MacTools تفويض المسؤول لتثبيت أداة مساعدة يملكها root وبوضع 4755 في /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper. تغيّر الأداة حالة شحن البطارية.", + "de": "Bei der ersten Verwendung fordert MacTools eine Administratorautorisierung an, um unter /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper ein root-eigenes Hilfsprogramm mit Modus 4755 zu installieren. Es ändert den Ladezustand der Batterie.", + "en": "On first use, MacTools requests administrator authorization to install a root-owned, mode-4755 helper at /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper. The helper changes battery charging state.", + "es": "En el primer uso, MacTools solicita autorización de administrador para instalar en /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper una herramienta auxiliar propiedad de root y con modo 4755. La herramienta cambia el estado de carga de la batería.", + "fr": "Lors de la première utilisation, MacTools demande une autorisation d’administrateur pour installer dans /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper un utilitaire appartenant à root et doté du mode 4755. Cet utilitaire modifie l’état de charge de la batterie.", + "ja": "初回使用時に、MacTools は管理者認証を求め、root 所有かつモード 4755 のヘルパーを /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper にインストールします。このヘルパーはバッテリーの充電状態を変更します。", + "ko": "처음 사용할 때 MacTools는 관리자 인증을 요청하고 root 소유의 모드 4755 도우미를 /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper에 설치합니다. 이 도우미는 배터리 충전 상태를 변경합니다.", + "pt": "No primeiro uso, o MacTools solicita autorização de administrador para instalar em /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper um auxiliar pertencente ao root e com modo 4755. O auxiliar altera o estado de carregamento da bateria.", + "ru": "При первом использовании MacTools запрашивает авторизацию администратора, чтобы установить в /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper принадлежащую root службу с режимом 4755. Она изменяет состояние зарядки батареи.", + "zh-Hans": "首次使用时,MacTools 会请求管理员授权,在 /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper 安装 root 所有且权限模式为 4755 的辅助工具,用于更改电池充电状态。", + "zh-Hant": "首次使用時,MacTools 會要求管理員授權,在 /Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper 安裝 root 所有且權限模式為 4755 的輔助工具,用於更改電池充電狀態。" + }, + "setup.verify-hardware.title": { + "ar": "التحقق من توافق البطارية", + "de": "Kompatible Batterie prüfen", + "en": "Verify Compatible Battery", + "es": "Comprobar la compatibilidad de la batería", + "fr": "Vérifier la compatibilité de la batterie", + "ja": "対応バッテリーを確認", + "ko": "호환 배터리 확인", + "pt": "Verificar bateria compatível", + "ru": "Проверить совместимость батареи", + "zh-Hans": "确认电池兼容性", + "zh-Hant": "確認電池相容性" + }, + "setup.verify-hardware.description": { + "ar": "يتطلب جهاز Mac ببطارية مدمجة يمكن التحكم في حالة شحنها عبر SMC.", + "de": "Erfordert einen Mac mit integrierter Batterie, deren Ladezustand über den SMC gesteuert werden kann.", + "en": "Requires a Mac with a built-in battery whose charging state can be controlled through the SMC.", + "es": "Requiere un Mac con una batería integrada cuyo estado de carga pueda controlarse mediante el SMC.", + "fr": "Nécessite un Mac doté d’une batterie intégrée dont l’état de charge peut être contrôlé via le SMC.", + "ja": "SMC 経由で充電状態を制御できる内蔵バッテリーを搭載した Mac が必要です。", + "ko": "SMC를 통해 충전 상태를 제어할 수 있는 내장 배터리가 탑재된 Mac이 필요합니다.", + "pt": "Requer um Mac com bateria integrada cujo estado de carregamento possa ser controlado pelo SMC.", + "ru": "Требуется Mac со встроенной батареей, состоянием зарядки которой можно управлять через SMC.", + "zh-Hans": "需要配备内置电池,且可通过 SMC 控制充电状态的 Mac。", + "zh-Hant": "需要配備內建電池,且可透過 SMC 控制充電狀態的 Mac。" + }, + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description", + "action.hold.title": "@localizable.panel.action.stopCharging", + "action.hold.description": "@localizable.panel.action.stopCharging", + "action.resume.title": "@localizable.panel.action.startCharging", + "action.resume.description": "@localizable.panel.action.startCharging", + "action.discharge.title": "@localizable.action.manifest.discharge.title", + "action.discharge.description": "@localizable.action.manifest.discharge.title", + "action.set-limit.title": "@localizable.settings.limit.title", + "action.set-limit.description": "@localizable.settings.limit.description", + "action.set-limit.parameter-summary": "@localizable.settings.limit.target" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -68,5 +136,295 @@ "settings": "form" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "battery", + "charge", + "limit", + "system" + ], + "localizedSynonyms": { + "ar": [ + "حد شحن البطارية" + ], + "de": [ + "Batterieladelimit" + ], + "en": [ + "Battery Charge Limit" + ], + "es": [ + "Límite de carga de batería" + ], + "fr": [ + "Limite de charge de la batterie" + ], + "ja": [ + "バッテリー充電上限" + ], + "ko": [ + "배터리 충전 한도" + ], + "pt": [ + "Limite de carga da bateria" + ], + "ru": [ + "Лимит заряда батареи" + ], + "zh-Hans": [ + "电池充电上限" + ], + "zh-Hant": [ + "電池充電上限" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "built-in battery" + ], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "advanced", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "install-privileged-helper", + "title": "@productStrings.setup.install-helper.title", + "description": "@productStrings.setup.install-helper.description" + }, + { + "id": "verify-compatible-hardware", + "title": "@productStrings.setup.verify-hardware.title", + "description": "@productStrings.setup.verify-hardware.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "battery-charge-limit", + "kind": "mixed", + "staticActions": [ + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "电池充电上限", + "限制电池充电至指定上限", + "battery", + "charge", + "limit", + "set", + "enabled" + ], + "systemImage": "battery.100.bolt", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "confirmAlways" + }, + { + "id": "hold", + "title": "@productStrings.action.hold.title", + "description": "@productStrings.action.hold.description", + "keywords": [ + "电池充电上限", + "停止充电", + "battery", + "charge", + "limit", + "hold" + ], + "systemImage": "bolt.slash.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "confirmAlways" + }, + { + "id": "resume", + "title": "@productStrings.action.resume.title", + "description": "@productStrings.action.resume.description", + "keywords": [ + "电池充电上限", + "开始充电", + "battery", + "charge", + "limit", + "resume" + ], + "systemImage": "bolt.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "confirmAlways" + }, + { + "id": "discharge", + "title": "@productStrings.action.discharge.title", + "description": "@productStrings.action.discharge.description", + "keywords": [ + "电池充电上限", + "强制放电至 80%", + "battery", + "charge", + "limit", + "discharge" + ], + "systemImage": "minus.circle", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [ + { + "id": "set-limit", + "title": "@productStrings.action.set-limit.title", + "description": "@productStrings.action.set-limit.description", + "entrySource": "charge-limit-presets", + "parameters": [ + { + "id": "limit", + "kind": "integer", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-limit.parameter-summary", + "localOnlyIdentity": false, + "keywords": [ + "battery", + "charge", + "limit", + "set", + "presets" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "confirmAlways" + } + ] + } + ] + } } diff --git a/Plugins/Calendar/plugin.json b/Plugins/Calendar/plugin.json index c79f3fc1..e5054400 100644 --- a/Plugins/Calendar/plugin.json +++ b/Plugins/Calendar/plugin.json @@ -48,6 +48,12 @@ "summary": "查看日期、節假日和系統行程" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -66,5 +72,120 @@ "calendarFullAccess", "automation" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "calendar", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "التقويم" + ], + "de": [ + "Kalender" + ], + "en": [ + "Calendar" + ], + "es": [ + "Calendario" + ], + "fr": [ + "Calendrier" + ], + "ja": [ + "カレンダー" + ], + "ko": [ + "캘린더" + ], + "pt": [ + "Calendário" + ], + "ru": [ + "Календарь" + ], + "zh-Hans": [ + "日历" + ], + "zh-Hant": [ + "行事曆" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "calendarFullAccess", + "automation" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "calendar-events" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/ClipboardClear/plugin.json b/Plugins/ClipboardClear/plugin.json index 5be5d389..1bf316f3 100644 --- a/Plugins/ClipboardClear/plugin.json +++ b/Plugins/ClipboardClear/plugin.json @@ -48,6 +48,10 @@ "summary": "一鍵清空當前剪貼板內容" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,148 @@ "settings": "none" }, "permissions": [], - "category": "storage" + "category": "storage", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "clipboard", + "clear", + "storage" + ], + "localizedSynonyms": { + "ar": [ + "مسح الحافظة" + ], + "de": [ + "Zwischenablage leeren" + ], + "en": [ + "Clear Clipboard" + ], + "es": [ + "Borrar portapapeles" + ], + "fr": [ + "Vider le presse-papiers" + ], + "ja": [ + "クリップボードを消去" + ], + "ko": [ + "클립보드 지우기" + ], + "pt": [ + "Limpar área de transferência" + ], + "ru": [ + "Очистить буфер обмена" + ], + "zh-Hans": [ + "清空剪贴板" + ], + "zh-Hant": [ + "清空剪貼簿" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "storage" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "clipboard-content" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "clipboard-clear", + "kind": "static", + "staticActions": [ + { + "id": "clear", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "清空剪贴板", + "一键清空当前剪贴板内容", + "clipboard", + "pasteboard", + "clear" + ], + "systemImage": "trash", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/CloudflareR2/plugin.json b/Plugins/CloudflareR2/plugin.json index 6e5bb640..ec16b096 100644 --- a/Plugins/CloudflareR2/plugin.json +++ b/Plugins/CloudflareR2/plugin.json @@ -48,6 +48,23 @@ "summary": "ارفع الملفات إلى Cloudflare R2." } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup-configure-r2-credentials-description": { + "ar": "أدخل معرّف الحساب والحاوية ومفتاح الوصول والسر المحفوظ في سلسلة المفاتيح قبل أول رفع.", + "de": "Gib vor dem ersten Upload Konto-ID, Bucket, Zugriffsschlüssel und das im Schlüsselbund gespeicherte Geheimnis ein.", + "en": "Before the first upload, enter the account ID, bucket, access key, and Keychain-stored secret.", + "es": "Antes de la primera carga, introduce el ID de cuenta, el bucket, la clave de acceso y el secreto guardado en el llavero.", + "fr": "Avant le premier envoi, saisissez l’identifiant du compte, le bucket, la clé d’accès et le secret stocké dans le trousseau.", + "ja": "初回アップロード前に、アカウント ID、バケット、アクセスキー、キーチェーンに保存するシークレットを入力します。", + "ko": "처음 업로드하기 전에 계정 ID, 버킷, 액세스 키와 키체인에 저장할 비밀 키를 입력하세요.", + "pt": "Antes do primeiro envio, introduza o ID da conta, o bucket, a chave de acesso e o segredo guardado no Porta‑Chaves.", + "ru": "Перед первой загрузкой укажите ID учётной записи, бакет, ключ доступа и секрет, сохранённый в Связке ключей.", + "zh-Hans": "首次上传前,请填写账户 ID、存储桶、访问密钥,并将密钥安全地存入钥匙串。", + "zh-Hant": "首次上傳前,請填寫帳戶 ID、儲存貯體、存取金鑰,並將密鑰安全地存入鑰匙圈。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +80,160 @@ "settings": "workspace" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "cloudflare", + "r2", + "upload", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "الرفع إلى Cloudflare R2" + ], + "de": [ + "Cloudflare R2-Upload" + ], + "en": [ + "Cloudflare R2 Upload" + ], + "es": [ + "Carga en Cloudflare R2" + ], + "fr": [ + "Envoi vers Cloudflare R2" + ], + "ja": [ + "Cloudflare R2 アップロード" + ], + "ko": [ + "Cloudflare R2 업로드" + ], + "pt": [ + "Upload para o Cloudflare R2" + ], + "ru": [ + "Загрузка в Cloudflare R2" + ], + "zh-Hans": [ + "Cloudflare R2 上传" + ], + "zh-Hant": [ + "Cloudflare R2 上傳" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "advanced", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "selected-files", + "upload-configuration" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "required", + "networkDomains": [], + "allowsUserConfiguredDomains": true, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "configure-r2-credentials", + "title": "@productStrings.display-name", + "description": "@productStrings.setup-configure-r2-credentials-description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "cloudflare-r2", + "kind": "static", + "staticActions": [ + { + "id": "upload-file", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "R2", + "S3", + "Cloudflare", + "上传", + "cloudflare", + "r2", + "upload", + "file" + ], + "systemImage": "icloud.and.arrow.up.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/DeviceBattery/Tests/DeviceBatteryCommandRunnerTests.swift b/Plugins/DeviceBattery/Tests/DeviceBatteryCommandRunnerTests.swift index d4d51301..ed041f04 100644 --- a/Plugins/DeviceBattery/Tests/DeviceBatteryCommandRunnerTests.swift +++ b/Plugins/DeviceBattery/Tests/DeviceBatteryCommandRunnerTests.swift @@ -48,19 +48,18 @@ final class DeviceBatteryCommandRunnerTests: XCTestCase { let start = clock.now let output = await DeviceBatteryCommandRunner.run( path: "/bin/sh", - arguments: ["-c", "printf 'keep\\nskip\\n'; sleep 5"], - // Leave enough launch time under the parallel full suite. This test - // covers partial-output draining and the 2-second assertion below - // still proves that the descendant's 5-second pipe is not awaited. - timeout: 0.5, + arguments: ["-c", "printf 'keep\\nskip\\n'; sleep 30"], + // CI runs the full suite in parallel, so allow the fixture enough + // time to launch and emit its output before forcing termination. + // The elapsed-time assertion still proves that the inherited pipe + // is closed without waiting for the descendant's 30-second sleep. + timeout: 2, outputLineFilter: { $0 == "keep" } ) - XCTAssertEqual( - output, - DeviceBatteryCommandResult(output: "keep\n", completion: .timedOut) - ) - XCTAssertLessThan(start.duration(to: clock.now), .seconds(2)) + XCTAssertEqual(output?.completion, .timedOut) + XCTAssertEqual(output?.output, "keep\n") + XCTAssertLessThan(start.duration(to: clock.now), .seconds(5)) } func testCompletedParentDoesNotWaitForDescendantHoldingPipe() async { diff --git a/Plugins/DeviceBattery/plugin.json b/Plugins/DeviceBattery/plugin.json index f969b299..d273b8e9 100644 --- a/Plugins/DeviceBattery/plugin.json +++ b/Plugins/DeviceBattery/plugin.json @@ -48,6 +48,10 @@ "summary": "查看 Mac、Apple 行動裝置、藍牙外設和雷柏滑鼠電量" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,5 +66,117 @@ "componentPanel": true, "settings": "form" }, - "permissions": [] + "permissions": [ + "inputMonitoring" + ], + "category": "monitoring", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "device", + "battery", + "monitoring" + ], + "localizedSynonyms": { + "ar": [ + "بطارية الأجهزة" + ], + "de": [ + "Gerätebatterie" + ], + "en": [ + "Device Battery" + ], + "es": [ + "Batería de dispositivos" + ], + "fr": [ + "Batterie des appareils" + ], + "ja": [ + "デバイスのバッテリー" + ], + "ko": [ + "기기 배터리" + ], + "pt": [ + "Bateria dos dispositivos" + ], + "ru": [ + "Батарея устройств" + ], + "zh-Hans": [ + "设备电量" + ], + "zh-Hant": [ + "裝置電量" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "monitoring" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "inputMonitoring" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "connected-device-battery-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/DiskClean/plugin.json b/Plugins/DiskClean/plugin.json index 8bfe8a57..afb16a20 100644 --- a/Plugins/DiskClean/plugin.json +++ b/Plugins/DiskClean/plugin.json @@ -48,6 +48,23 @@ "summary": "掃描系統快取、開發產物與殘留安裝包,預設移到廢紙簍,執行前校驗路徑安全" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "retention-description": { + "ar": "تظل إعدادات التنظيف محفوظة حتى إزالة بيانات الإضافة. يحتفظ سجل التدقيق بملفين بحد أقصى، حجم كل منهما 5 ميغابايت، وتُضغط سجلات التجهيز بعد الاسترداد.", + "de": "Bereinigungseinstellungen bleiben bis zum Entfernen der Plug-in-Daten erhalten. Der Prüfverlauf umfasst höchstens zwei 5-MB-Dateien; Staging-Einträge werden nach der Wiederherstellung komprimiert.", + "en": "Cleanup settings persist until plugin data is removed. Audit history keeps at most two 5 MB log files, and staging records are compacted after recovery.", + "es": "Los ajustes de limpieza se conservan hasta que se eliminan los datos del complemento. El historial de auditoría guarda como máximo dos archivos de 5 MB y los registros de preparación se compactan tras la recuperación.", + "fr": "Les réglages de nettoyage restent jusqu’à la suppression des données du module. L’historique d’audit conserve au plus deux fichiers de 5 Mo et les enregistrements de préparation sont compactés après récupération.", + "ja": "クリーンアップ設定はプラグインデータを削除するまで保持されます。監査履歴は最大 5 MB のログを 2 ファイル保持し、ステージング記録は復旧後に圧縮されます。", + "ko": "정리 설정은 플러그인 데이터를 제거할 때까지 유지됩니다. 감사 기록은 최대 5MB 로그 파일 2개를 보관하며 스테이징 기록은 복구 후 정리됩니다.", + "pt": "As definições de limpeza permanecem até os dados do plugin serem removidos. O histórico de auditoria mantém no máximo dois ficheiros de 5 MB e os registos de preparação são compactados após a recuperação.", + "ru": "Настройки очистки хранятся до удаления данных плагина. История аудита содержит не более двух файлов по 5 МБ, а записи подготовки уплотняются после восстановления.", + "zh-Hans": "清理设置会保留到插件数据被移除。审计历史最多保留两个 5 MB 日志文件,暂存记录会在恢复后压缩整理。", + "zh-Hant": "清理設定會保留到外掛資料被移除。稽核歷史最多保留兩個 5 MB 記錄檔,暫存記錄會在復原後壓縮整理。" + } + }, "version": "2.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +80,157 @@ "settings": "workspace" }, "permissions": [], - "category": "storage" + "category": "storage", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "disk", + "clean", + "cleanup", + "storage" + ], + "localizedSynonyms": { + "ar": [ + "تنظيف القرص" + ], + "de": [ + "Datenträgerbereinigung" + ], + "en": [ + "Disk Cleanup" + ], + "es": [ + "Limpieza de disco" + ], + "fr": [ + "Nettoyage du disque" + ], + "ja": [ + "ディスククリーンアップ" + ], + "ko": [ + "디스크 정리" + ], + "pt": [ + "Limpeza de disco" + ], + "ru": [ + "Очистка диска" + ], + "zh-Hans": [ + "磁盘清理" + ], + "zh-Hant": [ + "磁碟清理" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "storage" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "filesystem-paths", + "file-sizes" + ], + "dataPersisted": [ + "plugin-configuration", + "cleanup-audit-history", + "cleanup-staging-journal" + ], + "retention": { + "policy": "user-controlled", + "description": "@productStrings.retention-description" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "disk-clean", + "kind": "static", + "staticActions": [ + { + "id": "scan-and-review", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "磁盘清理", + "扫描", + "打开详情", + "disk", + "clean", + "scan", + "and", + "review" + ], + "systemImage": "magnifyingglass", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/DisplayBrightness/plugin.json b/Plugins/DisplayBrightness/plugin.json index 2b4128f4..b630cfda 100644 --- a/Plugins/DisplayBrightness/plugin.json +++ b/Plugins/DisplayBrightness/plugin.json @@ -48,6 +48,20 @@ "summary": "快速調節每個顯示器的亮度" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.display-brightness.decrease.title": "@localizable.shortcut.direction.decrease", + "action.display-brightness.decrease.description": "@localizable.shortcut.direction.decrease", + "action.display-brightness.increase.title": "@localizable.shortcut.direction.increase", + "action.display-brightness.increase.description": "@localizable.shortcut.direction.increase", + "action.disable-built-in-display.title": "@localizable.displayDisable.action.disable", + "action.disable-built-in-display.description": "@localizable.displayDisable.action.disable", + "action.restore-built-in-display.title": "@localizable.displayDisable.action.restore", + "action.restore-built-in-display.description": "@localizable.displayDisable.action.restore" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +77,244 @@ "settings": "form" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "display", + "brightness" + ], + "localizedSynonyms": { + "ar": [ + "سطوع الشاشة" + ], + "de": [ + "Displayhelligkeit" + ], + "en": [ + "Display Brightness" + ], + "es": [ + "Brillo de pantalla" + ], + "fr": [ + "Luminosité de l’écran" + ], + "ja": [ + "ディスプレイの明るさ" + ], + "ko": [ + "디스플레이 밝기" + ], + "pt": [ + "Brilho da tela" + ], + "ru": [ + "Яркость дисплея" + ], + "zh-Hans": [ + "显示器亮度" + ], + "zh-Hant": [ + "顯示器亮度" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "connected display" + ], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "display-brightness", + "kind": "static", + "staticActions": [ + { + "id": "display-brightness.decrease", + "title": "@productStrings.action.display-brightness.decrease.title", + "description": "@productStrings.action.display-brightness.decrease.description", + "keywords": [ + "显示器亮度", + "降低", + "display", + "brightness", + "decrease" + ], + "systemImage": "sun.min.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "display-brightness.increase", + "title": "@productStrings.action.display-brightness.increase.title", + "description": "@productStrings.action.display-brightness.increase.description", + "keywords": [ + "显示器亮度", + "增加", + "display", + "brightness", + "increase" + ], + "systemImage": "sun.max.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "disable-built-in-display", + "title": "@productStrings.action.disable-built-in-display.title", + "description": "@productStrings.action.disable-built-in-display.description", + "keywords": [ + "显示器亮度", + "display", + "disable", + "brightness", + "built", + "in" + ], + "systemImage": "display", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + }, + { + "id": "restore-built-in-display", + "title": "@productStrings.action.restore-built-in-display.title", + "description": "@productStrings.action.restore-built-in-display.description", + "keywords": [ + "显示器亮度", + "display", + "restore", + "brightness", + "built", + "in" + ], + "systemImage": "display", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "automatic-rule", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/DisplayResolution/plugin.json b/Plugins/DisplayResolution/plugin.json index 4668c968..0abcd3dc 100644 --- a/Plugins/DisplayResolution/plugin.json +++ b/Plugins/DisplayResolution/plugin.json @@ -48,6 +48,27 @@ "summary": "查看並切換每個顯示器的分辨率" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.set-resolution.title": "@localizable.metadata.title", + "action.set-resolution.description": "@localizable.metadata.description", + "action.set-resolution.parameter-summary": { + "ar": "اختر شاشة وحدد الحجم المنطقي وحجم البكسل ومعدل التحديث.", + "de": "Wähle ein Display und gib logische Größe, Pixelgröße und Bildwiederholrate an.", + "en": "Select a display and specify logical size, pixel size, and refresh rate.", + "es": "Selecciona una pantalla y especifica el tamaño lógico, el tamaño en píxeles y la frecuencia de actualización.", + "fr": "Sélectionnez un écran et indiquez la taille logique, la taille en pixels et la fréquence de rafraîchissement.", + "ja": "ディスプレイを選択し、論理サイズ、ピクセルサイズ、リフレッシュレートを指定します。", + "ko": "디스플레이를 선택하고 논리적 크기, 픽셀 크기 및 새로 고침 빈도를 지정하세요.", + "pt": "Selecione um monitor e especifique o tamanho lógico, o tamanho em pixels e a taxa de atualização.", + "ru": "Выберите дисплей и укажите логический размер, размер в пикселях и частоту обновления.", + "zh-Hans": "选择显示器,并指定逻辑尺寸、像素尺寸和刷新率。", + "zh-Hant": "選擇顯示器,並指定邏輯尺寸、像素尺寸和更新率。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +84,195 @@ "settings": "none" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "display", + "resolution" + ], + "localizedSynonyms": { + "ar": [ + "دقة الشاشة" + ], + "de": [ + "Displayauflösung" + ], + "en": [ + "Display Resolution" + ], + "es": [ + "Resolución de pantalla" + ], + "fr": [ + "Résolution de l’écran" + ], + "ja": [ + "ディスプレイ解像度" + ], + "ko": [ + "디스플레이 해상도" + ], + "pt": [ + "Resolução da tela" + ], + "ru": [ + "Разрешение дисплея" + ], + "zh-Hans": [ + "显示器分辨率" + ], + "zh-Hant": [ + "顯示器解析度" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "connected display" + ], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "display-resolution", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "set-resolution", + "title": "@productStrings.action.set-resolution.title", + "description": "@productStrings.action.set-resolution.description", + "entrySource": "connected-display-modes", + "parameters": [ + { + "id": "display", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + }, + { + "id": "width", + "kind": "integer", + "isRequired": true, + "portability": "portable" + }, + { + "id": "height", + "kind": "integer", + "isRequired": true, + "portability": "portable" + }, + { + "id": "pixel-width", + "kind": "integer", + "isRequired": true, + "portability": "portable" + }, + { + "id": "pixel-height", + "kind": "integer", + "isRequired": true, + "portability": "portable" + }, + { + "id": "refresh-rate", + "kind": "double", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-resolution.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "display", + "resolution", + "set", + "connected", + "modes" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ] + } + ] + } } diff --git a/Plugins/DisplaySleep/plugin.json b/Plugins/DisplaySleep/plugin.json index 51a92bbd..a86f2bb0 100644 --- a/Plugins/DisplaySleep/plugin.json +++ b/Plugins/DisplaySleep/plugin.json @@ -48,6 +48,10 @@ "summary": "一鍵讓所有顯示器立即進入休眠" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,149 @@ "settings": "none" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "display", + "sleep" + ], + "localizedSynonyms": { + "ar": [ + "إسبات الشاشات" + ], + "de": [ + "Display-Ruhezustand" + ], + "en": [ + "Display Sleep" + ], + "es": [ + "Reposo de pantallas" + ], + "fr": [ + "Mise en veille des écrans" + ], + "ja": [ + "ディスプレイをスリープ" + ], + "ko": [ + "디스플레이 잠자기" + ], + "pt": [ + "Repouso das telas" + ], + "ru": [ + "Сон дисплеев" + ], + "zh-Hans": [ + "显示器休眠" + ], + "zh-Hant": [ + "顯示器睡眠" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "display-sleep", + "kind": "static", + "staticActions": [ + { + "id": "execute", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "显示器休眠", + "立即让显示器休眠", + "display", + "sleep", + "execute" + ], + "systemImage": "display", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/DisplayTrueColor/plugin.json b/Plugins/DisplayTrueColor/plugin.json index 1f32b1d1..d9c4b20a 100644 --- a/Plugins/DisplayTrueColor/plugin.json +++ b/Plugins/DisplayTrueColor/plugin.json @@ -48,6 +48,15 @@ "summary": "自動調節顯示器顏色以適應環境光" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +72,193 @@ "settings": "none" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "display", + "true", + "color", + "tone" + ], + "localizedSynonyms": { + "ar": [ + "True Tone" + ], + "de": [ + "True Tone" + ], + "en": [ + "True Tone" + ], + "es": [ + "True Tone" + ], + "fr": [ + "True Tone" + ], + "ja": [ + "True Tone" + ], + "ko": [ + "True Tone" + ], + "pt": [ + "True Tone" + ], + "ru": [ + "True Tone" + ], + "zh-Hans": [ + "原彩显示" + ], + "zh-Hant": [ + "原彩顯示" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "display-true-color", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "原彩显示", + "自动调节显示器颜色以适应环境光", + "True Tone", + "display", + "true", + "color", + "toggle" + ], + "systemImage": "circle.righthalf.filled", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "原彩显示", + "自动调节显示器颜色以适应环境光", + "True Tone", + "display", + "true", + "color", + "set", + "enabled" + ], + "systemImage": "circle.righthalf.filled", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/DockClickMinimize/plugin.json b/Plugins/DockClickMinimize/plugin.json index 2d397b08..c8654cbc 100644 --- a/Plugins/DockClickMinimize/plugin.json +++ b/Plugins/DockClickMinimize/plugin.json @@ -3,17 +3,56 @@ "displayName": "点击程序坞隐藏活跃 App", "summary": "点击活跃 App 的程序坞图标即可将其隐藏(如 Windows)。", "localizedMetadata": { - "ar": { "displayName": "إخفاء التطبيق النشط بالنقر على Dock", "summary": "أخفِ التطبيق النشط بالنقر على أيقونته في Dock (كما في Windows)." }, - "de": { "displayName": "Aktive App per Dock-Klick ausblenden", "summary": "Blendet die aktive App durch Klick auf ihr Dock-Symbol aus (wie unter Windows)." }, - "en": { "displayName": "Hide Active App on Dock Click", "summary": "Hide the active app by clicking its Dock icon (like on Windows)." }, - "es": { "displayName": "Ocultar la app activa al hacer clic en el Dock", "summary": "Oculta la app activa al hacer clic en su icono del Dock (como en Windows)." }, - "fr": { "displayName": "Masquer l’app active par clic dans le Dock", "summary": "Masque l’app active en cliquant sur son icône dans le Dock (comme sous Windows)." }, - "ja": { "displayName": "DockクリックでアクティブなAppを隠す", "summary": "DockのアイコンをクリックしてアクティブなAppを隠します(Windowsと同様)。" }, - "ko": { "displayName": "Dock 클릭으로 활성 앱 가리기", "summary": "Dock 아이콘을 클릭해 활성 앱을 가립니다(Windows처럼)." }, - "pt": { "displayName": "Ocultar app ativa com clique no Dock", "summary": "Oculta o app ativo ao clicar no ícone dele no Dock (como no Windows)." }, - "ru": { "displayName": "Скрыть активное приложение щелчком по Dock", "summary": "Скрывает активное приложение щелчком по его значку в Dock (как в Windows)." }, - "zh-Hans": { "displayName": "点击程序坞隐藏活跃 App", "summary": "点击活跃 App 的程序坞图标即可将其隐藏(如 Windows)。" }, - "zh-Hant": { "displayName": "點擊 Dock 隱藏使用中 App", "summary": "點擊使用中 App 的 Dock 圖示即可將其隱藏(如 Windows)。" } + "ar": { + "displayName": "إخفاء التطبيق النشط بالنقر على Dock", + "summary": "أخفِ التطبيق النشط بالنقر على أيقونته في Dock (كما في Windows)." + }, + "de": { + "displayName": "Aktive App per Dock-Klick ausblenden", + "summary": "Blendet die aktive App durch Klick auf ihr Dock-Symbol aus (wie unter Windows)." + }, + "en": { + "displayName": "Hide Active App on Dock Click", + "summary": "Hide the active app by clicking its Dock icon (like on Windows)." + }, + "es": { + "displayName": "Ocultar la app activa al hacer clic en el Dock", + "summary": "Oculta la app activa al hacer clic en su icono del Dock (como en Windows)." + }, + "fr": { + "displayName": "Masquer l’app active par clic dans le Dock", + "summary": "Masque l’app active en cliquant sur son icône dans le Dock (comme sous Windows)." + }, + "ja": { + "displayName": "DockクリックでアクティブなAppを隠す", + "summary": "DockのアイコンをクリックしてアクティブなAppを隠します(Windowsと同様)。" + }, + "ko": { + "displayName": "Dock 클릭으로 활성 앱 가리기", + "summary": "Dock 아이콘을 클릭해 활성 앱을 가립니다(Windows처럼)." + }, + "pt": { + "displayName": "Ocultar app ativa com clique no Dock", + "summary": "Oculta o app ativo ao clicar no ícone dele no Dock (como no Windows)." + }, + "ru": { + "displayName": "Скрыть активное приложение щелчком по Dock", + "summary": "Скрывает активное приложение щелчком по его значку в Dock (как в Windows)." + }, + "zh-Hans": { + "displayName": "点击程序坞隐藏活跃 App", + "summary": "点击活跃 App 的程序坞图标即可将其隐藏(如 Windows)。" + }, + "zh-Hant": { + "displayName": "點擊 Dock 隱藏使用中 App", + "summary": "點擊使用中 App 的 Dock 圖示即可將其隱藏(如 Windows)。" + } + }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" }, "version": "1.0.0", "minHostVersion": "1.2.0", @@ -29,6 +68,130 @@ "componentPanel": false, "settings": "form" }, - "permissions": ["accessibility", "inputMonitoring"], - "category": "productivity" + "permissions": [ + "accessibility", + "inputMonitoring" + ], + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "dock", + "click", + "minimize", + "hide", + "active", + "app", + "on", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "إخفاء التطبيق النشط بالنقر على Dock" + ], + "de": [ + "Aktive App per Dock-Klick ausblenden" + ], + "en": [ + "Hide Active App on Dock Click" + ], + "es": [ + "Ocultar la app activa al hacer clic en el Dock" + ], + "fr": [ + "Masquer l’app active par clic dans le Dock" + ], + "ja": [ + "DockクリックでアクティブなAppを隠す" + ], + "ko": [ + "Dock 클릭으로 활성 앱 가리기" + ], + "pt": [ + "Ocultar app ativa com clique no Dock" + ], + "ru": [ + "Скрыть активное приложение щелчком по Dock" + ], + "zh-Hans": [ + "点击程序坞隐藏活跃 App" + ], + "zh-Hant": [ + "點擊 Dock 隱藏使用中 App" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility", + "inputMonitoring" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "productivity-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/DockLock/Sources/DockLockPlugin.swift b/Plugins/DockLock/Sources/DockLockPlugin.swift index 01a39cdc..8ad2ed57 100644 --- a/Plugins/DockLock/Sources/DockLockPlugin.swift +++ b/Plugins/DockLock/Sources/DockLockPlugin.swift @@ -249,7 +249,8 @@ final class DockLockPlugin: MacToolsPlugin, PluginPrimaryPanel, AccessibilityPermissionRefreshing, - PluginActionProviding + PluginActionProviding, + PluginActionPermissionProviding { private enum ActionID { static let toggle = "toggle" @@ -385,6 +386,15 @@ final class DockLockPlugin: ] } + func permissionRequirementIDs(for actionKey: ActionKey) -> [String] { + guard actionKey.providerID == metadata.id, + actionKey.actionID == ActionID.toggle || actionKey.actionID == ActionID.setEnabled + else { + return [] + } + return [PermissionID.accessibility] + } + var actionCatalogEntries: [ActionCatalogEntry] { [ ActionCatalogEntry( diff --git a/Plugins/DockLock/Tests/DockLockPluginTests.swift b/Plugins/DockLock/Tests/DockLockPluginTests.swift index b39bf6c4..6fd83118 100644 --- a/Plugins/DockLock/Tests/DockLockPluginTests.swift +++ b/Plugins/DockLock/Tests/DockLockPluginTests.swift @@ -297,6 +297,13 @@ final class DockLockPluginTests: XCTestCase { XCTAssertEqual(plugin.actionCatalogEntries.count, 3) XCTAssertEqual(plugin.actionCatalogEntries.first?.presentationState, .inactive) XCTAssertEqual(plugin.actionDefinitions.map(\.externalInvocationPolicy), [.allowed, .allowed]) + XCTAssertEqual(plugin.permissionRequirements.map(\.id), ["accessibility"]) + for definition in plugin.actionDefinitions { + XCTAssertEqual( + plugin.permissionRequirementIDs(for: definition.key), + ["accessibility"] + ) + } } func testCanonicalEnableAndDisableActionsUseTheSharedMutationPath() async throws { diff --git a/Plugins/DockLock/plugin.json b/Plugins/DockLock/plugin.json index 68074669..71344e7e 100644 --- a/Plugins/DockLock/plugin.json +++ b/Plugins/DockLock/plugin.json @@ -3,19 +3,62 @@ "displayName": "锁定程序坞", "summary": "防止程序坞在多显示器之间意外移动", "localizedMetadata": { + "ar": { + "displayName": "قفل Dock", + "summary": "امنع انتقال Dock بين الشاشات عن طريق الخطأ." + }, + "de": { + "displayName": "Dock-Sperre", + "summary": "Verhindert, dass das Dock versehentlich zwischen Displays verschoben wird." + }, "en": { "displayName": "Dock Lock", "summary": "Prevent the Dock from moving between displays accidentally." }, + "es": { + "displayName": "Bloqueo del Dock", + "summary": "Evita que el Dock se mueva accidentalmente entre pantallas." + }, "fr": { "displayName": "Verrouillage du Dock", "summary": "Empêche le Dock de passer accidentellement d’un écran à l’autre." }, + "ja": { + "displayName": "Dockの固定", + "summary": "Dockが誤ってディスプレイ間を移動しないようにします。" + }, + "ko": { + "displayName": "Dock 잠금", + "summary": "Dock이 실수로 디스플레이 사이를 이동하지 않도록 합니다." + }, + "pt": { + "displayName": "Bloqueio do Dock", + "summary": "Impeça que o Dock se mova acidentalmente entre monitores." + }, + "ru": { + "displayName": "Блокировка Dock", + "summary": "Не позволяет Dock случайно перемещаться между дисплеями." + }, "zh-Hans": { "displayName": "锁定程序坞", "summary": "防止程序坞在多显示器之间意外移动" + }, + "zh-Hant": { + "displayName": "鎖定 Dock", + "summary": "防止 Dock 意外移動到其他顯示器。" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.0.1", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -33,5 +76,204 @@ "permissions": [ "accessibility" ], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "dock", + "lock", + "display" + ], + "localizedSynonyms": { + "ar": [ + "قفل Dock" + ], + "de": [ + "Dock-Sperre" + ], + "en": [ + "Dock Lock" + ], + "es": [ + "Bloqueo del Dock" + ], + "fr": [ + "Verrouillage du Dock" + ], + "ja": [ + "Dockの固定" + ], + "ko": [ + "Dock 잠금" + ], + "pt": [ + "Bloqueio do Dock" + ], + "ru": [ + "Блокировка Dock" + ], + "zh-Hans": [ + "锁定程序坞" + ], + "zh-Hant": [ + "鎖定 Dock" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "dock-lock", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "锁定程序坞", + "防止程序坞在多显示器之间意外移动", + "Dock", + "dock", + "lock", + "toggle" + ], + "systemImage": "lock.rectangle", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "锁定程序坞", + "防止程序坞在多显示器之间意外移动", + "Dock", + "dock", + "lock", + "set", + "enabled" + ], + "systemImage": "lock.rectangle", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/EjectDisk/plugin.json b/Plugins/EjectDisk/plugin.json index b89cec98..e6b6b229 100644 --- a/Plugins/EjectDisk/plugin.json +++ b/Plugins/EjectDisk/plugin.json @@ -48,6 +48,10 @@ "summary": "推出所有可移動磁盤" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,150 @@ "settings": "none" }, "permissions": [], - "category": "storage" + "category": "storage", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "eject", + "disk", + "disks", + "storage" + ], + "localizedSynonyms": { + "ar": [ + "إخراج الأقراص" + ], + "de": [ + "Datenträger auswerfen" + ], + "en": [ + "Eject Disks" + ], + "es": [ + "Expulsar discos" + ], + "fr": [ + "Éjecter les disques" + ], + "ja": [ + "ディスクを取り出す" + ], + "ko": [ + "디스크 추출" + ], + "pt": [ + "Ejetar discos" + ], + "ru": [ + "Извлечь диски" + ], + "zh-Hans": [ + "推出磁盘" + ], + "zh-Hant": [ + "退出磁碟" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "storage" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "storage-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "eject-disk", + "kind": "static", + "staticActions": [ + { + "id": "eject-all", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "推出磁盘", + "推出所有可移动磁盘", + "disk", + "eject", + "volume", + "all" + ], + "systemImage": "eject", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift b/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift index bf74db8e..7bf5056f 100644 --- a/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift +++ b/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift @@ -21,10 +21,10 @@ private struct EmptyTrashPluginProvider: PluginProvider { @MainActor final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSurfaceLifecycleHandling, - PluginActionProviding + PluginActionProviding, PluginActionPermissionProviding { private enum PermissionID { - static let finderAutomation = "finder-automation" + static let automation = "automation" } private enum ActionID { static let empty = "empty" @@ -98,7 +98,7 @@ final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSur var permissionRequirements: [PluginPermissionRequirement] { [ PluginPermissionRequirement( - id: PermissionID.finderAutomation, + id: PermissionID.automation, kind: .automation, title: localization.string("permission.automation.title", defaultValue: "Finder 自动化"), description: localization.string( @@ -131,6 +131,13 @@ final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSur ] } + func permissionRequirementIDs(for actionKey: ActionKey) -> [String] { + guard actionKey.providerID == metadata.id, actionKey.actionID == ActionID.empty else { + return [] + } + return [PermissionID.automation] + } + func actionAvailability(for reference: ActionReference) -> ActionAvailability { guard reference.key.actionID == ActionID.empty else { return .unavailable(PluginKitLocalization.actionUnavailable) @@ -179,7 +186,7 @@ final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSur } func permissionState(for permissionID: String) -> PluginPermissionState { - guard permissionID == PermissionID.finderAutomation else { + guard permissionID == PermissionID.automation else { return PluginPermissionState(isGranted: true, footnote: nil) } return PluginPermissionState( @@ -189,7 +196,7 @@ final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSur } func handlePermissionAction(id: String) { - guard id == PermissionID.finderAutomation, + guard id == PermissionID.automation, let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else { return } diff --git a/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift b/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift index bf38701d..1f937b91 100644 --- a/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift +++ b/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift @@ -70,6 +70,11 @@ final class EmptyTrashPluginTests: XCTestCase { XCTAssertEqual(definition.externalInvocationPolicy, .confirmAlways) XCTAssertFalse(definition.capabilities.contains(.cancellable)) XCTAssertEqual(definition.executionTimeoutSeconds, 600) + XCTAssertEqual(plugin.permissionRequirements.map(\.id), ["automation"]) + XCTAssertEqual( + plugin.permissionRequirementIDs(for: definition.key), + ["automation"] + ) let result = try await plugin.beginAction( ActionInvocation(reference: reference, source: .test, mode: .background) diff --git a/Plugins/EmptyTrash/plugin.json b/Plugins/EmptyTrash/plugin.json index 56003419..2e117020 100644 --- a/Plugins/EmptyTrash/plugin.json +++ b/Plugins/EmptyTrash/plugin.json @@ -48,6 +48,10 @@ "summary": "清空垃圾桶中的所有項目" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +66,155 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], - "category": "storage" + "permissions": [ + "automation" + ], + "category": "storage", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "empty", + "trash", + "storage" + ], + "localizedSynonyms": { + "ar": [ + "إفراغ سلة المهملات" + ], + "de": [ + "Papierkorb leeren" + ], + "en": [ + "Empty Trash" + ], + "es": [ + "Vaciar papelera" + ], + "fr": [ + "Vider la corbeille" + ], + "ja": [ + "ゴミ箱を空にする" + ], + "ko": [ + "휴지통 비우기" + ], + "pt": [ + "Esvaziar Lixo" + ], + "ru": [ + "Очистить корзину" + ], + "zh-Hans": [ + "清空废纸篓" + ], + "zh-Hant": [ + "清空垃圾桶" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "storage" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "automation" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "storage-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "empty-trash", + "kind": "static", + "staticActions": [ + { + "id": "empty", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "清空废纸篓", + "清空废纸篓中的所有项目", + "trash", + "delete", + "empty" + ], + "systemImage": "trash", + "parameters": [], + "permissionIDs": [ + "automation" + ], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/FanControl/plugin.json b/Plugins/FanControl/plugin.json index 856a8ad3..3e4cfd44 100644 --- a/Plugins/FanControl/plugin.json +++ b/Plugins/FanControl/plugin.json @@ -48,6 +48,96 @@ "summary": "管理風扇轉速預設,支持自動、全速和自定義策略" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.install-helper.title": { + "ar": "تثبيت أداة مساعدة ذات صلاحيات مميّزة", + "de": "Privilegiertes Hilfsprogramm installieren", + "en": "Install Privileged Helper", + "es": "Instalar herramienta auxiliar con privilegios", + "fr": "Installer l’utilitaire privilégié", + "ja": "特権ヘルパーをインストール", + "ko": "권한 있는 도우미 설치", + "pt": "Instalar auxiliar privilegiado", + "ru": "Установить привилегированную службу", + "zh-Hans": "安装特权辅助工具", + "zh-Hant": "安裝特權輔助工具" + }, + "setup.install-helper.description": { + "ar": "عند الاستخدام الأول، يطلب MacTools تفويض المسؤول لتثبيت أداة مساعدة يملكها root وبوضع 4755 في /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper. تغيّر الأداة إعدادات مراوح النظام.", + "de": "Bei der ersten Verwendung fordert MacTools eine Administratorautorisierung an, um unter /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper ein root-eigenes Hilfsprogramm mit Modus 4755 zu installieren. Es ändert die Einstellungen der Systemlüfter.", + "en": "On first use, MacTools requests administrator authorization to install a root-owned, mode-4755 helper at /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper. The helper changes system fan settings.", + "es": "En el primer uso, MacTools solicita autorización de administrador para instalar en /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper una herramienta auxiliar propiedad de root y con modo 4755. La herramienta cambia los ajustes de los ventiladores del sistema.", + "fr": "Lors de la première utilisation, MacTools demande une autorisation d’administrateur pour installer dans /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper un utilitaire appartenant à root et doté du mode 4755. Cet utilitaire modifie les réglages des ventilateurs du système.", + "ja": "初回使用時に、MacTools は管理者認証を求め、root 所有かつモード 4755 のヘルパーを /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper にインストールします。このヘルパーはシステムファンの設定を変更します。", + "ko": "처음 사용할 때 MacTools는 관리자 인증을 요청하고 root 소유의 모드 4755 도우미를 /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper에 설치합니다. 이 도우미는 시스템 팬 설정을 변경합니다.", + "pt": "No primeiro uso, o MacTools solicita autorização de administrador para instalar em /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper um auxiliar pertencente ao root e com modo 4755. O auxiliar altera os ajustes das ventoinhas do sistema.", + "ru": "При первом использовании MacTools запрашивает авторизацию администратора, чтобы установить в /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper принадлежащую root службу с режимом 4755. Она изменяет настройки системных вентиляторов.", + "zh-Hans": "首次使用时,MacTools 会请求管理员授权,在 /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper 安装 root 所有且权限模式为 4755 的辅助工具,用于更改系统风扇设置。", + "zh-Hant": "首次使用時,MacTools 會要求管理員授權,在 /Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper 安裝 root 所有且權限模式為 4755 的輔助工具,用於更改系統風扇設定。" + }, + "setup.verify-hardware.title": { + "ar": "التحقق من توافق المراوح", + "de": "Steuerbare Lüfter prüfen", + "en": "Verify Controllable Fans", + "es": "Comprobar ventiladores controlables", + "fr": "Vérifier les ventilateurs contrôlables", + "ja": "制御可能なファンを確認", + "ko": "제어 가능한 팬 확인", + "pt": "Verificar ventoinhas controláveis", + "ru": "Проверить управляемые вентиляторы", + "zh-Hans": "确认风扇可控制", + "zh-Hant": "確認風扇可控制" + }, + "setup.verify-hardware.description": { + "ar": "يتطلب جهاز Mac يمكن قراءة مراوح نظامه والتحكم فيها عبر SMC.", + "de": "Erfordert einen Mac, dessen Systemlüfter über den SMC gelesen und gesteuert werden können.", + "en": "Requires a Mac whose system fans can be read and controlled through the SMC.", + "es": "Requiere un Mac cuyos ventiladores del sistema puedan leerse y controlarse mediante el SMC.", + "fr": "Nécessite un Mac dont les ventilateurs système peuvent être lus et contrôlés via le SMC.", + "ja": "SMC 経由でシステムファンを読み取り、制御できる Mac が必要です。", + "ko": "SMC를 통해 시스템 팬을 읽고 제어할 수 있는 Mac이 필요합니다.", + "pt": "Requer um Mac cujas ventoinhas do sistema possam ser lidas e controladas pelo SMC.", + "ru": "Требуется Mac, системные вентиляторы которого можно считывать и контролировать через SMC.", + "zh-Hans": "需要系统风扇可通过 SMC 读取和控制的 Mac。", + "zh-Hant": "需要系統風扇可透過 SMC 讀取和控制的 Mac。" + }, + "action.apply-preset.title": { + "ar": "تطبيق إعداد مروحة مسبق", "de": "Lüftervoreinstellung anwenden", + "en": "Apply Fan Preset", "es": "Aplicar preajuste de ventilador", + "fr": "Appliquer un préréglage de ventilateur", "ja": "ファンプリセットを適用", + "ko": "팬 사전 설정 적용", "pt": "Aplicar predefinição de ventoinha", + "ru": "Применить профиль вентилятора", "zh-Hans": "应用风扇预设", + "zh-Hant": "套用風扇預設" + }, + "action.apply-preset.description": { + "ar": "طبّق إستراتيجية سرعة المروحة من إعداد مسبق محفوظ.", + "de": "Wendet die Lüfterstrategie einer gespeicherten Voreinstellung an.", + "en": "Apply the fan-speed strategy from a saved preset.", + "es": "Aplica la estrategia de velocidad de un preajuste guardado.", + "fr": "Applique la stratégie de vitesse d’un préréglage enregistré.", + "ja": "保存済みプリセットのファン速度戦略を適用します。", + "ko": "저장된 사전 설정의 팬 속도 전략을 적용합니다.", + "pt": "Aplica a estratégia de velocidade de uma predefinição salva.", + "ru": "Применяет стратегию скорости из сохранённого профиля.", + "zh-Hans": "应用已存预设中的风扇转速策略。", + "zh-Hant": "套用已儲存預設中的風扇轉速策略。" + }, + "action.apply-preset.parameter-summary": { + "ar": "اختر إعداد المروحة المسبق المحفوظ لتطبيقه.", + "de": "Wähle die gespeicherte Lüftervoreinstellung aus, die angewendet werden soll.", + "en": "Select the saved fan preset to apply.", + "es": "Selecciona el preajuste de ventilador guardado que quieres aplicar.", + "fr": "Sélectionnez le préréglage de ventilateur enregistré à appliquer.", + "ja": "適用する保存済みファンプリセットを選択します。", + "ko": "적용할 저장된 팬 사전 설정을 선택하세요.", + "pt": "Selecione a predefinição de ventoinha salva que deseja aplicar.", + "ru": "Выберите сохранённый профиль вентилятора для применения.", + "zh-Hans": "选择要应用的已存风扇预设。", + "zh-Hant": "選擇要套用的已儲存風扇預設。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -68,5 +158,175 @@ "settings": "form" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "fan", + "control", + "system" + ], + "localizedSynonyms": { + "ar": [ + "التحكم بالمراوح" + ], + "de": [ + "Lüftersteuerung" + ], + "en": [ + "Fan Control" + ], + "es": [ + "Control de ventiladores" + ], + "fr": [ + "Contrôle des ventilateurs" + ], + "ja": [ + "ファン制御" + ], + "ko": [ + "팬 제어" + ], + "pt": [ + "Controle de ventoinhas" + ], + "ru": [ + "Управление вентиляторами" + ], + "zh-Hans": [ + "风扇控制" + ], + "zh-Hant": [ + "風扇控制" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "controllable system fans" + ], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "advanced", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "install-privileged-helper", + "title": "@productStrings.setup.install-helper.title", + "description": "@productStrings.setup.install-helper.description" + }, + { + "id": "verify-compatible-hardware", + "title": "@productStrings.setup.verify-hardware.title", + "description": "@productStrings.setup.verify-hardware.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "fan-control", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "apply-preset", + "title": "@productStrings.action.apply-preset.title", + "description": "@productStrings.action.apply-preset.description", + "entrySource": "saved-fan-presets", + "parameters": [ + { + "id": "preset", + "kind": "string", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.apply-preset.parameter-summary", + "localOnlyIdentity": false, + "keywords": [ + "fan", + "control", + "apply", + "preset", + "saved", + "presets" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ] + } + ] + } } diff --git a/Plugins/FixDamagedApp/plugin.json b/Plugins/FixDamagedApp/plugin.json index ad8a9475..6a612947 100644 --- a/Plugins/FixDamagedApp/plugin.json +++ b/Plugins/FixDamagedApp/plugin.json @@ -48,6 +48,10 @@ "summary": "移除隔離屬性,解決「已損壞」或「不受信任」提示" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,153 @@ "settings": "form" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "fix", + "damaged", + "app", + "apps", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "إصلاح التطبيقات التالفة" + ], + "de": [ + "Beschädigte Apps reparieren" + ], + "en": [ + "Fix Damaged Apps" + ], + "es": [ + "Reparar apps dañadas" + ], + "fr": [ + "Réparer les apps endommagées" + ], + "ja": [ + "破損したアプリを修復" + ], + "ko": [ + "손상된 앱 복구" + ], + "pt": [ + "Corrigir apps danificados" + ], + "ru": [ + "Исправить повреждённые приложения" + ], + "zh-Hans": [ + "修复损坏应用" + ], + "zh-Hant": [ + "修復損壞的 App" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "productivity-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "fix-damaged-app", + "kind": "static", + "staticActions": [ + { + "id": "choose-app", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "修复损坏应用", + "移除隔离属性,解决「已损坏」或「不受信任」提示", + "app", + "quarantine", + "fix", + "damaged", + "choose" + ], + "systemImage": "wrench.and.screwdriver.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/HideNotch/plugin.json b/Plugins/HideNotch/plugin.json index 756b18d1..5d36726a 100644 --- a/Plugins/HideNotch/plugin.json +++ b/Plugins/HideNotch/plugin.json @@ -48,6 +48,15 @@ "summary": "自動遮擋劉海螢幕頂部區域" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +72,190 @@ "settings": "none" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "hide", + "notch", + "display" + ], + "localizedSynonyms": { + "ar": [ + "إخفاء النتوء" + ], + "de": [ + "Notch ausblenden" + ], + "en": [ + "Hide Notch" + ], + "es": [ + "Ocultar muesca" + ], + "fr": [ + "Masquer l’encoche" + ], + "ja": [ + "ノッチを隠す" + ], + "ko": [ + "노치 숨기기" + ], + "pt": [ + "Ocultar entalhe" + ], + "ru": [ + "Скрыть вырез" + ], + "zh-Hans": [ + "隐藏刘海" + ], + "zh-Hant": [ + "隱藏瀏海" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "hide-notch", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "隐藏刘海", + "自动遮挡刘海屏顶部区域", + "notch", + "hide", + "toggle" + ], + "systemImage": "rectangle.topthird.inset.filled", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "隐藏刘海", + "自动遮挡刘海屏顶部区域", + "notch", + "hide", + "set", + "enabled" + ], + "systemImage": "rectangle.topthird.inset.filled", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/Homebrew/plugin.json b/Plugins/Homebrew/plugin.json index ded91eb6..07b19250 100644 --- a/Plugins/Homebrew/plugin.json +++ b/Plugins/Homebrew/plugin.json @@ -48,6 +48,20 @@ "summary": "管理 Homebrew 包、軟體倉庫並執行系統診斷" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.update.title": "@localizable.detail.diagnostics.update.title", + "action.update.description": "@localizable.detail.diagnostics.update.desc", + "action.upgrade-all.title": "@localizable.detail.diagnostics.upgrade.title", + "action.upgrade-all.description": "@localizable.detail.diagnostics.upgrade.desc", + "action.doctor.title": "@localizable.detail.diagnostics.doctor.title", + "action.doctor.description": "@localizable.detail.diagnostics.doctor.desc", + "action.cleanup.title": "@localizable.detail.diagnostics.cleanup.title", + "action.cleanup.description": "@localizable.detail.diagnostics.cleanup.desc" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +77,245 @@ "settings": "workspace" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "homebrew", + "manager", + "system" + ], + "localizedSynonyms": { + "ar": [ + "Homebrew" + ], + "de": [ + "Homebrew" + ], + "en": [ + "Homebrew Manager" + ], + "es": [ + "Homebrew" + ], + "fr": [ + "Homebrew" + ], + "ja": [ + "Homebrew" + ], + "ko": [ + "Homebrew" + ], + "pt": [ + "Homebrew" + ], + "ru": [ + "Homebrew" + ], + "zh-Hans": [ + "Homebrew 管理" + ], + "zh-Hant": [ + "Homebrew 管理" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [ + "brew" + ], + "permissionIDs": [], + "setupComplexity": "advanced", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "required", + "networkDomains": [ + "brew.sh", + "formulae.brew.sh", + "github.com", + "githubusercontent.com" + ], + "allowsUserConfiguredDomains": true, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "homebrew", + "kind": "static", + "staticActions": [ + { + "id": "update", + "title": "@productStrings.action.update.title", + "description": "@productStrings.action.update.description", + "keywords": [ + "Homebrew", + "更新软件源", + "brew", + "homebrew", + "update" + ], + "systemImage": "arrow.clockwise.circle.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "automatic-rule", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + }, + { + "id": "upgrade-all", + "title": "@productStrings.action.upgrade-all.title", + "description": "@productStrings.action.upgrade-all.description", + "keywords": [ + "Homebrew", + "更新所有包", + "brew", + "homebrew", + "upgrade", + "all" + ], + "systemImage": "arrow.up.circle.fill", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + }, + { + "id": "doctor", + "title": "@productStrings.action.doctor.title", + "description": "@productStrings.action.doctor.description", + "keywords": [ + "Homebrew", + "运行诊断", + "brew", + "homebrew", + "doctor" + ], + "systemImage": "heart.text.square.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "automatic-rule", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + }, + { + "id": "cleanup", + "title": "@productStrings.action.cleanup.title", + "description": "@productStrings.action.cleanup.description", + "keywords": [ + "Homebrew", + "清理缓存", + "brew", + "homebrew", + "cleanup" + ], + "systemImage": "trash.circle.fill", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift b/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift index 05c57450..7e17126d 100644 --- a/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift +++ b/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift @@ -4,6 +4,16 @@ import MacToolsPluginKit @MainActor final class IPOverviewPluginTests: XCTestCase { + func testManifestActionsMatchRuntimePolicy() throws { + let plugin = IPOverviewPlugin(viewModel: IPOverviewViewModel(storage: IPOverviewPluginTestStorage())) + + try PluginManifestActionAssertions.assertConsistency( + pluginDirectoryName: "IPOverview", + definitions: plugin.actionDefinitions, + permissionIDs: { _ in [] } + ) + } + func testCanonicalCopyActionsRefreshBeforeCopyingCurrentAddresses() async throws { let pasteboard = NSPasteboard(name: .init("IPOverviewPluginTests.\(UUID().uuidString)")) let snapshot = IPOverviewSnapshot( diff --git a/Plugins/IPOverview/plugin.json b/Plugins/IPOverview/plugin.json index 06dc8fc5..4d4bcf29 100644 --- a/Plugins/IPOverview/plugin.json +++ b/Plugins/IPOverview/plugin.json @@ -48,6 +48,112 @@ "summary": "查看出口 IP、本地 IP、歸屬地與網路測速摘要" } }, + "productStrings": { + "long-description": { + "ar": "اعرض عناوين IP المحلية والعامة والموقع وجودة الشبكة، وانسخ العناوين الحالية بسرعة.", + "de": "Zeige lokale und öffentliche IP-Adressen, Standort und Netzwerkqualität an und kopiere aktuelle Adressen schnell.", + "en": "Inspect local and public IP addresses, location, and network quality, then quickly copy current addresses.", + "es": "Consulta las IP locales y públicas, la ubicación y la calidad de red, y copia rápidamente las direcciones actuales.", + "fr": "Consultez les adresses IP locales et publiques, la localisation et la qualité du réseau, puis copiez les adresses actuelles.", + "ja": "ローカル/パブリック IP、所在地、ネットワーク品質を確認し、現在のアドレスをすばやくコピーします。", + "ko": "로컬 및 공인 IP 주소, 위치와 네트워크 품질을 확인하고 현재 주소를 빠르게 복사합니다.", + "pt": "Consulte IPs locais e públicos, localização e qualidade da rede e copie rapidamente os endereços atuais.", + "ru": "Просматривайте локальные и публичные IP-адреса, местоположение и качество сети и быстро копируйте текущие адреса.", + "zh-Hans": "查看本地与公网 IP、归属地和网络质量,并快速复制当前地址。", + "zh-Hant": "查看本機與公網 IP、歸屬地和網路品質,並快速複製目前位址。" + }, + "example-copy-public-address": { + "ar": "حدّث عنوان IP العام وانسخه قبل إعداد قائمة سماح.", + "de": "Aktualisiere und kopiere deine öffentliche IP vor dem Einrichten einer Zulassungsliste.", + "en": "Refresh and copy your public IP before configuring an allowlist.", + "es": "Actualiza y copia tu IP pública antes de configurar una lista de permitidos.", + "fr": "Actualisez et copiez votre IP publique avant de configurer une liste d’autorisation.", + "ja": "許可リストを設定する前にパブリック IP を更新してコピーします。", + "ko": "허용 목록을 설정하기 전에 공인 IP를 새로 고치고 복사합니다.", + "pt": "Atualize e copie o IP público antes de configurar uma lista de permissões.", + "ru": "Обновите и скопируйте публичный IP перед настройкой списка разрешений.", + "zh-Hans": "配置允许列表前刷新并复制公网 IP。", + "zh-Hant": "設定允許清單前重新整理並複製公網 IP。" + }, + "use-case-inspect-connectivity": { + "ar": "فحص اتصال الشبكة", + "de": "Netzwerkverbindung prüfen", + "en": "Inspect network connectivity", + "es": "Comprobar la conectividad de red", + "fr": "Examiner la connectivité réseau", + "ja": "ネットワーク接続を確認", + "ko": "네트워크 연결 확인", + "pt": "Verificar conectividade da rede", + "ru": "Проверить сетевое подключение", + "zh-Hans": "检查网络连接", + "zh-Hant": "檢查網路連線" + }, + "action-ip-overview-copy-local-ipv4-title": { + "ar": "نسخ IPv4 المحلي", + "de": "Lokale IPv4 kopieren", + "en": "Copy Local IPv4", + "es": "Copiar IPv4 local", + "fr": "Copier l’IPv4 locale", + "ja": "ローカル IPv4 をコピー", + "ko": "로컬 IPv4 복사", + "pt": "Copiar IPv4 local", + "ru": "Скопировать локальный IPv4", + "zh-Hans": "复制本地 IPv4", + "zh-Hant": "複製本機 IPv4" + }, + "action-ip-overview-copy-local-ipv4-description": { + "ar": "حدّث وانسخ عنوان IPv4 المحلي المفضل.", + "de": "Aktualisiert und kopiert die bevorzugte lokale IPv4-Adresse.", + "en": "Refresh and copy the preferred local IPv4 address.", + "es": "Actualiza y copia la dirección IPv4 local preferida.", + "fr": "Actualise et copie l’adresse IPv4 locale préférée.", + "ja": "優先ローカル IPv4 アドレスを更新してコピーします。", + "ko": "기본 로컬 IPv4 주소를 새로 고치고 복사합니다.", + "pt": "Atualiza e copia o endereço IPv4 local preferido.", + "ru": "Обновляет и копирует предпочтительный локальный IPv4-адрес.", + "zh-Hans": "刷新并复制首选本地 IPv4 地址。", + "zh-Hant": "重新整理並複製偏好的本機 IPv4 位址。" + }, + "action-ip-overview-copy-public-ipv4-title": { + "ar": "نسخ IPv4 العام", + "de": "Öffentliche IPv4 kopieren", + "en": "Copy Public IPv4", + "es": "Copiar IPv4 pública", + "fr": "Copier l’IPv4 publique", + "ja": "パブリック IPv4 をコピー", + "ko": "공인 IPv4 복사", + "pt": "Copiar IPv4 público", + "ru": "Скопировать публичный IPv4", + "zh-Hans": "复制公网 IPv4", + "zh-Hant": "複製公網 IPv4" + }, + "action-ip-overview-copy-public-ipv4-description": { + "ar": "حدّث وانسخ عنوان IPv4 العام الحالي.", + "de": "Aktualisiert und kopiert die aktuelle öffentliche IPv4-Adresse.", + "en": "Refresh and copy the current public IPv4 address.", + "es": "Actualiza y copia la dirección IPv4 pública actual.", + "fr": "Actualise et copie l’adresse IPv4 publique actuelle.", + "ja": "現在のパブリック IPv4 アドレスを更新してコピーします。", + "ko": "현재 공인 IPv4 주소를 새로 고치고 복사합니다.", + "pt": "Atualiza e copia o endereço IPv4 público atual.", + "ru": "Обновляет и копирует текущий публичный IPv4-адрес.", + "zh-Hans": "刷新并复制当前公网 IPv4 地址。", + "zh-Hant": "重新整理並複製目前公網 IPv4 位址。" + }, + "setup-missing-dependency-help": { + "ar": "يتطلب اكتشاف عنوان IP العام اتصالًا بالشبكة.", + "de": "Die Ermittlung der öffentlichen IP erfordert eine Netzwerkverbindung.", + "en": "Public IP detection requires a network connection.", + "es": "La detección de la IP pública requiere conexión de red.", + "fr": "La détection de l’IP publique nécessite une connexion réseau.", + "ja": "パブリック IP の検出にはネットワーク接続が必要です。", + "ko": "공인 IP를 감지하려면 네트워크 연결이 필요합니다.", + "pt": "A deteção do IP público requer uma ligação de rede.", + "ru": "Для определения публичного IP требуется подключение к сети.", + "zh-Hans": "检测公网 IP 需要网络连接。", + "zh-Hant": "偵測公網 IP 需要網路連線。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +169,241 @@ "settings": "workspace" }, "permissions": [], - "category": "monitoring" + "category": "monitoring", + "presentation": { + "longDescription": "@productStrings.long-description", + "examples": [ + { + "id": "copy-public-address", + "text": "@productStrings.example-copy-public-address" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "IP address", + "network diagnostics", + "public IP", + "local IP" + ], + "localizedSynonyms": { + "ar": [ + "عنوان IP", + "تشخيص الشبكة" + ], + "de": [ + "IP-Adresse", + "Netzwerkdiagnose" + ], + "en": [ + "IP address", + "network check" + ], + "es": [ + "dirección IP", + "diagnóstico de red" + ], + "fr": [ + "adresse IP", + "diagnostic réseau" + ], + "ja": [ + "IP アドレス", + "ネットワーク診断" + ], + "ko": [ + "IP 주소", + "네트워크 진단" + ], + "pt": [ + "endereço IP", + "diagnóstico de rede" + ], + "ru": [ + "IP-адрес", + "диагностика сети" + ], + "zh-Hans": [ + "IP 地址", + "网络诊断" + ], + "zh-Hant": [ + "IP 位址", + "網路診斷" + ] + }, + "useCases": [ + { + "id": "inspect-connectivity", + "title": "@productStrings.use-case-inspect-connectivity" + } + ], + "goalCategories": [ + "networking", + "diagnostics", + "privacy" + ], + "relatedPluginIDs": [ + "system-status" + ], + "alternativePluginIDs": [] + }, + "requirements": { + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "network-connection" + ], + "applications": [], + "executables": [ + "/usr/bin/networkQuality" + ], + "permissionIDs": [], + "setupComplexity": "simple", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "local-network-addresses", + "public-network-addresses", + "network-location", + "connectivity-results", + "custom-test-targets" + ], + "dataPersisted": [ + "cached-network-state", + "privacy-display-preference", + "custom-test-targets" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "required", + "networkDomains": [ + "api.live.bilibili.com", + "cip.cc", + "myip.ipip.net", + "ipv4.netart.cn", + "ipv6.ddnspod.com", + "ipv6.netart.cn", + "4.ipcheck.ing", + "api4.ipify.org", + "ifconfig.me", + "6.ipcheck.ing", + "api6.ipify.org", + "ipapi.co", + "ipwho.is", + "res.wx.qq.com", + "www.google.com", + "www.cloudflare.com", + "www.youtube.com", + "github.com", + "chatgpt.com", + "edns.ip-api.com", + "ipv4.surfsharkdns.com", + "stun.l.google.com", + "stun.voip.blackberry.com", + "global.stun.twilio.com", + "stun.cloudflare.com", + "mensura.cdn-apple.com" + ], + "allowsUserConfiguredDomains": true, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": true + }, + "actions": { + "providers": [ + { + "id": "ip-overview", + "kind": "static", + "staticActions": [ + { + "id": "copy-local-ipv4", + "title": "@productStrings.action-ip-overview-copy-local-ipv4-title", + "description": "@productStrings.action-ip-overview-copy-local-ipv4-description", + "keywords": [ + "IP", + "local address", + "copy" + ], + "systemImage": "doc.on.doc", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "copy-public-ipv4", + "title": "@productStrings.action-ip-overview-copy-public-ipv4-title", + "description": "@productStrings.action-ip-overview-copy-public-ipv4-description", + "keywords": [ + "IP", + "public address", + "copy" + ], + "systemImage": "doc.on.doc", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + }, + "setup": { + "steps": [], + "suggestedTestAction": { + "providerID": "ip-overview", + "actionID": "copy-public-ipv4" + }, + "optionalSurfaces": [ + "global-shortcut", + "workflow", + "automatic-rule", + "action-grid" + ], + "missingDependencyHelp": "@productStrings.setup-missing-dependency-help" + }, + "relationships": { + "relatedPluginIDs": [ + "system-status" + ], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/InputRemapping/plugin.json b/Plugins/InputRemapping/plugin.json index e6601b10..b767804f 100644 --- a/Plugins/InputRemapping/plugin.json +++ b/Plugins/InputRemapping/plugin.json @@ -3,25 +3,195 @@ "displayName": "自定义快捷操作:键盘、触控板、鼠标", "summary": "将输入映射为操作", "localizedMetadata": { - "ar": { "displayName": "اختصارات مخصصة: لوحة المفاتيح، لوحة التتبع، الماوس", "summary": "اربط الإدخالات بالإجراءات." }, - "de": { "displayName": "Eigene Kurzbefehle: Tastatur, Trackpad, Maus", "summary": "Eingaben Aktionen zuordnen." }, - "en": { "displayName": "Custom Shortcuts: Keyboard, Trackpad, Mouse", "summary": "Map inputs to actions" }, - "es": { "displayName": "Atajos personalizados: Teclado, Panel táctil, Ratón", "summary": "Asigna entradas a acciones." }, - "fr": { "displayName": "Raccourcis personnalisés : Clavier, Pavé tactile, Souris", "summary": "Associer les entrées aux actions." }, - "ja": { "displayName": "カスタムショートカット:キーボード、トラックパッド、マウス", "summary": "入力をアクションに割り当てます。" }, - "ko": { "displayName": "사용자화 단축키: 키보드, 트랙패드, 마우스", "summary": "입력을 동작에 연결합니다." }, - "pt": { "displayName": "Atalhos personalizados: Teclado, Trackpad, Rato", "summary": "Associe entradas a ações." }, - "ru": { "displayName": "Пользовательские сочетания: Клавиатура, Трекпад, Мышь", "summary": "Сопоставляйте ввод с действиями." }, - "zh-Hans": { "displayName": "自定义快捷操作:键盘、触控板、鼠标", "summary": "将输入映射为操作" }, - "zh-Hant": { "displayName": "自訂快速操作:鍵盤、觸控板、滑鼠", "summary": "將輸入對應至操作。" } + "ar": { + "displayName": "اختصارات مخصصة: لوحة المفاتيح، لوحة التتبع، الماوس", + "summary": "اربط الإدخالات بالإجراءات." + }, + "de": { + "displayName": "Eigene Kurzbefehle: Tastatur, Trackpad, Maus", + "summary": "Eingaben Aktionen zuordnen." + }, + "en": { + "displayName": "Custom Shortcuts: Keyboard, Trackpad, Mouse", + "summary": "Map inputs to actions" + }, + "es": { + "displayName": "Atajos personalizados: Teclado, Panel táctil, Ratón", + "summary": "Asigna entradas a acciones." + }, + "fr": { + "displayName": "Raccourcis personnalisés : Clavier, Pavé tactile, Souris", + "summary": "Associer les entrées aux actions." + }, + "ja": { + "displayName": "カスタムショートカット:キーボード、トラックパッド、マウス", + "summary": "入力をアクションに割り当てます。" + }, + "ko": { + "displayName": "사용자화 단축키: 키보드, 트랙패드, 마우스", + "summary": "입력을 동작에 연결합니다." + }, + "pt": { + "displayName": "Atalhos personalizados: Teclado, Trackpad, Rato", + "summary": "Associe entradas a ações." + }, + "ru": { + "displayName": "Пользовательские сочетания: Клавиатура, Трекпад, Мышь", + "summary": "Сопоставляйте ввод с действиями." + }, + "zh-Hans": { + "displayName": "自定义快捷操作:键盘、触控板、鼠标", + "summary": "将输入映射为操作" + }, + "zh-Hant": { + "displayName": "自訂快速操作:鍵盤、觸控板、滑鼠", + "summary": "將輸入對應至操作。" + } + }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" }, "version": "1.0.1", "minHostVersion": "1.2.0", "pluginKitVersion": 5, "bundleRelativePath": "InputRemapping.bundle", "factoryClass": "InputRemappingPlugin.InputRemappingPluginFactory", - "build": { "project": "../../MacTools.xcodeproj", "scheme": "InputRemappingPlugin" }, - "capabilities": { "primaryPanel": true, "componentPanel": false, "settings": "workspace" }, - "permissions": ["accessibility", "inputMonitoring"], - "category": "productivity" + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "InputRemappingPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "settings": "workspace" + }, + "permissions": [ + "accessibility", + "inputMonitoring" + ], + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "input", + "remapping", + "custom", + "shortcuts", + "keyboard", + "trackpad", + "mouse", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "اختصارات مخصصة: لوحة المفاتيح، لوحة التتبع، الماوس" + ], + "de": [ + "Eigene Kurzbefehle: Tastatur, Trackpad, Maus" + ], + "en": [ + "Custom Shortcuts: Keyboard, Trackpad, Mouse" + ], + "es": [ + "Atajos personalizados: Teclado, Panel táctil, Ratón" + ], + "fr": [ + "Raccourcis personnalisés : Clavier, Pavé tactile, Souris" + ], + "ja": [ + "カスタムショートカット:キーボード、トラックパッド、マウス" + ], + "ko": [ + "사용자화 단축키: 키보드, 트랙패드, 마우스" + ], + "pt": [ + "Atalhos personalizados: Teclado, Trackpad, Rato" + ], + "ru": [ + "Пользовательские сочетания: Клавиатура, Трекпад, Мышь" + ], + "zh-Hans": [ + "自定义快捷操作:键盘、触控板、鼠标" + ], + "zh-Hant": [ + "自訂快速操作:鍵盤、觸控板、滑鼠" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility", + "inputMonitoring" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "input-events" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/KeepAwake/Resources/Localizable.xcstrings b/Plugins/KeepAwake/Resources/Localizable.xcstrings index 96cea7e9..2e52585b 100644 --- a/Plugins/KeepAwake/Resources/Localizable.xcstrings +++ b/Plugins/KeepAwake/Resources/Localizable.xcstrings @@ -1843,6 +1843,146 @@ } } } + }, + "action.manifest.startForDuration.title": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "منع الإسبات لمدة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Für einen Zeitraum wach halten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Awake for a Duration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener activo durante un periodo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Maintenir actif pendant une durée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指定時間スリープを防ぐ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지정 시간 동안 잠자기 방지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manter ativo durante um período" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не переходить в сон заданное время" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在指定时长内阻止休眠" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在指定時長內阻止休眠" + } + } + } + }, + "action.manifest.startForDuration.description": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُبقي Mac نشطًا لعدد الدقائق المطلوب." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hält den Mac für die angegebene Anzahl von Minuten wach." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep the Mac awake for the requested number of minutes." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantiene el Mac activo durante el número de minutos indicado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Maintient le Mac actif pendant le nombre de minutes demandé." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指定した分数だけ Mac のスリープを防ぎます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "요청한 시간(분) 동안 Mac이 잠자기 상태로 전환되지 않도록 합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mantém o Mac ativo durante o número de minutos indicado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не дает Mac переходить в сон указанное количество минут." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "让 Mac 在指定分钟数内保持唤醒。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讓 Mac 在指定分鐘數內保持喚醒。" + } + } + } } }, "version": "1.0" diff --git a/Plugins/KeepAwake/plugin.json b/Plugins/KeepAwake/plugin.json index 643c6045..22c0618a 100644 --- a/Plugins/KeepAwake/plugin.json +++ b/Plugins/KeepAwake/plugin.json @@ -48,6 +48,18 @@ "summary": "保持 Mac 喚醒;可選保持螢幕常亮或讓螢幕工具繼續運作。MacBook 闔蓋運作要求連接電源" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description", + "action.start-for-duration.title": "@localizable.action.manifest.startForDuration.title", + "action.start-for-duration.description": "@localizable.action.manifest.startForDuration.description", + "action.start-for-duration.parameter-summary": "@localizable.action.manifest.startForDuration.description" + }, "version": "1.3.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -68,5 +80,231 @@ "settings": "form" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "keep", + "awake", + "system" + ], + "localizedSynonyms": { + "ar": [ + "منع السكون" + ], + "de": [ + "Wach halten" + ], + "en": [ + "Keep Awake" + ], + "es": [ + "Mantener activo" + ], + "fr": [ + "Empêcher la suspension" + ], + "ja": [ + "スリープを防止" + ], + "ko": [ + "잠자기 방지" + ], + "pt": [ + "Manter ativo" + ], + "ru": [ + "Не давать уснуть" + ], + "zh-Hans": [ + "阻止休眠" + ], + "zh-Hant": [ + "防止睡眠" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "keep-awake", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "阻止休眠", + "keep", + "awake", + "toggle" + ], + "systemImage": "moon", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "阻止休眠", + "保持 Mac 唤醒;可选保持屏幕常亮或让屏幕工具继续工作。MacBook 合盖运行要求连接电源", + "keep", + "awake", + "set", + "enabled" + ], + "systemImage": "moon", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "start-for-duration", + "title": "@productStrings.action.start-for-duration.title", + "description": "@productStrings.action.start-for-duration.description", + "keywords": [ + "阻止休眠", + "30min", + "1h", + "2h", + "5h", + "keep", + "awake", + "start", + "for", + "duration" + ], + "systemImage": "moon", + "parameters": [ + { + "id": "duration-seconds", + "kind": "integer", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.start-for-duration.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/LaunchControl/plugin.json b/Plugins/LaunchControl/plugin.json index d2be66e4..6475a23b 100644 --- a/Plugins/LaunchControl/plugin.json +++ b/Plugins/LaunchControl/plugin.json @@ -48,6 +48,76 @@ "summary": "查看和管理 launchctl 啟動項" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.start-favorite.title": "@localizable.managedAction.start.title", + "action.start-favorite.description": { + "ar": "ابدأ عنصر التشغيل المفضل المحدد.", "de": "Startet das ausgewählte favorisierte Startelement.", + "en": "Start the selected favorite launch item.", "es": "Inicia el elemento de inicio favorito seleccionado.", + "fr": "Démarre l’élément de lancement favori sélectionné.", "ja": "選択したお気に入りの起動項目を開始します。", + "ko": "선택한 즐겨찾기 시작 항목을 시작합니다.", "pt": "Inicia o item de inicialização favorito selecionado.", + "ru": "Запускает выбранный избранный элемент запуска.", "zh-Hans": "启动所选的收藏启动项。", + "zh-Hant": "啟動所選的收藏啟動項目。" + }, + "action.start-favorite.parameter-summary": { + "ar": "اختر عنصر التشغيل المفضل المخزن على هذا الـ Mac لبدئه.", + "de": "Wähle das auf diesem Mac gespeicherte favorisierte Startelement aus, das gestartet werden soll.", + "en": "Select the favorite launch item stored on this Mac to start.", + "es": "Selecciona el elemento de inicio favorito guardado en este Mac que quieres iniciar.", + "fr": "Sélectionnez l’élément de lancement favori enregistré sur ce Mac à démarrer.", + "ja": "この Mac に保存されているお気に入りの起動項目から、開始する項目を選択します。", + "ko": "이 Mac에 저장된 즐겨찾기 시작 항목 중 시작할 항목을 선택하세요.", + "pt": "Selecione o item de inicialização favorito salvo neste Mac para iniciar.", + "ru": "Выберите сохранённый на этом Mac избранный элемент запуска, который нужно запустить.", + "zh-Hans": "选择存储在此 Mac 上、要启动的收藏启动项。", + "zh-Hant": "選擇儲存在此 Mac 上、要啟動的收藏啟動項目。" + }, + "action.stop-favorite.title": "@localizable.managedAction.stop.title", + "action.stop-favorite.description": { + "ar": "أوقف عنصر التشغيل المفضل المحدد.", "de": "Stoppt das ausgewählte favorisierte Startelement.", + "en": "Stop the selected favorite launch item.", "es": "Detiene el elemento de inicio favorito seleccionado.", + "fr": "Arrête l’élément de lancement favori sélectionné.", "ja": "選択したお気に入りの起動項目を停止します。", + "ko": "선택한 즐겨찾기 시작 항목을 중지합니다.", "pt": "Interrompe o item de inicialização favorito selecionado.", + "ru": "Останавливает выбранный избранный элемент запуска.", "zh-Hans": "停止所选的收藏启动项。", + "zh-Hant": "停止所選的收藏啟動項目。" + }, + "action.stop-favorite.parameter-summary": { + "ar": "اختر عنصر التشغيل المفضل المخزن على هذا الـ Mac لإيقافه.", + "de": "Wähle das auf diesem Mac gespeicherte favorisierte Startelement aus, das gestoppt werden soll.", + "en": "Select the favorite launch item stored on this Mac to stop.", + "es": "Selecciona el elemento de inicio favorito guardado en este Mac que quieres detener.", + "fr": "Sélectionnez l’élément de lancement favori enregistré sur ce Mac à arrêter.", + "ja": "この Mac に保存されているお気に入りの起動項目から、停止する項目を選択します。", + "ko": "이 Mac에 저장된 즐겨찾기 시작 항목 중 중지할 항목을 선택하세요.", + "pt": "Selecione o item de inicialização favorito salvo neste Mac para interromper.", + "ru": "Выберите сохранённый на этом Mac избранный элемент запуска, который нужно остановить.", + "zh-Hans": "选择存储在此 Mac 上、要停止的收藏启动项。", + "zh-Hant": "選擇儲存在此 Mac 上、要停止的收藏啟動項目。" + }, + "action.restart-favorite.title": "@localizable.managedAction.restart.title", + "action.restart-favorite.description": { + "ar": "أعد تشغيل عنصر التشغيل المفضل المحدد.", "de": "Startet das ausgewählte favorisierte Startelement neu.", + "en": "Restart the selected favorite launch item.", "es": "Reinicia el elemento de inicio favorito seleccionado.", + "fr": "Redémarre l’élément de lancement favori sélectionné.", "ja": "選択したお気に入りの起動項目を再起動します。", + "ko": "선택한 즐겨찾기 시작 항목을 다시 시작합니다.", "pt": "Reinicia o item de inicialização favorito selecionado.", + "ru": "Перезапускает выбранный избранный элемент запуска.", "zh-Hans": "重新启动所选的收藏启动项。", + "zh-Hant": "重新啟動所選的收藏啟動項目。" + }, + "action.restart-favorite.parameter-summary": { + "ar": "اختر عنصر التشغيل المفضل المخزن على هذا الـ Mac لإعادة تشغيله.", + "de": "Wähle das auf diesem Mac gespeicherte favorisierte Startelement aus, das neu gestartet werden soll.", + "en": "Select the favorite launch item stored on this Mac to restart.", + "es": "Selecciona el elemento de inicio favorito guardado en este Mac que quieres reiniciar.", + "fr": "Sélectionnez l’élément de lancement favori enregistré sur ce Mac à redémarrer.", + "ja": "この Mac に保存されているお気に入りの起動項目から、再起動する項目を選択します。", + "ko": "이 Mac에 저장된 즐겨찾기 시작 항목 중 다시 시작할 항목을 선택하세요.", + "pt": "Selecione o item de inicialização favorito salvo neste Mac para reiniciar.", + "ru": "Выберите сохранённый на этом Mac избранный элемент запуска, который нужно перезапустить.", + "zh-Hans": "选择存储在此 Mac 上、要重新启动的收藏启动项。", + "zh-Hant": "選擇儲存在此 Mac 上、要重新啟動的收藏啟動項目。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +133,230 @@ "settings": "workspace" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "launch", + "control", + "items", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "عناصر التشغيل" + ], + "de": [ + "Startelemente" + ], + "en": [ + "Launch Items" + ], + "es": [ + "Elementos de inicio" + ], + "fr": [ + "Éléments de lancement" + ], + "ja": [ + "起動項目" + ], + "ko": [ + "시작 항목" + ], + "pt": [ + "Itens de inicialização" + ], + "ru": [ + "Элементы запуска" + ], + "zh-Hans": [ + "启动项" + ], + "zh-Hant": [ + "啟動項目" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "productivity-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "launch-control", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "start-favorite", + "title": "@productStrings.action.start-favorite.title", + "description": "@productStrings.action.start-favorite.description", + "entrySource": "favorite-launch-items", + "parameters": [ + { + "id": "item-id", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + } + ], + "parameterSummary": "@productStrings.action.start-favorite.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "launch", + "control", + "start", + "favorite", + "items" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "automatic-rule", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + }, + { + "id": "stop-favorite", + "title": "@productStrings.action.stop-favorite.title", + "description": "@productStrings.action.stop-favorite.description", + "entrySource": "favorite-launch-items", + "parameters": [ + { + "id": "item-id", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + } + ], + "parameterSummary": "@productStrings.action.stop-favorite.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "launch", + "control", + "stop", + "favorite", + "items" + ], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + }, + { + "id": "restart-favorite", + "title": "@productStrings.action.restart-favorite.title", + "description": "@productStrings.action.restart-favorite.description", + "entrySource": "favorite-launch-items", + "parameters": [ + { + "id": "item-id", + "kind": "string", + "isRequired": true, + "portability": "localOnly" + } + ], + "parameterSummary": "@productStrings.action.restart-favorite.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "launch", + "control", + "restart", + "favorite", + "items" + ], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ] + } + ] + } } diff --git a/Plugins/Launchpad/plugin.json b/Plugins/Launchpad/plugin.json index e3f578ea..3ea00732 100644 --- a/Plugins/Launchpad/plugin.json +++ b/Plugins/Launchpad/plugin.json @@ -48,6 +48,10 @@ "summary": "用全域快速鍵或選單列打開 App 網格,搜尋並啟動。" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.2.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,149 @@ "settings": "form" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "launchpad", + "system" + ], + "localizedSynonyms": { + "ar": [ + "Launchpad" + ], + "de": [ + "Launchpad" + ], + "en": [ + "Launchpad" + ], + "es": [ + "Launchpad" + ], + "fr": [ + "Launchpad" + ], + "ja": [ + "Launchpad" + ], + "ko": [ + "Launchpad" + ], + "pt": [ + "Launchpad" + ], + "ru": [ + "Launchpad" + ], + "zh-Hans": [ + "启动台" + ], + "zh-Hant": [ + "啟動台" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "launchpad", + "kind": "static", + "staticActions": [ + { + "id": "toggleLaunchpad", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "启动台", + "打开启动台", + "Launchpad", + "launchpad", + "togglelaunchpad" + ], + "systemImage": "square.grid.3x3.fill", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/LockScreen/plugin.json b/Plugins/LockScreen/plugin.json index da101e62..a905931b 100644 --- a/Plugins/LockScreen/plugin.json +++ b/Plugins/LockScreen/plugin.json @@ -48,6 +48,10 @@ "summary": "一鍵立即鎖定螢幕,進入密碼解鎖界面" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,150 @@ "settings": "none" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "lock", + "screen", + "system" + ], + "localizedSynonyms": { + "ar": [ + "قفل الشاشة" + ], + "de": [ + "Bildschirm sperren" + ], + "en": [ + "Lock Screen" + ], + "es": [ + "Bloquear pantalla" + ], + "fr": [ + "Verrouiller l’écran" + ], + "ja": [ + "画面をロック" + ], + "ko": [ + "화면 잠그기" + ], + "pt": [ + "Bloquear tela" + ], + "ru": [ + "Заблокировать экран" + ], + "zh-Hans": [ + "锁定屏幕" + ], + "zh-Hant": [ + "鎖定螢幕" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "lock-screen", + "kind": "static", + "staticActions": [ + { + "id": "execute", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "锁定屏幕", + "立即锁定屏幕", + "lock", + "screen", + "execute" + ], + "systemImage": "lock", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/MicrophoneMute/plugin.json b/Plugins/MicrophoneMute/plugin.json index da38028b..ea95fb22 100644 --- a/Plugins/MicrophoneMute/plugin.json +++ b/Plugins/MicrophoneMute/plugin.json @@ -48,6 +48,15 @@ "summary": "快速靜音或恢復預設麥克風輸入" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +72,188 @@ "settings": "none" }, "permissions": [], - "category": "audio" + "category": "audio", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "microphone", + "mute", + "audio" + ], + "localizedSynonyms": { + "ar": [ + "كتم الميكروفون" + ], + "de": [ + "Mikrofon stummschalten" + ], + "en": [ + "Microphone Mute" + ], + "es": [ + "Silenciar micrófono" + ], + "fr": [ + "Couper le micro" + ], + "ja": [ + "マイクをミュート" + ], + "ko": [ + "마이크 음소거" + ], + "pt": [ + "Silenciar microfone" + ], + "ru": [ + "Отключить микрофон" + ], + "zh-Hans": [ + "麦克风静音" + ], + "zh-Hant": [ + "麥克風靜音" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "audio" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "audio-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "microphone-mute", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "麦克风静音", + "快速静音或恢复默认麦克风输入", + "microphone", + "mute", + "toggle" + ], + "systemImage": "mic.slash", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "麦克风静音", + "快速静音或恢复默认麦克风输入", + "microphone", + "mute", + "set", + "enabled" + ], + "systemImage": "mic.slash", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/MiddleClick/plugin.json b/Plugins/MiddleClick/plugin.json index eddb0b3f..31e106dd 100644 --- a/Plugins/MiddleClick/plugin.json +++ b/Plugins/MiddleClick/plugin.json @@ -48,6 +48,12 @@ "summary": "觸控板輕點 → 模擬滑鼠中鍵" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" + }, "version": "1.0.16", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +68,164 @@ "componentPanel": false, "settings": "form" }, - "permissions": ["accessibility"], - "category": "productivity" + "permissions": [ + "accessibility" + ], + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "middle", + "click", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "النقر بالزر الأوسط" + ], + "de": [ + "Mittelklick" + ], + "en": [ + "Middle Click" + ], + "es": [ + "Clic central" + ], + "fr": [ + "Clic du milieu" + ], + "ja": [ + "ミドルクリック" + ], + "ko": [ + "가운데 클릭" + ], + "pt": [ + "Clique do meio" + ], + "ru": [ + "Средний щелчок" + ], + "zh-Hans": [ + "模拟鼠标中键" + ], + "zh-Hant": [ + "模擬滑鼠中鍵" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "input-events" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "middle-click", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "模拟鼠标中键", + "触控板轻点 → 模拟鼠标中键", + "middle", + "click", + "toggle" + ], + "systemImage": "hand.tap", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "automatic-rule", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/MouseEnhancer/plugin.json b/Plugins/MouseEnhancer/plugin.json index f03851fe..5757e433 100644 --- a/Plugins/MouseEnhancer/plugin.json +++ b/Plugins/MouseEnhancer/plugin.json @@ -48,6 +48,12 @@ "summary": "分別調整滑鼠與觸控板的捲動方向" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -66,5 +72,121 @@ "accessibility", "inputMonitoring" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "mouse", + "enhancer", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "محسّن الماوس" + ], + "de": [ + "Maus-Erweiterung" + ], + "en": [ + "Mouse Enhancer" + ], + "es": [ + "Mejoras para el ratón" + ], + "fr": [ + "Amélioration de la souris" + ], + "ja": [ + "マウス機能拡張" + ], + "ko": [ + "마우스 기능 향상" + ], + "pt": [ + "Aprimoramento do mouse" + ], + "ru": [ + "Расширение мыши" + ], + "zh-Hans": [ + "鼠标增强" + ], + "zh-Hant": [ + "滑鼠增強" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility", + "inputMonitoring" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "input-events" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/NightShift/plugin.json b/Plugins/NightShift/plugin.json index a2965894..7c34bf1f 100644 --- a/Plugins/NightShift/plugin.json +++ b/Plugins/NightShift/plugin.json @@ -48,6 +48,15 @@ "summary": "降低藍光,使螢幕顏色更暖" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +72,188 @@ "settings": "none" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "night", + "shift", + "display" + ], + "localizedSynonyms": { + "ar": [ + "Night Shift" + ], + "de": [ + "Night Shift" + ], + "en": [ + "Night Shift" + ], + "es": [ + "Night Shift" + ], + "fr": [ + "Night Shift" + ], + "ja": [ + "Night Shift" + ], + "ko": [ + "Night Shift" + ], + "pt": [ + "Night Shift" + ], + "ru": [ + "Night Shift" + ], + "zh-Hans": [ + "夜览" + ], + "zh-Hant": [ + "Night Shift" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "night-shift", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "夜览", + "降低蓝光,使屏幕颜色更暖", + "night", + "shift", + "toggle" + ], + "systemImage": "lamp.floor", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "夜览", + "降低蓝光,使屏幕颜色更暖", + "night", + "shift", + "set", + "enabled" + ], + "systemImage": "lamp.floor", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/PhysicalCleanMode/plugin.json b/Plugins/PhysicalCleanMode/plugin.json index b7837491..b337c4ab 100644 --- a/Plugins/PhysicalCleanMode/plugin.json +++ b/Plugins/PhysicalCleanMode/plugin.json @@ -48,6 +48,12 @@ "summary": "螢幕全黑並臨時禁用鍵盤輸入" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -65,5 +71,162 @@ "permissions": [ "accessibility" ], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "physical", + "clean", + "mode", + "display" + ], + "localizedSynonyms": { + "ar": [ + "وضع التنظيف" + ], + "de": [ + "Reinigungsmodus" + ], + "en": [ + "Clean Mode" + ], + "es": [ + "Modo limpieza" + ], + "fr": [ + "Mode nettoyage" + ], + "ja": [ + "クリーニングモード" + ], + "ko": [ + "청소 모드" + ], + "pt": [ + "Modo de limpeza" + ], + "ru": [ + "Режим очистки" + ], + "zh-Hans": [ + "清洁模式" + ], + "zh-Hant": [ + "清潔模式" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "physical-clean-mode", + "kind": "static", + "staticActions": [ + { + "id": "enter", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "清洁模式", + "屏幕全黑并临时禁用键盘输入", + "clean", + "keyboard", + "physical", + "mode", + "enter" + ], + "systemImage": "sparkles", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/QuitApps/plugin.json b/Plugins/QuitApps/plugin.json index ff9d42e1..c8fff1ec 100644 --- a/Plugins/QuitApps/plugin.json +++ b/Plugins/QuitApps/plugin.json @@ -48,6 +48,10 @@ "summary": "選擇並退出正在運行的App,或一鍵退出全部" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +67,147 @@ "settings": "none" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "quit", + "apps", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "إنهاء التطبيقات" + ], + "de": [ + "Apps beenden" + ], + "en": [ + "Quit Apps" + ], + "es": [ + "Salir de apps" + ], + "fr": [ + "Quitter les apps" + ], + "ja": [ + "アプリを終了" + ], + "ko": [ + "앱 종료" + ], + "pt": [ + "Encerrar apps" + ], + "ru": [ + "Закрыть приложения" + ], + "zh-Hans": [ + "退出应用" + ], + "zh-Hant": [ + "結束 App" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "productivity-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "quit-apps", + "kind": "static", + "staticActions": [ + { + "id": "choose-apps", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "退出应用", + "选择并退出正在运行的应用", + "quit", + "apps", + "choose" + ], + "systemImage": "power", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/RightClick/Sources/RightClickPlugin.swift b/Plugins/RightClick/Sources/RightClickPlugin.swift index ded25c2c..a7e7e8c5 100644 --- a/Plugins/RightClick/Sources/RightClickPlugin.swift +++ b/Plugins/RightClick/Sources/RightClickPlugin.swift @@ -87,7 +87,7 @@ final class RightClickPlugin: MacToolsPlugin { [ PluginPermissionRequirement( id: RightClickPermissionID.finderExtension, - kind: .automation, + kind: .finderExtension, title: localization.string("permission.finderExtension.title", defaultValue: "Finder 扩展"), description: localization.string( "permission.finderExtension.description", diff --git a/Plugins/RightClick/plugin.json b/Plugins/RightClick/plugin.json index 0144a2a8..06a675fd 100644 --- a/Plugins/RightClick/plugin.json +++ b/Plugins/RightClick/plugin.json @@ -48,8 +48,25 @@ "summary": "為 Finder 右鍵菜單添加新建資料夾和路徑複製" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup-finder-extension": { + "ar": "فعّل ملحق Finder الخاص بـ MacTools في إعدادات النظام ضمن عام > عناصر تسجيل الدخول والملحقات > ملحقات Finder.", + "de": "Aktiviere die MacTools Finder-Erweiterung unter Systemeinstellungen > Allgemein > Anmeldeobjekte & Erweiterungen > Finder-Erweiterungen.", + "en": "Enable the MacTools Finder extension in System Settings under General > Login Items & Extensions > Finder Extensions.", + "es": "Activa la extensión de Finder de MacTools en Ajustes del Sistema, en General > Ítems de inicio y extensiones > Extensiones de Finder.", + "fr": "Activez l’extension Finder de MacTools dans Réglages Système, sous Général > Ouverture et extensions > Extensions Finder.", + "ja": "システム設定の「一般」>「ログイン項目と機能拡張」>「Finder機能拡張」でMacToolsのFinder機能拡張を有効にします。", + "ko": "시스템 설정의 일반 > 로그인 항목 및 확장 프로그램 > Finder 확장 프로그램에서 MacTools Finder 확장 프로그램을 켜십시오.", + "pt": "Ative a extensão do Finder do MacTools em Ajustes do Sistema > Geral > Itens de Início de Sessão e Extensões > Extensões do Finder.", + "ru": "Включите расширение Finder для MacTools в разделе «Системные настройки» > «Основные» > «Объекты входа и расширения» > «Расширения Finder».", + "zh-Hans": "在“系统设置 > 通用 > 登录项与扩展 > Finder 扩展”中启用 MacTools 右键工具。", + "zh-Hant": "在「系統設定 > 一般 > 登入項目與延伸功能 > Finder 延伸功能」中啟用 MacTools 右鍵工具。" + } + }, "version": "1.1.0", - "minHostVersion": "1.2.0", + "minHostVersion": "1.2.1", "pluginKitVersion": 5, "bundleRelativePath": "RightClick.bundle", "factoryClass": "RightClickPlugin.RightClickPluginFactory", @@ -63,5 +80,118 @@ "settings": "form" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "right", + "click", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "النقر بزر الماوس الأيمن" + ], + "de": [ + "Rechtsklick" + ], + "en": [ + "Right Click" + ], + "es": [ + "Clic derecho" + ], + "fr": [ + "Clic droit" + ], + "ja": [ + "右クリック" + ], + "ko": [ + "오른쪽 클릭" + ], + "pt": [ + "Clique direito" + ], + "ru": [ + "Правый клик" + ], + "zh-Hans": [ + "右键工具" + ], + "zh-Hant": [ + "右鍵工具" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "selected-files" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "enable-finder-extension", + "title": "@productStrings.display-name", + "description": "@productStrings.setup-finder-extension" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/SavedScripts/Tests/SavedScriptsPluginTests.swift b/Plugins/SavedScripts/Tests/SavedScriptsPluginTests.swift index ecd7a185..acc60c2d 100644 --- a/Plugins/SavedScripts/Tests/SavedScriptsPluginTests.swift +++ b/Plugins/SavedScripts/Tests/SavedScriptsPluginTests.swift @@ -27,6 +27,7 @@ final class SavedScriptsPluginTests: XCTestCase { XCTAssertEqual(definition.risk, .confirmationRequired) XCTAssertNotNil(definition.confirmation) XCTAssertEqual(definition.externalInvocationPolicy, .unavailable) + XCTAssertFalse(definition.capabilities.contains(.automatic)) XCTAssertTrue(definition.capabilities.contains(.cancellable)) XCTAssertTrue(definition.capabilities.contains(.reportsProgress)) @@ -52,9 +53,24 @@ final class SavedScriptsPluginTests: XCTestCase { allowExternalInvocation: true )).get() - XCTAssertEqual(plugin.actionDefinitions.first?.risk, .safe) - XCTAssertEqual(plugin.actionDefinitions.first?.externalInvocationPolicy, .confirmAlways) - XCTAssertNotNil(plugin.actionDefinitions.first?.confirmation) + let template = try PluginManifestActionAssertions.dynamicTemplate( + pluginDirectoryName: "SavedScripts", + id: "run-script" + ) + XCTAssertEqual(template["riskVariesByEntry"] as? Bool, true) + XCTAssertEqual(template["automaticEligibilityVariesByEntry"] as? Bool, true) + XCTAssertEqual(template["externalInvocation"] as? String, "configurable") + XCTAssertTrue( + Set(template["surfaces"] as? [String] ?? []).isSuperset( + of: ["run-link", "automatic-rule"] + ) + ) + + let definition = try XCTUnwrap(plugin.actionDefinitions.first) + XCTAssertEqual(definition.risk, .safe) + XCTAssertEqual(definition.externalInvocationPolicy, .confirmAlways) + XCTAssertTrue(definition.capabilities.contains(.automatic)) + XCTAssertNotNil(definition.confirmation) } func testActionExecutesScriptAndCapturesOutputForStandaloneLibrary() async throws { diff --git a/Plugins/SavedScripts/plugin.json b/Plugins/SavedScripts/plugin.json index 79206264..feefeb75 100644 --- a/Plugins/SavedScripts/plugin.json +++ b/Plugins/SavedScripts/plugin.json @@ -48,6 +48,23 @@ "summary": "儲存並執行 AppleScript 和 Shell 腳本。" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.run-script.parameter-summary": { + "ar": "اختر البرنامج النصي المحفوظ على هذا الـ Mac لتشغيله.", + "de": "Wähle das auf diesem Mac gespeicherte Skript aus, das ausgeführt werden soll.", + "en": "Select the saved script stored on this Mac to run.", + "es": "Selecciona el script guardado en este Mac que quieres ejecutar.", + "fr": "Sélectionnez le script enregistré sur ce Mac à exécuter.", + "ja": "この Mac に保存されているスクリプトから、実行する項目を選択します。", + "ko": "이 Mac에 저장된 스크립트 중 실행할 항목을 선택하세요.", + "pt": "Selecione o script salvo neste Mac para executar.", + "ru": "Выберите сохранённый на этом Mac скрипт для запуска.", + "zh-Hans": "选择存储在此 Mac 上、要运行的已存脚本。", + "zh-Hant": "選擇儲存在此 Mac 上、要執行的已儲存指令碼。" + } + }, "version": "1.0.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +80,158 @@ "settings": "workspace" }, "permissions": [], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "saved", + "scripts", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "البرامج النصية المحفوظة" + ], + "de": [ + "Gespeicherte Skripte" + ], + "en": [ + "Saved Scripts" + ], + "es": [ + "Scripts guardados" + ], + "fr": [ + "Scripts enregistrés" + ], + "ja": [ + "保存済みスクリプト" + ], + "ko": [ + "저장된 스크립트" + ], + "pt": [ + "Scripts salvos" + ], + "ru": [ + "Сохранённые скрипты" + ], + "zh-Hans": [ + "已存脚本" + ], + "zh-Hant": [ + "已儲存腳本" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "script-content", + "script-output", + "script-working-directories" + ], + "dataPersisted": [ + "plugin-configuration", + "script-content", + "script-working-directories" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "saved-scripts", + "kind": "dynamic", + "staticActions": [], + "dynamicTemplates": [ + { + "id": "run-script", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "entrySource": "saved-scripts", + "parameters": [], + "parameterSummary": "@productStrings.action.run-script.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "saved", + "scripts", + "run", + "script" + ], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "configurable", + "riskVariesByEntry": true, + "automaticEligibilityVariesByEntry": true + } + ] + } + ] + } } diff --git a/Plugins/Sidecar/plugin.json b/Plugins/Sidecar/plugin.json index 4b7fe5b4..fb55d51d 100644 --- a/Plugins/Sidecar/plugin.json +++ b/Plugins/Sidecar/plugin.json @@ -48,6 +48,31 @@ "summary": "連接附近可用的 Sidecar 顯示器作為延伸顯示器" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.connect-first-available.title": "@localizable.panel.action.connect", + "action.connect-first-available.description": "@localizable.shortcut.connectFirstAvailable.target", + "action.disconnect-all.title": "@localizable.panel.action.disconnect", + "action.disconnect-all.description": "@localizable.shortcut.disconnectAll.target", + "action.device.title": "@localizable.device.unnamed", + "action.device.description": "@localizable.panel.device.subtitle", + "action.device.parameter-summary": { + "ar": "اختر شاشة Sidecar القريبة المخزنة على هذا الـ Mac.", + "de": "Wähle das auf diesem Mac gespeicherte Sidecar-Display in der Nähe aus.", + "en": "Select the nearby Sidecar display stored on this Mac.", + "es": "Selecciona la pantalla Sidecar cercana guardada en este Mac.", + "fr": "Sélectionnez l’écran Sidecar à proximité enregistré sur ce Mac.", + "ja": "この Mac に保存されている近くの Sidecar ディスプレイを選択します。", + "ko": "이 Mac에 저장된 주변 Sidecar 디스플레이를 선택하세요.", + "pt": "Selecione o monitor Sidecar próximo salvo neste Mac.", + "ru": "Выберите ближайший дисплей Sidecar, сохранённый на этом Mac.", + "zh-Hans": "选择存储在此 Mac 上的附近 Sidecar 显示器。", + "zh-Hant": "選擇儲存在此 Mac 上的附近 Sidecar 顯示器。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +88,217 @@ "settings": "form" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "sidecar", + "display" + ], + "localizedSynonyms": { + "ar": [ + "Sidecar" + ], + "de": [ + "Sidecar" + ], + "en": [ + "Sidecar" + ], + "es": [ + "Sidecar" + ], + "fr": [ + "Sidecar" + ], + "ja": [ + "Sidecar" + ], + "ko": [ + "Sidecar" + ], + "pt": [ + "Sidecar" + ], + "ru": [ + "Sidecar" + ], + "zh-Hans": [ + "Sidecar" + ], + "zh-Hant": [ + "Sidecar" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "Sidecar-compatible Mac and display" + ], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "sidecar", + "kind": "mixed", + "staticActions": [ + { + "id": "connect-first-available", + "title": "@productStrings.action.connect-first-available.title", + "description": "@productStrings.action.connect-first-available.description", + "keywords": [ + "Sidecar", + "sidecar", + "connect", + "first", + "available" + ], + "systemImage": "rectangle.badge.plus", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + }, + { + "id": "disconnect-all", + "title": "@productStrings.action.disconnect-all.title", + "description": "@productStrings.action.disconnect-all.description", + "keywords": [ + "Sidecar", + "sidecar", + "disconnect", + "all" + ], + "systemImage": "rectangle.badge.minus", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ], + "dynamicTemplates": [ + { + "id": "device", + "title": "@productStrings.action.device.title", + "description": "@productStrings.action.device.description", + "entrySource": "configured-sidecar-devices", + "parameters": [], + "parameterSummary": "@productStrings.action.device.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "sidecar", + "device", + "configured", + "devices" + ], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "confirmAlways" + } + ] + } + ] + } } diff --git a/Plugins/StageManager/plugin.json b/Plugins/StageManager/plugin.json index b99e09d9..b27a65fc 100644 --- a/Plugins/StageManager/plugin.json +++ b/Plugins/StageManager/plugin.json @@ -48,6 +48,15 @@ "summary": "開啟幕前調度,集中顯示當前窗口並把其他窗口收納到側邊" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +72,190 @@ "settings": "none" }, "permissions": [], - "category": "display" + "category": "display", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "stage", + "manager", + "display" + ], + "localizedSynonyms": { + "ar": [ + "Stage Manager" + ], + "de": [ + "Stage Manager" + ], + "en": [ + "Stage Manager" + ], + "es": [ + "Organizador Visual" + ], + "fr": [ + "Stage Manager" + ], + "ja": [ + "ステージマネージャ" + ], + "ko": [ + "스테이지 매니저" + ], + "pt": [ + "Organizador Visual" + ], + "ru": [ + "Stage Manager" + ], + "zh-Hans": [ + "台前调度" + ], + "zh-Hant": [ + "幕前調度" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "display" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "display-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "stage-manager", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "台前调度", + "开启台前调度,集中显示当前窗口并把其他窗口收纳到侧边", + "Stage Manager", + "stage", + "manager", + "toggle" + ], + "systemImage": "sidebar.squares.leading", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "台前调度", + "开启台前调度,集中显示当前窗口并把其他窗口收纳到侧边", + "Stage Manager", + "stage", + "manager", + "set", + "enabled" + ], + "systemImage": "sidebar.squares.leading", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/SystemMute/plugin.json b/Plugins/SystemMute/plugin.json index 681dcb15..e3c0e0e9 100644 --- a/Plugins/SystemMute/plugin.json +++ b/Plugins/SystemMute/plugin.json @@ -48,6 +48,15 @@ "summary": "快速靜音或恢復系統音頻輸出" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "action.toggle.title": "@standardAction.toggle.title", + "action.toggle.description": "@standardAction.toggle.description", + "action.set-enabled.title": "@standardAction.set-enabled.title", + "action.set-enabled.description": "@standardAction.set-enabled.description", + "action.set-enabled.parameter-summary": "@standardAction.set-enabled.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +72,188 @@ "settings": "none" }, "permissions": [], - "category": "audio" + "category": "audio", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "system", + "mute", + "audio" + ], + "localizedSynonyms": { + "ar": [ + "كتم صوت النظام" + ], + "de": [ + "System stummschalten" + ], + "en": [ + "System Mute" + ], + "es": [ + "Silenciar sistema" + ], + "fr": [ + "Couper le son du système" + ], + "ja": [ + "システムをミュート" + ], + "ko": [ + "시스템 음소거" + ], + "pt": [ + "Silenciar sistema" + ], + "ru": [ + "Отключить звук системы" + ], + "zh-Hans": [ + "系统静音" + ], + "zh-Hant": [ + "系統靜音" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "audio" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "audio-state" + ], + "dataPersisted": [], + "retention": { + "policy": "none" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "system-mute", + "kind": "static", + "staticActions": [ + { + "id": "toggle", + "title": "@productStrings.action.toggle.title", + "description": "@productStrings.action.toggle.description", + "keywords": [ + "系统静音", + "快速静音或恢复系统音频输出", + "system", + "mute", + "toggle" + ], + "systemImage": "speaker.slash", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + }, + { + "id": "set-enabled", + "title": "@productStrings.action.set-enabled.title", + "description": "@productStrings.action.set-enabled.description", + "keywords": [ + "系统静音", + "快速静音或恢复系统音频输出", + "system", + "mute", + "set", + "enabled" + ], + "systemImage": "speaker.slash", + "parameters": [ + { + "id": "enabled", + "kind": "boolean", + "isRequired": true, + "portability": "portable" + } + ], + "parameterSummary": "@productStrings.action.set-enabled.parameter-summary", + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "automatic-rule", + "action-grid", + "trackpad-gesture", + "app-intent", + "manual" + ], + "automaticEligible": true, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/SystemSoftRestart/plugin.json b/Plugins/SystemSoftRestart/plugin.json index 06b32796..e3662361 100644 --- a/Plugins/SystemSoftRestart/plugin.json +++ b/Plugins/SystemSoftRestart/plugin.json @@ -48,6 +48,10 @@ "summary": "重新啟動 macOS 使用者服務,嘗試恢復常見執行階段異常" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary" + }, "version": "1.0.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -68,5 +72,155 @@ "settings": "form" }, "permissions": [], - "category": "system" + "category": "system", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "system", + "soft", + "restart" + ], + "localizedSynonyms": { + "ar": [ + "إعادة تشغيل النظام برمجيًا" + ], + "de": [ + "System-Sanftneustart" + ], + "en": [ + "System Soft Restart" + ], + "es": [ + "Reinicio suave del sistema" + ], + "fr": [ + "Redémarrage logiciel du système" + ], + "ja": [ + "システムソフト再起動" + ], + "ko": [ + "시스템 소프트 재시작" + ], + "pt": [ + "Reinicialização suave do sistema" + ], + "ru": [ + "Мягкий перезапуск системы" + ], + "zh-Hans": [ + "系统软重启" + ], + "zh-Hant": [ + "系統軟重啟" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "system" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "system-state" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "system-soft-restart", + "kind": "static", + "staticActions": [ + { + "id": "restart-user-services", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "系统软重启", + "soft restart", + "launchd", + "repair", + "修复", + "重启服务", + "system", + "soft", + "restart", + "user", + "services" + ], + "systemImage": "arrow.clockwise.circle.fill", + "parameters": [], + "permissionIDs": [], + "risk": "confirmationRequired", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/SystemStatus/plugin.json b/Plugins/SystemStatus/plugin.json index 7d203082..cfcb5a41 100644 --- a/Plugins/SystemStatus/plugin.json +++ b/Plugins/SystemStatus/plugin.json @@ -48,6 +48,23 @@ "summary": "實時查看系統狀態" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "retention-description": { + "ar": "تظل الإعدادات محفوظة حتى إزالة بيانات الإضافة. يقتصر سجل الأداء على 24 ساعة و8640 عينة.", + "de": "Einstellungen bleiben bis zum Entfernen der Plug-in-Daten erhalten. Der Leistungsverlauf ist auf 24 Stunden und 8.640 Messwerte begrenzt.", + "en": "Settings persist until plugin data is removed. Performance history is limited to 24 hours and 8,640 samples.", + "es": "Los ajustes se conservan hasta que se eliminan los datos del complemento. El historial de rendimiento se limita a 24 horas y 8640 muestras.", + "fr": "Les réglages restent jusqu’à la suppression des données du module. L’historique des performances est limité à 24 heures et 8 640 échantillons.", + "ja": "設定はプラグインデータを削除するまで保持されます。パフォーマンス履歴は 24 時間、8,640 サンプルに制限されます。", + "ko": "설정은 플러그인 데이터를 제거할 때까지 유지됩니다. 성능 기록은 24시간 및 8,640개 샘플로 제한됩니다.", + "pt": "As definições permanecem até os dados do plugin serem removidos. O histórico de desempenho está limitado a 24 horas e 8640 amostras.", + "ru": "Настройки хранятся до удаления данных плагина. История производительности ограничена 24 часами и 8640 образцами.", + "zh-Hans": "设置会保留到插件数据被移除。性能历史仅保留 24 小时,最多 8,640 个样本。", + "zh-Hant": "設定會保留到外掛資料被移除。效能歷史僅保留 24 小時,最多 8,640 個樣本。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +80,114 @@ "settings": "form" }, "permissions": [], - "category": "monitoring" + "category": "monitoring", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "system", + "status", + "monitoring" + ], + "localizedSynonyms": { + "ar": [ + "حالة النظام" + ], + "de": [ + "Systemstatus" + ], + "en": [ + "System Status" + ], + "es": [ + "Estado del sistema" + ], + "fr": [ + "État du système" + ], + "ja": [ + "システム状況" + ], + "ko": [ + "시스템 상태" + ], + "pt": [ + "Status do sistema" + ], + "ru": [ + "Состояние системы" + ], + "zh-Hans": [ + "系统状态" + ], + "zh-Hant": [ + "系統狀態" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "monitoring" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "monitoring-state" + ], + "dataPersisted": [ + "plugin-configuration", + "performance-history" + ], + "retention": { + "policy": "user-controlled", + "description": "@productStrings.retention-description" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/TrackpadGestures/plugin.json b/Plugins/TrackpadGestures/plugin.json index 363f36ee..abb1f888 100644 --- a/Plugins/TrackpadGestures/plugin.json +++ b/Plugins/TrackpadGestures/plugin.json @@ -48,6 +48,12 @@ "summary": "將自訂觸控板手勢對應為 MacTools 操作、快速鍵或中鍵點按" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" + }, "version": "1.0.2", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -66,5 +72,121 @@ "accessibility", "inputMonitoring" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "trackpad", + "gestures", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "إيماءات لوحة التتبع" + ], + "de": [ + "Trackpad-Gesten" + ], + "en": [ + "Trackpad Gestures" + ], + "es": [ + "Gestos del trackpad" + ], + "fr": [ + "Gestes du trackpad" + ], + "ja": [ + "トラックパッドジェスチャ" + ], + "ko": [ + "트랙패드 제스처" + ], + "pt": [ + "Gestos do trackpad" + ], + "ru": [ + "Жесты трекпада" + ], + "zh-Hans": [ + "触控板手势" + ], + "zh-Hant": [ + "觸控板手勢" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility", + "inputMonitoring" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "input-events" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": false, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/Translator/plugin.json b/Plugins/Translator/plugin.json index b67f462e..905b4794 100644 --- a/Plugins/Translator/plugin.json +++ b/Plugins/Translator/plugin.json @@ -48,6 +48,16 @@ "summary": "透過快速鍵翻譯選取文字或截圖區域。" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.select-translation.title": "@localizable.shortcut.selectTranslation.title", + "action.select-translation.description": "@localizable.shortcut.selectTranslation.description", + "action.screenshot-translation.title": "@localizable.shortcut.screenshotTranslation.title", + "action.screenshot-translation.description": "@localizable.shortcut.screenshotTranslation.description" + }, "version": "0.2.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -67,5 +77,196 @@ "automation", "screen-recording" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "translator", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "المترجم" + ], + "de": [ + "Übersetzer" + ], + "en": [ + "Translator" + ], + "es": [ + "Traductor" + ], + "fr": [ + "Traducteur" + ], + "ja": [ + "翻訳" + ], + "ko": [ + "번역" + ], + "pt": [ + "Tradutor" + ], + "ru": [ + "Переводчик" + ], + "zh-Hans": [ + "翻译" + ], + "zh-Hant": [ + "翻譯" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility", + "automation", + "screen-recording" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "selected-text", + "screenshots", + "translation-requests" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "required", + "networkDomains": [ + "api.openai.com" + ], + "allowsUserConfiguredDomains": true, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "translator", + "kind": "static", + "staticActions": [ + { + "id": "select-translation", + "title": "@productStrings.action.select-translation.title", + "description": "@productStrings.action.select-translation.description", + "keywords": [ + "翻译", + "划词翻译", + "translator", + "select", + "translation" + ], + "systemImage": "text.cursor", + "parameters": [], + "permissionIDs": [ + "accessibility", + "automation" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "screenshot-translation", + "title": "@productStrings.action.screenshot-translation.title", + "description": "@productStrings.action.screenshot-translation.description", + "keywords": [ + "翻译", + "截图翻译", + "OCR", + "translator", + "screenshot", + "translation" + ], + "systemImage": "viewfinder", + "parameters": [], + "permissionIDs": [ + "screen-recording" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/WindowLayouts/Resources/Localizable.xcstrings b/Plugins/WindowLayouts/Resources/Localizable.xcstrings index 03d380f4..c28b8b71 100644 --- a/Plugins/WindowLayouts/Resources/Localizable.xcstrings +++ b/Plugins/WindowLayouts/Resources/Localizable.xcstrings @@ -162,7 +162,16 @@ "settings.custom.preview": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Layout Preview" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "布局预览" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "佈局預覽" } } } }, "settings.custom.shortcut": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Global Shortcut" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "全局快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "全域快速鍵" } } } }, "settings.custom.shortcut.clear": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Clear Shortcut" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "清除快捷键" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "清除快速鍵" } } } }, - "settings.custom.name": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Name" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "名称" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "名稱" } } } }, + "settings.custom.name": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الاسم" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Name" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Nombre" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Nom" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "名前" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이름" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Nome" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Название" } }, + "en": { "stringUnit": { "state": "translated", "value": "Name" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "名称" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "名稱" } } } }, "settings.custom.widthMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Width Mode" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "宽度模式" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "寬度模式" } } } }, "settings.custom.width": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Width" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "宽度" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "寬度" } } } }, "settings.custom.heightMode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Height Mode" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "高度模式" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "高度模式" } } } }, @@ -178,7 +187,16 @@ "settings.custom.delete.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "The custom layout could not be deleted; its shortcut was restored." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法删除自定义布局;其快捷键已恢复。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "無法刪除自訂佈局;其快速鍵已還原。" } } } }, "settings.custom.delete.rollbackFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "The layout could not be deleted and its shortcut could not be restored:" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法删除布局,也无法恢复其快捷键:" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "無法刪除佈局,也無法還原其快速鍵:" } } } }, "settings.custom.delete.button": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Delete" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "删除" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "刪除" } } } }, - "settings.custom.defaultName": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Custom Layout" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "自定义布局" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "自訂佈局" } } } }, + "settings.custom.defaultName": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "تخطيط مخصص" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Benutzerdefiniertes Layout" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Diseño personalizado" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Disposition personnalisée" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "カスタムレイアウト" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "사용자 설정 레이아웃" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Disposição personalizada" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Пользовательская раскладка" } }, + "en": { "stringUnit": { "state": "translated", "value": "Custom Layout" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "自定义布局" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "自訂佈局" } } } }, "settings.custom.copySuffix": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Copy" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "副本" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "副本" } } } }, "settings.dimension.current": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Keep Current" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持当前" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持目前" } } } }, "settings.dimension.fraction": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Screen Percentage" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "屏幕比例" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "螢幕比例" } } } }, @@ -192,7 +210,16 @@ "settings.anchor.bottomLeft": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Left" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左下" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左下" } } } }, "settings.anchor.bottom": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下方" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下方" } } } }, "settings.anchor.bottomRight": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Right" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右下" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右下" } } } }, - "action.custom.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Apply a custom window size and anchored position." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "应用自定义窗口大小和固定位置。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "套用自訂視窗大小和固定位置。" } } } }, + "action.custom.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "طبّق حجم نافذة مخصصًا وموضعًا ثابتًا." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Wendet eine benutzerdefinierte Fenstergröße und verankerte Position an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Aplica un tamaño de ventana y una posición anclada personalizados." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Applique une taille de fenêtre et une position ancrée personnalisées." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "カスタムのウインドウサイズと固定位置を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "사용자 설정 윈도우 크기와 고정 위치를 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Aplica um tamanho de janela e uma posição fixa personalizados." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Применяет пользовательский размер окна и закрепленное положение." } }, + "en": { "stringUnit": { "state": "translated", "value": "Apply a custom window size and anchored position." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "应用自定义窗口大小和固定位置。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "套用自訂視窗大小和固定位置。" } } } }, "error.executionCancelled": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "The window layout operation was cancelled." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "窗口布局操作已取消。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "視窗佈局操作已取消。" } } } }, "error.executionQueueFull": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Too many window layout operations are waiting." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "等待中的窗口布局操作过多。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "等待中的視窗佈局操作過多。" } } } }, "error.accessibilityRequired": { @@ -292,78 +319,726 @@ } }, "error.customCommandUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "The custom window command could not be found." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "找不到自定义窗口命令。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "找不到自訂視窗命令。" } } } }, - "action.left-half.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Left Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左半螢幕" } } } }, - "action.left-half.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the left half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器左半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器左半區域。" } } } }, - "action.right-half.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Right Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右半螢幕" } } } }, - "action.right-half.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the right half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器右半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器右半區域。" } } } }, - "action.top-half.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Top Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "上半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "上半螢幕" } } } }, - "action.top-half.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the top half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器上半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器上半區域。" } } } }, - "action.bottom-half.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下半螢幕" } } } }, - "action.bottom-half.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器下半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器下半區域。" } } } }, - "action.top-left-quarter.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Top Left Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左上四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左上四分之一" } } } }, - "action.top-left-quarter.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the top-left quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器左上角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器左上角。" } } } }, - "action.top-right-quarter.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Top Right Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右上四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右上四分之一" } } } }, - "action.top-right-quarter.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the top-right quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器右上角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器右上角。" } } } }, - "action.bottom-left-quarter.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Left Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左下四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左下四分之一" } } } }, - "action.bottom-left-quarter.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the bottom-left quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器左下角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器左下角。" } } } }, - "action.bottom-right-quarter.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Right Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右下四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右下四分之一" } } } }, - "action.bottom-right-quarter.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the bottom-right quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器右下角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器右下角。" } } } }, - "action.maximize.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Maximize Window" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最大化窗口" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最大化視窗" } } } }, - "action.maximize.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the usable area of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到显示器可用区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿顯示器可用區域。" } } } }, - "action.center.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Center Window" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "居中窗口" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "置中視窗" } } } }, - "action.center.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Center the focused window while preserving its current size." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持当前大小并将窗口移到显示器中央。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持目前大小並將視窗移到顯示器中央。" } } } }, - "action.move-to-next-display.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move to Next Display" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到下一台显示器" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到下一台顯示器" } } } }, - "action.move-to-next-display.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move the focused window to the next display with proportional position and size." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "按相对位置和大小将窗口移到下一台显示器。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "按相對位置和大小將視窗移到下一台顯示器。" } } } }, - "action.move-to-previous-display.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move to Previous Display" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到上一台显示器" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到上一台顯示器" } } } }, - "action.move-to-previous-display.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move the focused window to the previous display with proportional position and size." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "按相对位置和大小将窗口移到上一台显示器。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "按相對位置和大小將視窗移到上一台顯示器。" } } } }, - "action.restore-previous-frame.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Restore Previous Window Frame" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "恢复上一个窗口位置" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "回復上一個視窗位置" } } } }, - "action.restore-previous-frame.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Restore the most recently saved position and size for this window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "恢复此窗口最近一次保存的位置和大小。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "回復此視窗最近一次儲存的位置和大小。" } } } }, - "action.toggle-full-screen.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Toggle Full Screen" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "切换全屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "切換全螢幕" } } } }, - "action.toggle-full-screen.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Toggle native macOS full screen for the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "切换当前窗口的 macOS 全屏状态。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "切換目前視窗的 macOS 全螢幕狀態。" } } } }, - "action.maximize-height.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Maximize Height" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最大化高度" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最大化高度" } } } }, - "action.maximize-height.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Preserve the width and fill the available height." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持宽度并填满可用高度。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持寬度並填滿可用高度。" } } } }, - "action.maximize-width.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Maximize Width" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最大化宽度" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最大化寬度" } } } }, - "action.maximize-width.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Preserve the height and fill the available width." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持高度并填满可用宽度。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持高度並填滿可用寬度。" } } } }, - "action.reasonable-size.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Reasonable Size" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "合理大小" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "合理大小" } } } }, - "action.reasonable-size.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Resize to 60% of the screen, capped at 1025 × 900, and center." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将窗口设为屏幕的 60%,最大 1025 × 900,并居中。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將視窗設為螢幕的 60%,最大 1025 × 900,並置中。" } } } }, - "action.move-to-top-edge.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move to Top Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到顶部" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到頂端" } } } }, - "action.move-to-top-edge.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the top edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕顶部。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕頂端。" } } } }, - "action.move-to-bottom-edge.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move to Bottom Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到底部" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到底部" } } } }, - "action.move-to-bottom-edge.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the bottom edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕底部。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕底部。" } } } }, - "action.move-to-left-edge.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move to Left Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到左侧" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到左側" } } } }, - "action.move-to-left-edge.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the left edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕左侧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕左側。" } } } }, - "action.move-to-right-edge.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Move to Right Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到右侧" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到右側" } } } }, - "action.move-to-right-edge.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the right edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕右侧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕右側。" } } } }, - "action.first-third.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "First Third" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左侧三分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左側三分之一" } } } }, - "action.first-third.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the first third of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左侧三分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左側三分之一。" } } } }, - "action.first-two-thirds.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "First Two Thirds" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左侧三分之二" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左側三分之二" } } } }, - "action.first-two-thirds.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the first two thirds of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左侧三分之二。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左側三分之二。" } } } }, - "action.center-third.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Center Third" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "中间三分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "中間三分之一" } } } }, - "action.center-third.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the center third of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充中间三分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿中間三分之一。" } } } }, - "action.last-two-thirds.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Last Two Thirds" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右侧三分之二" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右側三分之二" } } } }, - "action.last-two-thirds.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the last two thirds of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右侧三分之二。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右側三分之二。" } } } }, - "action.last-third.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Last Third" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右侧三分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右側三分之一" } } } }, - "action.last-third.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the last third of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右侧三分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右側三分之一。" } } } }, - "action.first-fourth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "First Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第一个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第一個四分之一" } } } }, - "action.first-fourth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the first fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充最左侧四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿最左側四分之一。" } } } }, - "action.second-fourth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Second Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第二个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第二個四分之一" } } } }, - "action.second-fourth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the second fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充第二个四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿第二個四分之一。" } } } }, - "action.third-fourth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Third Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第三个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第三個四分之一" } } } }, - "action.third-fourth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the third fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充第三个四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿第三個四分之一。" } } } }, - "action.last-fourth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Last Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最后一个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最後一個四分之一" } } } }, - "action.last-fourth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the last fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充最右侧四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿最右側四分之一。" } } } }, - "action.top-left-sixth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Top Left Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左上六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左上六分之一" } } } }, - "action.top-left-sixth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the top-left sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左上六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左上六分之一。" } } } }, - "action.top-center-sixth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Top Center Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "中上六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "中上六分之一" } } } }, - "action.top-center-sixth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the top-center sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充中上六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿中上六分之一。" } } } }, - "action.top-right-sixth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Top Right Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右上六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右上六分之一" } } } }, - "action.top-right-sixth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the top-right sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右上六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右上六分之一。" } } } }, - "action.bottom-left-sixth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Left Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左下六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左下六分之一" } } } }, - "action.bottom-left-sixth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom-left sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左下六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左下六分之一。" } } } }, - "action.bottom-center-sixth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Center Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "中下六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "中下六分之一" } } } }, - "action.bottom-center-sixth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom-center sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充中下六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿中下六分之一。" } } } }, - "action.bottom-right-sixth.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Bottom Right Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右下六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右下六分之一" } } } }, - "action.bottom-right-sixth.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom-right sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右下六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右下六分之一。" } } } } + "action.left-half.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "النصف الأيسر" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Linke Hälfte" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mitad izquierda" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Moitié gauche" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "左半分" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "왼쪽 절반" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Metade esquerda" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Левая половина" } }, + "en": { "stringUnit": { "state": "translated", "value": "Left Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左半螢幕" } } } }, + "action.left-half.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «النصف الأيسر»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Linke Hälfte“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mitad izquierda»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Moitié gauche»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「左半分」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “왼쪽 절반” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Metade esquerda»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Левая половина»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the left half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器左半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器左半區域。" } } } }, + "action.right-half.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "النصف الأيمن" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Rechte Hälfte" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mitad derecha" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Moitié droite" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "右半分" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "오른쪽 절반" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Metade direita" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Правая половина" } }, + "en": { "stringUnit": { "state": "translated", "value": "Right Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右半螢幕" } } } }, + "action.right-half.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «النصف الأيمن»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Rechte Hälfte“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mitad derecha»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Moitié droite»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「右半分」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “오른쪽 절반” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Metade direita»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Правая половина»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the right half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器右半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器右半區域。" } } } }, + "action.top-half.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "النصف العلوي" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Obere Hälfte" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mitad superior" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Moitié supérieure" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "上半分" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "위쪽 절반" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Metade superior" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Верхняя половина" } }, + "en": { "stringUnit": { "state": "translated", "value": "Top Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "上半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "上半螢幕" } } } }, + "action.top-half.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «النصف العلوي»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Obere Hälfte“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mitad superior»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Moitié supérieure»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「上半分」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “위쪽 절반” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Metade superior»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Верхняя половина»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the top half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器上半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器上半區域。" } } } }, + "action.bottom-half.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "النصف السفلي" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Untere Hälfte" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mitad inferior" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Moitié inférieure" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "下半分" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "아래쪽 절반" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Metade inferior" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Нижняя половина" } }, + "en": { "stringUnit": { "state": "translated", "value": "Bottom Half" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "下半屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "下半螢幕" } } } }, + "action.bottom-half.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «النصف السفلي»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Untere Hälfte“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mitad inferior»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Moitié inférieure»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「下半分」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “아래쪽 절반” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Metade inferior»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Нижняя половина»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom half of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到当前显示器下半区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿目前顯示器下半區域。" } } } }, + "action.top-left-quarter.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع العلوي الأيسر" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Oberes linkes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cuarto superior izquierdo" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quart supérieur gauche" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "左上 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "왼쪽 위 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Quarto superior esquerdo" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Верхняя левая четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Top Left Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左上四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左上四分之一" } } } }, + "action.top-left-quarter.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع العلوي الأيسر»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Oberes linkes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Cuarto superior izquierdo»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Quart supérieur gauche»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「左上 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “왼쪽 위 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Quarto superior esquerdo»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Верхняя левая четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the top-left quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器左上角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器左上角。" } } } }, + "action.top-right-quarter.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع العلوي الأيمن" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Oberes rechtes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cuarto superior derecho" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quart supérieur droit" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "右上 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "오른쪽 위 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Quarto superior direito" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Верхняя правая четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Top Right Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右上四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右上四分之一" } } } }, + "action.top-right-quarter.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع العلوي الأيمن»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Oberes rechtes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Cuarto superior derecho»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Quart supérieur droit»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「右上 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “오른쪽 위 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Quarto superior direito»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Верхняя правая четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the top-right quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器右上角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器右上角。" } } } }, + "action.bottom-left-quarter.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع السفلي الأيسر" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Unteres linkes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cuarto inferior izquierdo" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quart inférieur gauche" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "左下 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "왼쪽 아래 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Quarto inferior esquerdo" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Нижняя левая четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Bottom Left Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左下四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左下四分之一" } } } }, + "action.bottom-left-quarter.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع السفلي الأيسر»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Unteres linkes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Cuarto inferior izquierdo»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Quart inférieur gauche»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「左下 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “왼쪽 아래 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Quarto inferior esquerdo»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Нижняя левая четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the bottom-left quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器左下角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器左下角。" } } } }, + "action.bottom-right-quarter.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع السفلي الأيمن" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Unteres rechtes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Cuarto inferior derecho" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quart inférieur droit" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "右下 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "오른쪽 아래 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Quarto inferior direito" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Нижняя правая четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Bottom Right Quarter" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右下四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右下四分之一" } } } }, + "action.bottom-right-quarter.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع السفلي الأيمن»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Unteres rechtes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Cuarto inferior derecho»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Quart inférieur droit»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「右下 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “오른쪽 아래 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Quarto inferior direito»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Нижняя правая четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Place the focused window in the bottom-right quarter of the current display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口放到当前显示器右下角。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗放到目前顯示器右下角。" } } } }, + "action.maximize.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "تكبير النافذة" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Fenster maximieren" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Maximizar ventana" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Agrandir la fenêtre" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ウインドウを最大化" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "윈도우 최대화" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Maximizar janela" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Развернуть окно" } }, + "en": { "stringUnit": { "state": "translated", "value": "Maximize Window" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最大化窗口" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最大化視窗" } } } }, + "action.maximize.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «تكبير النافذة»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Fenster maximieren“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Maximizar ventana»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Agrandir la fenêtre»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「ウインドウを最大化」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “윈도우 최대화” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Maximizar janela»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Развернуть окно»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the usable area of the current display with the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将当前窗口填充到显示器可用区域。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將目前視窗填滿顯示器可用區域。" } } } }, + "action.center.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "توسيط النافذة" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Fenster zentrieren" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Centrar ventana" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Centrer la fenêtre" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ウインドウを中央に配置" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "윈도우 가운데 정렬" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Centrar janela" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Поместить окно по центру" } }, + "en": { "stringUnit": { "state": "translated", "value": "Center Window" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "居中窗口" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "置中視窗" } } } }, + "action.center.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «توسيط النافذة»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Fenster zentrieren“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Centrar ventana»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Centrer la fenêtre»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「ウインドウを中央に配置」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “윈도우 가운데 정렬” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Centrar janela»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Поместить окно по центру»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Center the focused window while preserving its current size." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持当前大小并将窗口移到显示器中央。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持目前大小並將視窗移到顯示器中央。" } } } }, + "action.move-to-next-display.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "نقل إلى الشاشة التالية" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Auf nächsten Bildschirm verschieben" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover a la pantalla siguiente" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplacer vers l’écran suivant" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "次のディスプレイに移動" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "다음 디스플레이로 이동" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para o ecrã seguinte" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переместить на следующий дисплей" } }, + "en": { "stringUnit": { "state": "translated", "value": "Move to Next Display" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到下一台显示器" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到下一台顯示器" } } } }, + "action.move-to-next-display.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «نقل إلى الشاشة التالية»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Auf nächsten Bildschirm verschieben“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mover a la pantalla siguiente»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Déplacer vers l’écran suivant»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「次のディスプレイに移動」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “다음 디스플레이로 이동” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Mover para o ecrã seguinte»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переместить на следующий дисплей»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Move the focused window to the next display with proportional position and size." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "按相对位置和大小将窗口移到下一台显示器。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "按相對位置和大小將視窗移到下一台顯示器。" } } } }, + "action.move-to-previous-display.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "نقل إلى الشاشة السابقة" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Auf vorherigen Bildschirm verschieben" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover a la pantalla anterior" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplacer vers l’écran précédent" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "前のディスプレイに移動" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 디스플레이로 이동" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para o ecrã anterior" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переместить на предыдущий дисплей" } }, + "en": { "stringUnit": { "state": "translated", "value": "Move to Previous Display" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到上一台显示器" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到上一台顯示器" } } } }, + "action.move-to-previous-display.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «نقل إلى الشاشة السابقة»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Auf vorherigen Bildschirm verschieben“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mover a la pantalla anterior»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Déplacer vers l’écran précédent»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「前のディスプレイに移動」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “이전 디스플레이로 이동” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Mover para o ecrã anterior»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переместить на предыдущий дисплей»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Move the focused window to the previous display with proportional position and size." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "按相对位置和大小将窗口移到上一台显示器。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "按相對位置和大小將視窗移到上一台顯示器。" } } } }, + "action.restore-previous-frame.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "استعادة موضع النافذة السابق" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Vorherige Fensterposition wiederherstellen" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Restaurar posición anterior de la ventana" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Restaurer la position précédente" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "前のウインドウ位置に戻す" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 윈도우 위치 복원" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Restaurar posição anterior da janela" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Восстановить предыдущее положение окна" } }, + "en": { "stringUnit": { "state": "translated", "value": "Restore Previous Window Frame" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "恢复上一个窗口位置" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "回復上一個視窗位置" } } } }, + "action.restore-previous-frame.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «استعادة موضع النافذة السابق»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Vorherige Fensterposition wiederherstellen“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Restaurar posición anterior de la ventana»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Restaurer la position précédente»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「前のウインドウ位置に戻す」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “이전 윈도우 위치 복원” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Restaurar posição anterior da janela»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Восстановить предыдущее положение окна»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Restore the most recently saved position and size for this window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "恢复此窗口最近一次保存的位置和大小。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "回復此視窗最近一次儲存的位置和大小。" } } } }, + "action.toggle-full-screen.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "تبديل ملء الشاشة" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Vollbild umschalten" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Alternar pantalla completa" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Basculer en plein écran" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フルスクリーンを切り替える" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "전체 화면 전환" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Alternar ecrã inteiro" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переключить полноэкранный режим" } }, + "en": { "stringUnit": { "state": "translated", "value": "Toggle Full Screen" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "切换全屏" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "切換全螢幕" } } } }, + "action.toggle-full-screen.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «تبديل ملء الشاشة»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Vollbild umschalten“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Alternar pantalla completa»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Basculer en plein écran»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「フルスクリーンを切り替える」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “전체 화면 전환” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Alternar ecrã inteiro»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переключить полноэкранный режим»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Toggle native macOS full screen for the focused window." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "切换当前窗口的 macOS 全屏状态。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "切換目前視窗的 macOS 全螢幕狀態。" } } } }, + "action.maximize-height.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "تكبير الارتفاع" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Höhe maximieren" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Maximizar altura" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Agrandir en hauteur" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "高さを最大化" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "높이 최대화" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Maximizar altura" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Развернуть по высоте" } }, + "en": { "stringUnit": { "state": "translated", "value": "Maximize Height" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最大化高度" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最大化高度" } } } }, + "action.maximize-height.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «تكبير الارتفاع»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Höhe maximieren“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Maximizar altura»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Agrandir en hauteur»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「高さを最大化」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “높이 최대화” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Maximizar altura»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Развернуть по высоте»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Preserve the width and fill the available height." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持宽度并填满可用高度。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持寬度並填滿可用高度。" } } } }, + "action.maximize-width.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "تكبير العرض" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Breite maximieren" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Maximizar anchura" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Agrandir en largeur" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "幅を最大化" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "너비 최대화" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Maximizar largura" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Развернуть по ширине" } }, + "en": { "stringUnit": { "state": "translated", "value": "Maximize Width" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最大化宽度" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最大化寬度" } } } }, + "action.maximize-width.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «تكبير العرض»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Breite maximieren“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Maximizar anchura»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Agrandir en largeur»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「幅を最大化」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “너비 최대화” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Maximizar largura»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Развернуть по ширине»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Preserve the height and fill the available width." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持高度并填满可用宽度。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持高度並填滿可用寬度。" } } } }, + "action.reasonable-size.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "حجم مناسب" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Angemessene Größe" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Tamaño adecuado" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Taille raisonnable" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "適切なサイズ" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "적절한 크기" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Tamanho adequado" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Подходящий размер" } }, + "en": { "stringUnit": { "state": "translated", "value": "Reasonable Size" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "合理大小" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "合理大小" } } } }, + "action.reasonable-size.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «حجم مناسب»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Angemessene Größe“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Tamaño adecuado»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Taille raisonnable»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「適切なサイズ」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “적절한 크기” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Tamanho adequado»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Подходящий размер»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Resize to 60% of the screen, capped at 1025 × 900, and center." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "将窗口设为屏幕的 60%,最大 1025 × 900,并居中。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "將視窗設為螢幕的 60%,最大 1025 × 900,並置中。" } } } }, + "action.move-to-top-edge.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "نقل إلى الحافة العلوية" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "An den oberen Rand verschieben" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover al borde superior" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplacer vers le bord supérieur" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "上端に移動" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "위쪽 가장자리로 이동" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para a margem superior" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переместить к верхнему краю" } }, + "en": { "stringUnit": { "state": "translated", "value": "Move to Top Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到顶部" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到頂端" } } } }, + "action.move-to-top-edge.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «نقل إلى الحافة العلوية»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „An den oberen Rand verschieben“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mover al borde superior»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Déplacer vers le bord supérieur»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「上端に移動」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “위쪽 가장자리로 이동” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Mover para a margem superior»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переместить к верхнему краю»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the top edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕顶部。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕頂端。" } } } }, + "action.move-to-bottom-edge.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "نقل إلى الحافة السفلية" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "An den unteren Rand verschieben" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover al borde inferior" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplacer vers le bord inférieur" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "下端に移動" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "아래쪽 가장자리로 이동" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para a margem inferior" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переместить к нижнему краю" } }, + "en": { "stringUnit": { "state": "translated", "value": "Move to Bottom Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到底部" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到底部" } } } }, + "action.move-to-bottom-edge.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «نقل إلى الحافة السفلية»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „An den unteren Rand verschieben“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mover al borde inferior»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Déplacer vers le bord inférieur»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「下端に移動」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “아래쪽 가장자리로 이동” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Mover para a margem inferior»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переместить к нижнему краю»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the bottom edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕底部。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕底部。" } } } }, + "action.move-to-left-edge.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "نقل إلى الحافة اليسرى" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "An den linken Rand verschieben" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover al borde izquierdo" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplacer vers le bord gauche" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "左端に移動" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "왼쪽 가장자리로 이동" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para a margem esquerda" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переместить к левому краю" } }, + "en": { "stringUnit": { "state": "translated", "value": "Move to Left Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到左侧" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到左側" } } } }, + "action.move-to-left-edge.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «نقل إلى الحافة اليسرى»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „An den linken Rand verschieben“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mover al borde izquierdo»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Déplacer vers le bord gauche»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「左端に移動」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “왼쪽 가장자리로 이동” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Mover para a margem esquerda»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переместить к левому краю»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the left edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕左侧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕左側。" } } } }, + "action.move-to-right-edge.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "نقل إلى الحافة اليمنى" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "An den rechten Rand verschieben" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover al borde derecho" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Déplacer vers le bord droit" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "右端に移動" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "오른쪽 가장자리로 이동" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Mover para a margem direita" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Переместить к правому краю" } }, + "en": { "stringUnit": { "state": "translated", "value": "Move to Right Edge" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "移到右侧" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "移到右側" } } } }, + "action.move-to-right-edge.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «نقل إلى الحافة اليمنى»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „An den rechten Rand verschieben“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Mover al borde derecho»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Déplacer vers le bord droit»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「右端に移動」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “오른쪽 가장자리로 이동” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Mover para a margem direita»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Переместить к правому краю»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Preserve the size and move to the right edge." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "保持大小并移到屏幕右侧。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "保持大小並移到螢幕右側。" } } } }, + "action.first-third.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الثلث الأول" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Erstes Drittel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Primer tercio" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Premier tiers" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最初の 3 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "첫 번째 1/3" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Primeiro terço" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Первая треть" } }, + "en": { "stringUnit": { "state": "translated", "value": "First Third" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左侧三分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左側三分之一" } } } }, + "action.first-third.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الثلث الأول»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Erstes Drittel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Primer tercio»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Premier tiers»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「最初の 3 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “첫 번째 1/3” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Primeiro terço»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Первая треть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the first third of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左侧三分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左側三分之一。" } } } }, + "action.first-two-thirds.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الثلثان الأولان" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Erste zwei Drittel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Primeros dos tercios" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Deux premiers tiers" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最初の 3 分の 2" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "첫 번째 2/3" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Primeiros dois terços" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Первые две трети" } }, + "en": { "stringUnit": { "state": "translated", "value": "First Two Thirds" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左侧三分之二" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左側三分之二" } } } }, + "action.first-two-thirds.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الثلثان الأولان»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Erste zwei Drittel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Primeros dos tercios»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Deux premiers tiers»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「最初の 3 分の 2」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “첫 번째 2/3” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Primeiros dois terços»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Первые две трети»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the first two thirds of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左侧三分之二。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左側三分之二。" } } } }, + "action.center-third.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الثلث الأوسط" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Mittleres Drittel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Tercio central" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Tiers central" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "中央の 3 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "가운데 1/3" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Terço central" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Средняя треть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Center Third" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "中间三分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "中間三分之一" } } } }, + "action.center-third.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الثلث الأوسط»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Mittleres Drittel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Tercio central»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Tiers central»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「中央の 3 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “가운데 1/3” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Terço central»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Средняя треть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the center third of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充中间三分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿中間三分之一。" } } } }, + "action.last-two-thirds.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الثلثان الأخيران" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Letzte zwei Drittel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Últimos dos tercios" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Deux derniers tiers" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最後の 3 分の 2" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 2/3" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Últimos dois terços" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Последние две трети" } }, + "en": { "stringUnit": { "state": "translated", "value": "Last Two Thirds" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右侧三分之二" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右側三分之二" } } } }, + "action.last-two-thirds.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الثلثان الأخيران»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Letzte zwei Drittel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Últimos dos tercios»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Deux derniers tiers»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「最後の 3 分の 2」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “마지막 2/3” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Últimos dois terços»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Последние две трети»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the last two thirds of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右侧三分之二。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右側三分之二。" } } } }, + "action.last-third.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الثلث الأخير" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Letztes Drittel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Último tercio" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dernier tiers" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最後の 3 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 1/3" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Último terço" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Последняя треть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Last Third" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右侧三分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右側三分之一" } } } }, + "action.last-third.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الثلث الأخير»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Letztes Drittel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Último tercio»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Dernier tiers»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「最後の 3 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “마지막 1/3” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Último terço»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Последняя треть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the last third of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右侧三分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右側三分之一。" } } } }, + "action.first-fourth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع الأول" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Erstes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Primer cuarto" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Premier quart" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最初の 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "첫 번째 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Primeiro quarto" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Первая четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "First Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第一个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第一個四分之一" } } } }, + "action.first-fourth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع الأول»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Erstes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Primer cuarto»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Premier quart»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「最初の 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “첫 번째 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Primeiro quarto»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Первая четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the first fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充最左侧四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿最左側四分之一。" } } } }, + "action.second-fourth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع الثاني" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Zweites Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Segundo cuarto" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Deuxième quart" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "2 番目の 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "두 번째 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Segundo quarto" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Вторая четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Second Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第二个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第二個四分之一" } } } }, + "action.second-fourth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع الثاني»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Zweites Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Segundo cuarto»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Deuxième quart»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「2 番目の 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “두 번째 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Segundo quarto»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Вторая четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the second fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充第二个四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿第二個四分之一。" } } } }, + "action.third-fourth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع الثالث" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Drittes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Tercer cuarto" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Troisième quart" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "3 番目の 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "세 번째 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Terceiro quarto" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Третья четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Third Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "第三个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "第三個四分之一" } } } }, + "action.third-fourth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع الثالث»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Drittes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Tercer cuarto»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Troisième quart»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「3 番目の 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “세 번째 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Terceiro quarto»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Третья четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the third fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充第三个四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿第三個四分之一。" } } } }, + "action.last-fourth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "الربع الأخير" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Letztes Viertel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Último cuarto" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dernier quart" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最後の 4 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 1/4" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Último quarto" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Последняя четверть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Last Fourth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "最后一个四分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "最後一個四分之一" } } } }, + "action.last-fourth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «الربع الأخير»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Letztes Viertel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Último cuarto»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Dernier quart»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「最後の 4 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “마지막 1/4” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Último quarto»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Последняя четверть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the last fourth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充最右侧四分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿最右側四分之一。" } } } }, + "action.top-left-sixth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "السدس العلوي الأيسر" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Oberes linkes Sechstel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Sexto superior izquierdo" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sixième supérieur gauche" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "左上 6 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "왼쪽 위 1/6" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Sexto superior esquerdo" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Верхняя левая шестая часть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Top Left Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左上六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左上六分之一" } } } }, + "action.top-left-sixth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «السدس العلوي الأيسر»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Oberes linkes Sechstel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Sexto superior izquierdo»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Sixième supérieur gauche»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「左上 6 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “왼쪽 위 1/6” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Sexto superior esquerdo»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Верхняя левая шестая часть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the top-left sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左上六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左上六分之一。" } } } }, + "action.top-center-sixth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "السدس العلوي الأوسط" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Oberes mittleres Sechstel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Sexto superior central" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sixième supérieur central" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "中央上 6 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "가운데 위 1/6" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Sexto superior central" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Верхняя средняя шестая часть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Top Center Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "中上六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "中上六分之一" } } } }, + "action.top-center-sixth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «السدس العلوي الأوسط»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Oberes mittleres Sechstel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Sexto superior central»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Sixième supérieur central»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「中央上 6 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “가운데 위 1/6” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Sexto superior central»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Верхняя средняя шестая часть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the top-center sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充中上六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿中上六分之一。" } } } }, + "action.top-right-sixth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "السدس العلوي الأيمن" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Oberes rechtes Sechstel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Sexto superior derecho" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sixième supérieur droit" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "右上 6 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "오른쪽 위 1/6" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Sexto superior direito" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Верхняя правая шестая часть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Top Right Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右上六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右上六分之一" } } } }, + "action.top-right-sixth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «السدس العلوي الأيمن»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Oberes rechtes Sechstel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Sexto superior derecho»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Sixième supérieur droit»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「右上 6 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “오른쪽 위 1/6” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Sexto superior direito»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Верхняя правая шестая часть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the top-right sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右上六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右上六分之一。" } } } }, + "action.bottom-left-sixth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "السدس السفلي الأيسر" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Unteres linkes Sechstel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Sexto inferior izquierdo" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sixième inférieur gauche" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "左下 6 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "왼쪽 아래 1/6" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Sexto inferior esquerdo" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Нижняя левая шестая часть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Bottom Left Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "左下六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "左下六分之一" } } } }, + "action.bottom-left-sixth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «السدس السفلي الأيسر»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Unteres linkes Sechstel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Sexto inferior izquierdo»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Sixième inférieur gauche»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「左下 6 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “왼쪽 아래 1/6” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Sexto inferior esquerdo»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Нижняя левая шестая часть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom-left sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充左下六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿左下六分之一。" } } } }, + "action.bottom-center-sixth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "السدس السفلي الأوسط" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Unteres mittleres Sechstel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Sexto inferior central" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sixième inférieur central" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "中央下 6 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "가운데 아래 1/6" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Sexto inferior central" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Нижняя средняя шестая часть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Bottom Center Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "中下六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "中下六分之一" } } } }, + "action.bottom-center-sixth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «السدس السفلي الأوسط»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Unteres mittleres Sechstel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Sexto inferior central»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Sixième inférieur central»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「中央下 6 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “가운데 아래 1/6” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Sexto inferior central»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Нижняя средняя шестая часть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom-center sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充中下六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿中下六分之一。" } } } }, + "action.bottom-right-sixth.title": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "السدس السفلي الأيمن" } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Unteres rechtes Sechstel" } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Sexto inferior derecho" } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Sixième inférieur droit" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "右下 6 分の 1" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "오른쪽 아래 1/6" } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Sexto inferior direito" } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Нижняя правая шестая часть" } }, + "en": { "stringUnit": { "state": "translated", "value": "Bottom Right Sixth" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "右下六分之一" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "右下六分之一" } } } }, + "action.bottom-right-sixth.description": { "extractionState": "manual", "localizations": { + "ar" : { "stringUnit" : { "state" : "translated", "value" : "رتّب النافذة النشطة باستخدام «السدس السفلي الأيمن»." } }, + "de" : { "stringUnit" : { "state" : "translated", "value" : "Ordnet das aktive Fenster mit „Unteres rechtes Sechstel“ an." } }, + "es" : { "stringUnit" : { "state" : "translated", "value" : "Organiza la ventana activa con «Sexto inferior derecho»." } }, + "fr" : { "stringUnit" : { "state" : "translated", "value" : "Dispose la fenêtre active avec «Sixième inférieur droit»." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "アクティブなウインドウに「右下 6 分の 1」を適用します。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "활성 윈도우에 “오른쪽 아래 1/6” 레이아웃을 적용합니다." } }, + "pt" : { "stringUnit" : { "state" : "translated", "value" : "Organiza a janela ativa com «Sexto inferior direito»." } }, + "ru" : { "stringUnit" : { "state" : "translated", "value" : "Размещает активное окно в режиме «Нижняя правая шестая часть»." } }, + "en": { "stringUnit": { "state": "translated", "value": "Fill the bottom-right sixth of the display." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "填充右下六分之一。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "填滿右下六分之一。" } } } } }, "version": "1.0" } diff --git a/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift b/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift index 168bd3df..4bdb57aa 100644 --- a/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift +++ b/Plugins/WindowLayouts/Tests/WindowLayoutsPluginTests.swift @@ -32,7 +32,7 @@ final class WindowLayoutsPluginTests: XCTestCase { ) } - func testAppIntentsAndRunLinksAreExposed() throws { + func testProviderDoesNotVetoSystemExposureAndRunLinksAreAllowed() throws { let plugin = makePlugin() let definition = try XCTUnwrap(plugin.actionDefinitions.first) let reference = ActionReference(key: definition.key) diff --git a/Plugins/WindowLayouts/plugin.json b/Plugins/WindowLayouts/plugin.json index b7b4d82e..13279386 100644 --- a/Plugins/WindowLayouts/plugin.json +++ b/Plugins/WindowLayouts/plugin.json @@ -48,6 +48,87 @@ "summary": "移動聚焦視窗並調整其大小" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description", + "action.toggle-full-screen.title": "@localizable.action.toggle-full-screen.title", + "action.toggle-full-screen.description": "@localizable.action.toggle-full-screen.description", + "action.left-half.title": "@localizable.action.left-half.title", + "action.left-half.description": "@localizable.action.left-half.description", + "action.right-half.title": "@localizable.action.right-half.title", + "action.right-half.description": "@localizable.action.right-half.description", + "action.top-half.title": "@localizable.action.top-half.title", + "action.top-half.description": "@localizable.action.top-half.description", + "action.bottom-half.title": "@localizable.action.bottom-half.title", + "action.bottom-half.description": "@localizable.action.bottom-half.description", + "action.top-left-quarter.title": "@localizable.action.top-left-quarter.title", + "action.top-left-quarter.description": "@localizable.action.top-left-quarter.description", + "action.top-right-quarter.title": "@localizable.action.top-right-quarter.title", + "action.top-right-quarter.description": "@localizable.action.top-right-quarter.description", + "action.bottom-left-quarter.title": "@localizable.action.bottom-left-quarter.title", + "action.bottom-left-quarter.description": "@localizable.action.bottom-left-quarter.description", + "action.bottom-right-quarter.title": "@localizable.action.bottom-right-quarter.title", + "action.bottom-right-quarter.description": "@localizable.action.bottom-right-quarter.description", + "action.maximize.title": "@localizable.action.maximize.title", + "action.maximize.description": "@localizable.action.maximize.description", + "action.maximize-height.title": "@localizable.action.maximize-height.title", + "action.maximize-height.description": "@localizable.action.maximize-height.description", + "action.maximize-width.title": "@localizable.action.maximize-width.title", + "action.maximize-width.description": "@localizable.action.maximize-width.description", + "action.center.title": "@localizable.action.center.title", + "action.center.description": "@localizable.action.center.description", + "action.reasonable-size.title": "@localizable.action.reasonable-size.title", + "action.reasonable-size.description": "@localizable.action.reasonable-size.description", + "action.move-to-top-edge.title": "@localizable.action.move-to-top-edge.title", + "action.move-to-top-edge.description": "@localizable.action.move-to-top-edge.description", + "action.move-to-bottom-edge.title": "@localizable.action.move-to-bottom-edge.title", + "action.move-to-bottom-edge.description": "@localizable.action.move-to-bottom-edge.description", + "action.move-to-left-edge.title": "@localizable.action.move-to-left-edge.title", + "action.move-to-left-edge.description": "@localizable.action.move-to-left-edge.description", + "action.move-to-right-edge.title": "@localizable.action.move-to-right-edge.title", + "action.move-to-right-edge.description": "@localizable.action.move-to-right-edge.description", + "action.first-third.title": "@localizable.action.first-third.title", + "action.first-third.description": "@localizable.action.first-third.description", + "action.first-two-thirds.title": "@localizable.action.first-two-thirds.title", + "action.first-two-thirds.description": "@localizable.action.first-two-thirds.description", + "action.center-third.title": "@localizable.action.center-third.title", + "action.center-third.description": "@localizable.action.center-third.description", + "action.last-two-thirds.title": "@localizable.action.last-two-thirds.title", + "action.last-two-thirds.description": "@localizable.action.last-two-thirds.description", + "action.last-third.title": "@localizable.action.last-third.title", + "action.last-third.description": "@localizable.action.last-third.description", + "action.first-fourth.title": "@localizable.action.first-fourth.title", + "action.first-fourth.description": "@localizable.action.first-fourth.description", + "action.second-fourth.title": "@localizable.action.second-fourth.title", + "action.second-fourth.description": "@localizable.action.second-fourth.description", + "action.third-fourth.title": "@localizable.action.third-fourth.title", + "action.third-fourth.description": "@localizable.action.third-fourth.description", + "action.last-fourth.title": "@localizable.action.last-fourth.title", + "action.last-fourth.description": "@localizable.action.last-fourth.description", + "action.top-left-sixth.title": "@localizable.action.top-left-sixth.title", + "action.top-left-sixth.description": "@localizable.action.top-left-sixth.description", + "action.top-center-sixth.title": "@localizable.action.top-center-sixth.title", + "action.top-center-sixth.description": "@localizable.action.top-center-sixth.description", + "action.top-right-sixth.title": "@localizable.action.top-right-sixth.title", + "action.top-right-sixth.description": "@localizable.action.top-right-sixth.description", + "action.bottom-left-sixth.title": "@localizable.action.bottom-left-sixth.title", + "action.bottom-left-sixth.description": "@localizable.action.bottom-left-sixth.description", + "action.bottom-center-sixth.title": "@localizable.action.bottom-center-sixth.title", + "action.bottom-center-sixth.description": "@localizable.action.bottom-center-sixth.description", + "action.bottom-right-sixth.title": "@localizable.action.bottom-right-sixth.title", + "action.bottom-right-sixth.description": "@localizable.action.bottom-right-sixth.description", + "action.move-to-next-display.title": "@localizable.action.move-to-next-display.title", + "action.move-to-next-display.description": "@localizable.action.move-to-next-display.description", + "action.move-to-previous-display.title": "@localizable.action.move-to-previous-display.title", + "action.move-to-previous-display.description": "@localizable.action.move-to-previous-display.description", + "action.restore-previous-frame.title": "@localizable.action.restore-previous-frame.title", + "action.restore-previous-frame.description": "@localizable.action.restore-previous-frame.description", + "action.custom-command.title": "@localizable.settings.custom.defaultName", + "action.custom-command.description": "@localizable.action.custom.description", + "action.custom-command.parameter-summary": "@localizable.settings.custom.name" + }, "version": "1.0.0", "minHostVersion": "1.2.1", "pluginKitVersion": 5, @@ -65,5 +146,1339 @@ "permissions": [ "accessibility" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "window", + "layouts", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "تخطيطات النوافذ" + ], + "de": [ + "Fensterlayouts" + ], + "en": [ + "Window Layouts" + ], + "es": [ + "Diseños de ventanas" + ], + "fr": [ + "Disposition des fenêtres" + ], + "ja": [ + "ウインドウレイアウト" + ], + "ko": [ + "윈도우 레이아웃" + ], + "pt": [ + "Layouts de janelas" + ], + "ru": [ + "Расположение окон" + ], + "zh-Hans": [ + "窗口布局" + ], + "zh-Hant": [ + "視窗佈局" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "window-titles", + "window-frames" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "window-layouts", + "kind": "mixed", + "staticActions": [ + { + "id": "toggle-full-screen", + "title": "@productStrings.action.toggle-full-screen.title", + "description": "@productStrings.action.toggle-full-screen.description", + "keywords": [ + "窗口布局", + "切换全屏", + "window", + "layout", + "tile", + "layouts", + "toggle", + "full", + "screen" + ], + "systemImage": "arrow.up.left.and.arrow.down.right", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "left-half", + "title": "@productStrings.action.left-half.title", + "description": "@productStrings.action.left-half.description", + "keywords": [ + "窗口布局", + "左半屏", + "window", + "layout", + "tile", + "layouts", + "left", + "half" + ], + "systemImage": "rectangle.lefthalf.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "right-half", + "title": "@productStrings.action.right-half.title", + "description": "@productStrings.action.right-half.description", + "keywords": [ + "窗口布局", + "右半屏", + "window", + "layout", + "tile", + "layouts", + "right", + "half" + ], + "systemImage": "rectangle.righthalf.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "top-half", + "title": "@productStrings.action.top-half.title", + "description": "@productStrings.action.top-half.description", + "keywords": [ + "窗口布局", + "上半屏", + "window", + "layout", + "tile", + "layouts", + "top", + "half" + ], + "systemImage": "rectangle.tophalf.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "bottom-half", + "title": "@productStrings.action.bottom-half.title", + "description": "@productStrings.action.bottom-half.description", + "keywords": [ + "窗口布局", + "下半屏", + "window", + "layout", + "tile", + "layouts", + "bottom", + "half" + ], + "systemImage": "rectangle.bottomhalf.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "top-left-quarter", + "title": "@productStrings.action.top-left-quarter.title", + "description": "@productStrings.action.top-left-quarter.description", + "keywords": [ + "窗口布局", + "左上四分之一", + "window", + "layout", + "tile", + "layouts", + "top", + "left", + "quarter" + ], + "systemImage": "rectangle.split.2x2", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "top-right-quarter", + "title": "@productStrings.action.top-right-quarter.title", + "description": "@productStrings.action.top-right-quarter.description", + "keywords": [ + "窗口布局", + "右上四分之一", + "window", + "layout", + "tile", + "layouts", + "top", + "right", + "quarter" + ], + "systemImage": "rectangle.split.2x2", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "bottom-left-quarter", + "title": "@productStrings.action.bottom-left-quarter.title", + "description": "@productStrings.action.bottom-left-quarter.description", + "keywords": [ + "窗口布局", + "左下四分之一", + "window", + "layout", + "tile", + "layouts", + "bottom", + "left", + "quarter" + ], + "systemImage": "rectangle.split.2x2", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "bottom-right-quarter", + "title": "@productStrings.action.bottom-right-quarter.title", + "description": "@productStrings.action.bottom-right-quarter.description", + "keywords": [ + "窗口布局", + "右下四分之一", + "window", + "layout", + "tile", + "layouts", + "bottom", + "right", + "quarter" + ], + "systemImage": "rectangle.split.2x2", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "maximize", + "title": "@productStrings.action.maximize.title", + "description": "@productStrings.action.maximize.description", + "keywords": [ + "窗口布局", + "最大化窗口", + "window", + "layout", + "tile", + "layouts", + "maximize" + ], + "systemImage": "rectangle.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "maximize-height", + "title": "@productStrings.action.maximize-height.title", + "description": "@productStrings.action.maximize-height.description", + "keywords": [ + "窗口布局", + "最大化高度", + "window", + "layout", + "tile", + "layouts", + "maximize", + "height" + ], + "systemImage": "arrow.up.and.down", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "maximize-width", + "title": "@productStrings.action.maximize-width.title", + "description": "@productStrings.action.maximize-width.description", + "keywords": [ + "窗口布局", + "最大化宽度", + "window", + "layout", + "tile", + "layouts", + "maximize", + "width" + ], + "systemImage": "arrow.left.and.right", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "center", + "title": "@productStrings.action.center.title", + "description": "@productStrings.action.center.description", + "keywords": [ + "窗口布局", + "居中窗口", + "window", + "layout", + "tile", + "layouts", + "center" + ], + "systemImage": "macwindow", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "reasonable-size", + "title": "@productStrings.action.reasonable-size.title", + "description": "@productStrings.action.reasonable-size.description", + "keywords": [ + "窗口布局", + "合理大小", + "window", + "layout", + "tile", + "layouts", + "reasonable", + "size" + ], + "systemImage": "rectangle.center.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "move-to-top-edge", + "title": "@productStrings.action.move-to-top-edge.title", + "description": "@productStrings.action.move-to-top-edge.description", + "keywords": [ + "窗口布局", + "移到顶部", + "window", + "layout", + "tile", + "layouts", + "move", + "to", + "top", + "edge" + ], + "systemImage": "arrow.up.to.line", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "move-to-bottom-edge", + "title": "@productStrings.action.move-to-bottom-edge.title", + "description": "@productStrings.action.move-to-bottom-edge.description", + "keywords": [ + "窗口布局", + "移到底部", + "window", + "layout", + "tile", + "layouts", + "move", + "to", + "bottom", + "edge" + ], + "systemImage": "arrow.down.to.line", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "move-to-left-edge", + "title": "@productStrings.action.move-to-left-edge.title", + "description": "@productStrings.action.move-to-left-edge.description", + "keywords": [ + "窗口布局", + "移到左侧", + "window", + "layout", + "tile", + "layouts", + "move", + "to", + "left", + "edge" + ], + "systemImage": "arrow.left.to.line", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "move-to-right-edge", + "title": "@productStrings.action.move-to-right-edge.title", + "description": "@productStrings.action.move-to-right-edge.description", + "keywords": [ + "窗口布局", + "移到右侧", + "window", + "layout", + "tile", + "layouts", + "move", + "to", + "right", + "edge" + ], + "systemImage": "arrow.right.to.line", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "first-third", + "title": "@productStrings.action.first-third.title", + "description": "@productStrings.action.first-third.description", + "keywords": [ + "窗口布局", + "左侧三分之一", + "window", + "layout", + "tile", + "layouts", + "first", + "third" + ], + "systemImage": "rectangle.leadingthird.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "first-two-thirds", + "title": "@productStrings.action.first-two-thirds.title", + "description": "@productStrings.action.first-two-thirds.description", + "keywords": [ + "窗口布局", + "左侧三分之二", + "window", + "layout", + "tile", + "layouts", + "first", + "two", + "thirds" + ], + "systemImage": "rectangle.leadinghalf.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "center-third", + "title": "@productStrings.action.center-third.title", + "description": "@productStrings.action.center-third.description", + "keywords": [ + "窗口布局", + "中间三分之一", + "window", + "layout", + "tile", + "layouts", + "center", + "third" + ], + "systemImage": "rectangle.center.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "last-two-thirds", + "title": "@productStrings.action.last-two-thirds.title", + "description": "@productStrings.action.last-two-thirds.description", + "keywords": [ + "窗口布局", + "右侧三分之二", + "window", + "layout", + "tile", + "layouts", + "last", + "two", + "thirds" + ], + "systemImage": "rectangle.trailinghalf.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "last-third", + "title": "@productStrings.action.last-third.title", + "description": "@productStrings.action.last-third.description", + "keywords": [ + "窗口布局", + "右侧三分之一", + "window", + "layout", + "tile", + "layouts", + "last", + "third" + ], + "systemImage": "rectangle.trailingthird.inset.filled", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "first-fourth", + "title": "@productStrings.action.first-fourth.title", + "description": "@productStrings.action.first-fourth.description", + "keywords": [ + "窗口布局", + "第一个四分之一", + "window", + "layout", + "tile", + "layouts", + "first", + "fourth" + ], + "systemImage": "rectangle.split.3x1", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "second-fourth", + "title": "@productStrings.action.second-fourth.title", + "description": "@productStrings.action.second-fourth.description", + "keywords": [ + "窗口布局", + "第二个四分之一", + "window", + "layout", + "tile", + "layouts", + "second", + "fourth" + ], + "systemImage": "rectangle.split.3x1", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "third-fourth", + "title": "@productStrings.action.third-fourth.title", + "description": "@productStrings.action.third-fourth.description", + "keywords": [ + "窗口布局", + "第三个四分之一", + "window", + "layout", + "tile", + "layouts", + "third", + "fourth" + ], + "systemImage": "rectangle.split.3x1", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "last-fourth", + "title": "@productStrings.action.last-fourth.title", + "description": "@productStrings.action.last-fourth.description", + "keywords": [ + "窗口布局", + "最后一个四分之一", + "window", + "layout", + "tile", + "layouts", + "last", + "fourth" + ], + "systemImage": "rectangle.split.3x1", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "top-left-sixth", + "title": "@productStrings.action.top-left-sixth.title", + "description": "@productStrings.action.top-left-sixth.description", + "keywords": [ + "窗口布局", + "左上六分之一", + "window", + "layout", + "tile", + "layouts", + "top", + "left", + "sixth" + ], + "systemImage": "rectangle.split.3x3", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "top-center-sixth", + "title": "@productStrings.action.top-center-sixth.title", + "description": "@productStrings.action.top-center-sixth.description", + "keywords": [ + "窗口布局", + "中上六分之一", + "window", + "layout", + "tile", + "layouts", + "top", + "center", + "sixth" + ], + "systemImage": "rectangle.split.3x3", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "top-right-sixth", + "title": "@productStrings.action.top-right-sixth.title", + "description": "@productStrings.action.top-right-sixth.description", + "keywords": [ + "窗口布局", + "右上六分之一", + "window", + "layout", + "tile", + "layouts", + "top", + "right", + "sixth" + ], + "systemImage": "rectangle.split.3x3", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "bottom-left-sixth", + "title": "@productStrings.action.bottom-left-sixth.title", + "description": "@productStrings.action.bottom-left-sixth.description", + "keywords": [ + "窗口布局", + "左下六分之一", + "window", + "layout", + "tile", + "layouts", + "bottom", + "left", + "sixth" + ], + "systemImage": "rectangle.split.3x3", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "bottom-center-sixth", + "title": "@productStrings.action.bottom-center-sixth.title", + "description": "@productStrings.action.bottom-center-sixth.description", + "keywords": [ + "窗口布局", + "中下六分之一", + "window", + "layout", + "tile", + "layouts", + "bottom", + "center", + "sixth" + ], + "systemImage": "rectangle.split.3x3", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "bottom-right-sixth", + "title": "@productStrings.action.bottom-right-sixth.title", + "description": "@productStrings.action.bottom-right-sixth.description", + "keywords": [ + "窗口布局", + "右下六分之一", + "window", + "layout", + "tile", + "layouts", + "bottom", + "right", + "sixth" + ], + "systemImage": "rectangle.split.3x3", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "move-to-next-display", + "title": "@productStrings.action.move-to-next-display.title", + "description": "@productStrings.action.move-to-next-display.description", + "keywords": [ + "窗口布局", + "移到下一台显示器", + "window", + "layout", + "tile", + "layouts", + "move", + "to", + "next", + "display" + ], + "systemImage": "arrow.right.square", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "move-to-previous-display", + "title": "@productStrings.action.move-to-previous-display.title", + "description": "@productStrings.action.move-to-previous-display.description", + "keywords": [ + "窗口布局", + "移到上一台显示器", + "window", + "layout", + "tile", + "layouts", + "move", + "to", + "previous", + "display" + ], + "systemImage": "arrow.left.square", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + }, + { + "id": "restore-previous-frame", + "title": "@productStrings.action.restore-previous-frame.title", + "description": "@productStrings.action.restore-previous-frame.description", + "keywords": [ + "窗口布局", + "恢复上一个窗口位置", + "window", + "layout", + "tile", + "layouts", + "restore", + "previous", + "frame" + ], + "systemImage": "arrow.uturn.backward.square", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "allowed" + } + ], + "dynamicTemplates": [ + { + "id": "custom-command", + "title": "@productStrings.action.custom-command.title", + "description": "@productStrings.action.custom-command.description", + "entrySource": "custom-window-layouts", + "parameters": [], + "parameterSummary": "@productStrings.action.custom-command.parameter-summary", + "localOnlyIdentity": true, + "keywords": [ + "window", + "layouts", + "custom", + "command" + ], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "run-link", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "configurable" + } + ] + } + ] + } } diff --git a/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift b/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift index 1f06660a..5899e20f 100644 --- a/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift +++ b/Plugins/WindowSwitcher/Tests/WindowSwitcherPluginTests.swift @@ -53,6 +53,22 @@ private final class WindowSwitcherMemoryStorage: PluginStorage { @MainActor final class WindowSwitcherPluginTests: XCTestCase { + func testManifestActionMatchesRuntimePolicy() throws { + let plugin = WindowSwitcherPlugin( + context: PluginRuntimeContext( + pluginID: WindowSwitcherConstants.pluginID, + storage: WindowSwitcherMemoryStorage() + ), + accessibilityTrusted: { true } + ) + + try PluginManifestActionAssertions.assertConsistency( + pluginDirectoryName: "WindowSwitcher", + definitions: plugin.actionDefinitions, + permissionIDs: plugin.permissionRequirementIDs(for:) + ) + } + func testShortcutRecorderUsesGroupSummaryWithoutDuplicateControlLabel() { let plugin = WindowSwitcherPlugin(accessibilityTrusted: { true }) let definition = plugin.shortcutDefinitions.first diff --git a/Plugins/WindowSwitcher/plugin.json b/Plugins/WindowSwitcher/plugin.json index d15c1c26..a6dc9461 100644 --- a/Plugins/WindowSwitcher/plugin.json +++ b/Plugins/WindowSwitcher/plugin.json @@ -48,6 +48,112 @@ "summary": "用可設定快速鍵快速切換正在執行的視窗" } }, + "productStrings": { + "long-description": { + "ar": "بدّل بين نوافذ التطبيقات باستخدام اختصار عام قابل للتخصيص وطريقة عرض تفاعلية في المقدمة.", + "de": "Wechsle mit einem konfigurierbaren globalen Kurzbefehl und einer interaktiven Vordergrundansicht zwischen App-Fenstern.", + "en": "Switch between application windows with a configurable global shortcut and an interactive foreground picker.", + "es": "Cambia entre ventanas de aplicaciones con un atajo global configurable y un selector interactivo.", + "fr": "Passez d’une fenêtre d’app à l’autre avec un raccourci global configurable et un sélecteur interactif.", + "ja": "設定可能なグローバルショートカットと対話式の前面ピッカーでアプリのウインドウを切り替えます。", + "ko": "설정 가능한 전역 단축키와 대화형 전면 선택기로 앱 윈도우를 전환합니다.", + "pt": "Alterne entre janelas de apps com um atalho global configurável e um seletor interativo.", + "ru": "Переключайтесь между окнами приложений с помощью настраиваемого глобального сочетания и интерактивного выбора.", + "zh-Hans": "使用可配置的全局快捷键和前台交互选择器切换应用窗口。", + "zh-Hant": "使用可設定的全域快速鍵和前景互動選擇器切換 App 視窗。" + }, + "example-keyboard-switching": { + "ar": "اضغط الاختصار مرارًا لاختيار نافذة من لوحة المفاتيح.", + "de": "Drücke den Kurzbefehl wiederholt, um ein Fenster per Tastatur auszuwählen.", + "en": "Press the shortcut repeatedly to choose a window from the keyboard.", + "es": "Pulsa el atajo varias veces para elegir una ventana con el teclado.", + "fr": "Appuyez plusieurs fois sur le raccourci pour choisir une fenêtre au clavier.", + "ja": "ショートカットを繰り返し押してキーボードからウインドウを選びます。", + "ko": "단축키를 반복해서 눌러 키보드로 윈도우를 선택합니다.", + "pt": "Prima repetidamente o atalho para escolher uma janela pelo teclado.", + "ru": "Нажимайте сочетание повторно, чтобы выбрать окно с клавиатуры.", + "zh-Hans": "重复按快捷键,通过键盘选择窗口。", + "zh-Hant": "重複按快速鍵,透過鍵盤選擇視窗。" + }, + "use-case-switch-running-windows": { + "ar": "التبديل بين النوافذ المفتوحة", + "de": "Zwischen geöffneten Fenstern wechseln", + "en": "Switch running windows", + "es": "Cambiar entre ventanas abiertas", + "fr": "Changer de fenêtre ouverte", + "ja": "開いているウインドウを切り替える", + "ko": "실행 중인 윈도우 전환", + "pt": "Alternar janelas abertas", + "ru": "Переключать открытые окна", + "zh-Hans": "切换正在运行的窗口", + "zh-Hant": "切換正在執行的視窗" + }, + "action-window-switcher-switch-title": { + "ar": "فتح مبدّل النوافذ", + "de": "Fensterwechsel öffnen", + "en": "Open Window Switcher", + "es": "Abrir cambiador de ventanas", + "fr": "Ouvrir le sélecteur de fenêtres", + "ja": "ウインドウ切り替えを開く", + "ko": "윈도우 전환기 열기", + "pt": "Abrir alternador de janelas", + "ru": "Открыть переключатель окон", + "zh-Hans": "打开窗口切换", + "zh-Hant": "開啟視窗切換" + }, + "action-window-switcher-switch-description": { + "ar": "يعرض منتقيًا تفاعليًا للتبديل بين نوافذ التطبيقات.", + "de": "Zeigt einen interaktiven Auswahldialog zum Wechseln zwischen App-Fenstern.", + "en": "Show an interactive picker for switching between application windows.", + "es": "Muestra un selector interactivo para cambiar entre ventanas de aplicaciones.", + "fr": "Affiche un sélecteur interactif pour changer de fenêtre d’app.", + "ja": "アプリのウインドウを切り替える対話式ピッカーを表示します。", + "ko": "앱 윈도우를 전환하는 대화형 선택기를 표시합니다.", + "pt": "Mostra um seletor interativo para alternar entre janelas de apps.", + "ru": "Показывает интерактивный выбор для переключения между окнами приложений.", + "zh-Hans": "显示用于切换应用窗口的交互选择器。", + "zh-Hant": "顯示用於切換 App 視窗的互動選擇器。" + }, + "setup-grant-accessibility-title": { + "ar": "السماح بإمكانية الوصول", + "de": "Bedienungshilfen erlauben", + "en": "Allow Accessibility", + "es": "Permitir Accesibilidad", + "fr": "Autoriser l’accessibilité", + "ja": "アクセシビリティを許可", + "ko": "손쉬운 사용 허용", + "pt": "Permitir Acessibilidade", + "ru": "Разрешить Универсальный доступ", + "zh-Hans": "允许辅助功能", + "zh-Hant": "允許輔助使用" + }, + "setup-grant-accessibility-description": { + "ar": "اسمح لـ MacTools بعرض النوافذ والتبديل إليها عبر الاختصار العام.", + "de": "Erlaube MacTools, Fenster über den globalen Kurzbefehl anzuzeigen und zu wechseln.", + "en": "Allow MacTools to list and switch windows from the global shortcut.", + "es": "Permite que MacTools enumere y cambie ventanas con el atajo global.", + "fr": "Autorisez MacTools à lister et changer les fenêtres avec le raccourci global.", + "ja": "グローバルショートカットからウインドウを一覧表示して切り替えることを許可します。", + "ko": "MacTools가 전역 단축키로 윈도우를 나열하고 전환하도록 허용합니다.", + "pt": "Permita que o MacTools liste e alterne janelas pelo atalho global.", + "ru": "Разрешите MacTools показывать и переключать окна глобальным сочетанием.", + "zh-Hans": "允许 MacTools 通过全局快捷键列出并切换窗口。", + "zh-Hant": "允許 MacTools 透過全域快速鍵列出並切換視窗。" + }, + "setup-missing-dependency-help": { + "ar": "فعّل إذن إمكانية الوصول واختصار مبدّل النوافذ.", + "de": "Aktiviere die Bedienungshilfe-Berechtigung und den Fensterwechsel-Kurzbefehl.", + "en": "Enable Accessibility permission and the Window Switcher shortcut.", + "es": "Activa el permiso de Accesibilidad y el atajo del cambiador de ventanas.", + "fr": "Activez l’autorisation d’accessibilité et le raccourci du sélecteur de fenêtres.", + "ja": "アクセシビリティ権限とウインドウ切り替えショートカットを有効にしてください。", + "ko": "손쉬운 사용 권한과 윈도우 전환기 단축키를 활성화하십시오.", + "pt": "Ative a permissão de Acessibilidade e o atalho do alternador de janelas.", + "ru": "Включите разрешение Универсального доступа и сочетание переключателя окон.", + "zh-Hans": "启用辅助功能权限和窗口切换快捷键。", + "zh-Hant": "啟用輔助使用權限和視窗切換快速鍵。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -65,5 +171,185 @@ "permissions": [ "accessibility" ], - "category": "productivity" + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.long-description", + "examples": [ + { + "id": "keyboard-switching", + "text": "@productStrings.example-keyboard-switching" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "window switcher", + "application switcher", + "keyboard", + "shortcut" + ], + "localizedSynonyms": { + "ar": [ + "تبديل النوافذ", + "اختصار النوافذ" + ], + "de": [ + "Fensterwechsel", + "Fensterkurzbefehl" + ], + "en": [ + "window switcher", + "window shortcut" + ], + "es": [ + "cambiador de ventanas", + "atajo de ventanas" + ], + "fr": [ + "sélecteur de fenêtres", + "raccourci de fenêtre" + ], + "ja": [ + "ウインドウ切り替え", + "ウインドウショートカット" + ], + "ko": [ + "윈도우 전환", + "윈도우 단축키" + ], + "pt": [ + "alternador de janelas", + "atalho de janelas" + ], + "ru": [ + "переключатель окон", + "сочетание для окон" + ], + "zh-Hans": [ + "窗口切换", + "窗口快捷键" + ], + "zh-Hant": [ + "視窗切換", + "視窗快速鍵" + ] + }, + "useCases": [ + { + "id": "switch-running-windows", + "title": "@productStrings.use-case-switch-running-windows" + } + ], + "goalCategories": [ + "productivity", + "keyboard-navigation" + ], + "relatedPluginIDs": [ + "app-hotkey" + ], + "alternativePluginIDs": [] + }, + "requirements": { + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [ + "keyboard" + ], + "applications": [], + "executables": [], + "permissionIDs": [ + "accessibility" + ], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "running-applications", + "application-windows", + "window-titles" + ], + "dataPersisted": [ + "switcher-configuration", + "shortcut-bindings" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "actions": { + "providers": [ + { + "id": "window-switcher", + "kind": "static", + "staticActions": [ + { + "id": "switch", + "title": "@productStrings.action-window-switcher-switch-title", + "description": "@productStrings.action-window-switcher-switch-description", + "keywords": [ + "window switcher", + "keyboard", + "shortcut" + ], + "systemImage": "rectangle.2.swap", + "parameters": [], + "permissionIDs": [ + "accessibility" + ], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + }, + "setup": { + "steps": [ + { + "id": "grant-accessibility", + "title": "@productStrings.setup-grant-accessibility-title", + "description": "@productStrings.setup-grant-accessibility-description" + } + ], + "suggestedTestAction": { + "providerID": "window-switcher", + "actionID": "switch" + }, + "optionalSurfaces": [ + "action-grid", + "trackpad-gesture" + ], + "missingDependencyHelp": "@productStrings.setup-missing-dependency-help" + }, + "relationships": { + "relatedPluginIDs": [ + "app-hotkey" + ], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/Plugins/XcodeClean/plugin.json b/Plugins/XcodeClean/plugin.json index 161675d7..e72baea6 100644 --- a/Plugins/XcodeClean/plugin.json +++ b/Plugins/XcodeClean/plugin.json @@ -48,6 +48,12 @@ "summary": "分類清理 Xcode DerivedData、裝置支援、封存與快取" } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "setup.requirements.title": "@standardSetup.requirements.title", + "setup.requirements.description": "@standardSetup.requirements.description" + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -63,5 +69,165 @@ "settings": "workspace" }, "permissions": [], - "category": "storage" + "category": "storage", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "xcode", + "clean", + "cleanup", + "storage" + ], + "localizedSynonyms": { + "ar": [ + "تنظيف Xcode" + ], + "de": [ + "Xcode-Bereinigung" + ], + "en": [ + "Xcode Cleanup" + ], + "es": [ + "Limpieza de Xcode" + ], + "fr": [ + "Nettoyage Xcode" + ], + "ja": [ + "Xcode クリーンアップ" + ], + "ko": [ + "Xcode 정리" + ], + "pt": [ + "Limpeza do Xcode" + ], + "ru": [ + "Очистка Xcode" + ], + "zh-Hans": [ + "Xcode 清理" + ], + "zh-Hant": [ + "Xcode 清理" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "storage" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [ + { + "bundleID": "com.apple.dt.Xcode", + "name": "Xcode" + } + ], + "executables": [], + "permissionIDs": [], + "setupComplexity": "guided", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "developer-cache-paths", + "file-sizes" + ], + "dataPersisted": [ + "plugin-configuration" + ], + "retention": { + "policy": "user-controlled" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [ + { + "id": "review-requirements", + "title": "@productStrings.setup.requirements.title", + "description": "@productStrings.setup.requirements.description" + } + ], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + }, + "actions": { + "providers": [ + { + "id": "xcode-clean", + "kind": "static", + "staticActions": [ + { + "id": "scan-and-review", + "title": "@productStrings.display-name", + "description": "@productStrings.summary", + "keywords": [ + "Xcode 清理", + "扫描", + "Xcode", + "xcode", + "clean", + "scan", + "and", + "review" + ], + "systemImage": "magnifyingglass", + "parameters": [], + "permissionIDs": [], + "risk": "safe", + "surfaces": [ + "unified-search", + "global-shortcut", + "workflow", + "action-grid", + "trackpad-gesture", + "manual" + ], + "automaticEligible": false, + "externalInvocation": "unavailable" + } + ], + "dynamicTemplates": [] + } + ] + } } diff --git a/Plugins/ZshConfig/Resources/Localizable.xcstrings b/Plugins/ZshConfig/Resources/Localizable.xcstrings index 8cc7b9b0..bbaef72b 100644 --- a/Plugins/ZshConfig/Resources/Localizable.xcstrings +++ b/Plugins/ZshConfig/Resources/Localizable.xcstrings @@ -5112,6 +5112,289 @@ } } } + }, + "permission.automation.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب تحميل الإعدادات من الوحدة الطرفية التحكم في تطبيق الوحدة الطرفية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Laden der Konfiguration aus Terminal muss Terminal gesteuert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading configuration from Terminal requires controlling Terminal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargar la configuración desde Terminal requiere controlar Terminal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charger la configuration depuis Terminal nécessite de contrôler Terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルから設定を読み込むには、ターミナルの制御が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널에서 구성을 불러오려면 터미널을 제어해야 합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregar a configuração pelo Terminal requer o controle do Terminal." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для загрузки конфигурации из Терминала требуется управление Терминалом." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从终端载入配置时需要控制“终端”。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從「終端機」載入設定時需要控制「終端機」。" + } + } + } + }, + "permission.automation.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيطلب macOS إذن الأتمتة عند تحميل الإعدادات من الوحدة الطرفية للمرة الأولى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS fragt beim ersten Laden der Konfiguration aus Terminal nach der Automation-Berechtigung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS will request Automation access the first time configuration is loaded from Terminal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "macOS solicitará acceso de automatización la primera vez que se cargue la configuración desde Terminal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "macOS demandera l’autorisation d’automatisation lors du premier chargement de la configuration depuis Terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルから初めて設定を読み込むときに、macOSがオートメーションの許可を求めます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널에서 구성을 처음 불러올 때 macOS가 자동화 권한을 요청합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O macOS solicitará acesso à Automação na primeira vez que a configuração for carregada pelo Terminal." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "При первой загрузке конфигурации из Терминала macOS запросит доступ к автоматизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "macOS 会在首次从终端载入配置时请求自动化授权。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "macOS 會在首次從「終端機」載入設定時要求自動化授權。" + } + } + } + }, + "permission.automation.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأتمتة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automatización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オートメーション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Automação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматизация" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動化" + } + } + } + }, + "permission.automation.status": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد عند الاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigung bei Verwendung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm on Use" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar al usar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmation à l’utilisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用時に確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 시 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ao usar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтверждение при использовании" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按需确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按需確認" + } + } + } } }, "version": "1.0" diff --git a/Plugins/ZshConfig/Sources/ZshConfigPlugin.swift b/Plugins/ZshConfig/Sources/ZshConfigPlugin.swift index 5b701df4..edd19be5 100644 --- a/Plugins/ZshConfig/Sources/ZshConfigPlugin.swift +++ b/Plugins/ZshConfig/Sources/ZshConfigPlugin.swift @@ -31,6 +31,10 @@ private enum ControlID { @MainActor final class ZshConfigPlugin: MacToolsPlugin, PluginPrimaryPanel { + private enum PermissionID { + static let automation = "automation" + } + // MARK: Metadata let metadata: PluginMetadata @@ -102,6 +106,42 @@ final class ZshConfigPlugin: MacToolsPlugin, PluginPrimaryPanel { // The host intercepts the Edit button and navigates to this plugin's settings page. } + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.automation, + kind: .automation, + title: localization.string("permission.automation.title", defaultValue: "自动化"), + description: localization.string( + "permission.automation.description", + defaultValue: "从终端载入配置时需要控制“终端”。" + ) + ), + ] + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState( + isGranted: false, + footnote: permissionID == PermissionID.automation + ? localization.string( + "permission.automation.footnote", + defaultValue: "macOS 会在首次从终端载入配置时请求自动化授权。" + ) + : nil, + statusText: permissionID == PermissionID.automation + ? localization.string("permission.automation.status", defaultValue: "按需确认") + : nil, + statusSystemImage: permissionID == PermissionID.automation ? "cursorarrow.click.2" : nil, + statusTone: permissionID == PermissionID.automation ? .neutral : nil + ) + } + + func handlePermissionAction(id: String) { + guard id == PermissionID.automation else { return } + requestPermissionGuidance?(PermissionID.automation) + } + // MARK: - Configuration var settingsPage: PluginSettingsPage? { diff --git a/Plugins/ZshConfig/Tests/ZshConfigTests.swift b/Plugins/ZshConfig/Tests/ZshConfigTests.swift index 66730b45..d1d7d592 100644 --- a/Plugins/ZshConfig/Tests/ZshConfigTests.swift +++ b/Plugins/ZshConfig/Tests/ZshConfigTests.swift @@ -2,6 +2,17 @@ import XCTest @testable import ZshConfigPlugin final class ZshConfigTests: XCTestCase { + @MainActor + func testPublishesOptionalAutomationRequirement() { + let plugin = ZshConfigPlugin() + + XCTAssertEqual(plugin.permissionRequirements.map(\.id), ["automation"]) + let state = plugin.permissionState(for: "automation") + XCTAssertFalse(state.isGranted) + XCTAssertEqual(state.statusText, "按需确认") + XCTAssertEqual(state.statusTone, .neutral) + } + func testFileTypesExposeStableFilenamesAndMetadata() throws { XCTAssertEqual(ZshConfigFileType.allCases.map(\.filename), [ ".zshrc", diff --git a/Plugins/ZshConfig/plugin.json b/Plugins/ZshConfig/plugin.json index 1162868d..3341201a 100644 --- a/Plugins/ZshConfig/plugin.json +++ b/Plugins/ZshConfig/plugin.json @@ -48,6 +48,23 @@ "summary": "حرّر ~/.zshrc وملفات إعداد zsh الأخرى بسرعة، مع تحرير مدمج ومقتطفات شائعة." } }, + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "retention-description": { + "ar": "يُحفظ محتوى ملفات shell ونسخة احتياطية واحدة بلاحقة .bak لكل ملف shell مُحرَّر. يستبدل الحفظ التالي لذلك الملف نسخته الاحتياطية، وتبقى النسخ الاحتياطية حتى تحذفها.", + "de": "Der Inhalt von Shell-Dateien und je eine .bak-Sicherung pro bearbeiteter Shell-Datei werden gespeichert. Beim nächsten Speichern derselben Datei wird ihre Sicherung ersetzt; andernfalls bleibt sie bestehen, bis du sie löschst.", + "en": "Shell file contents and one .bak backup per edited shell file are stored. The next save of that file replaces its backup; backups otherwise remain until you remove them.", + "es": "Se guardan el contenido de los archivos de shell y una copia .bak por cada archivo de shell editado. Al volver a guardar ese archivo se reemplaza su copia; las copias permanecen hasta que las elimines.", + "fr": "Le contenu des fichiers shell et une sauvegarde .bak par fichier shell modifié sont conservés. Le prochain enregistrement de ce fichier remplace sa sauvegarde ; les sauvegardes restent sinon jusqu’à leur suppression.", + "ja": "シェルファイルの内容と、編集したシェルファイルごとに 1 つの .bak バックアップを保存します。そのファイルを次回保存するとバックアップが置き換えられ、削除するまで保持されます。", + "ko": "셸 파일 내용과 편집한 셸 파일마다 하나의 .bak 백업을 저장합니다. 해당 파일을 다음에 저장하면 그 백업을 교체하며, 직접 삭제할 때까지 유지됩니다.", + "pt": "O conteúdo dos arquivos de shell e um backup .bak por arquivo de shell editado são armazenados. O próximo salvamento desse arquivo substitui seu backup; os backups permanecem até serem removidos.", + "ru": "Содержимое файлов оболочки и одна резервная копия .bak для каждого изменённого файла сохраняются. При следующем сохранении этого файла его копия заменяется; резервные копии хранятся до удаления пользователем.", + "zh-Hans": "会保存 Shell 文件内容,并为每个编辑过的 Shell 文件保留一个 .bak 备份。再次保存该文件时会替换它的备份;这些备份会保留到你手动删除为止。", + "zh-Hant": "會儲存 Shell 檔案內容,並為每個編輯過的 Shell 檔案保留一個 .bak 備份。再次儲存該檔案時會取代它的備份;這些備份會保留到你手動刪除為止。" + } + }, "version": "1.1.0", "minHostVersion": "1.2.0", "pluginKitVersion": 5, @@ -62,6 +79,120 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], - "category": "productivity" + "permissions": [ + "automation" + ], + "category": "productivity", + "presentation": { + "longDescription": "@productStrings.summary", + "examples": [ + { + "id": "primary-use", + "text": "@productStrings.summary" + } + ], + "screenshots": [], + "documentationURL": "https://github.com/ggbond268/MacTools#plugins-and-settings", + "supportURL": "https://github.com/ggbond268/MacTools/issues", + "publisher": "MacTools", + "license": "Apache-2.0" + }, + "discovery": { + "keywords": [ + "zsh", + "config", + "productivity" + ], + "localizedSynonyms": { + "ar": [ + "إعدادات zsh" + ], + "de": [ + "zsh-Konfig." + ], + "en": [ + "zsh Config" + ], + "es": [ + "Config. zsh" + ], + "fr": [ + "Config. zsh" + ], + "ja": [ + "zsh 設定" + ], + "ko": [ + "zsh 설정" + ], + "pt": [ + "Config. zsh" + ], + "ru": [ + "Настройки zsh" + ], + "zh-Hans": [ + "zsh 配置" + ], + "zh-Hant": [ + "zsh 設定" + ] + }, + "useCases": [ + { + "id": "primary-use", + "title": "@productStrings.display-name" + } + ], + "goalCategories": [ + "productivity" + ], + "relatedPluginIDs": [], + "alternativePluginIDs": [] + }, + "requirements": { + "minimumMacOSVersion": "14.0", + "architectures": [ + "arm64", + "x86_64" + ], + "hardware": [], + "applications": [], + "executables": [], + "permissionIDs": [ + "automation" + ], + "setupComplexity": "none", + "requiresRelaunch": false + }, + "privacy": { + "dataObserved": [ + "shell-configuration" + ], + "dataPersisted": [ + "plugin-configuration", + "shell-configuration", + "shell-configuration-backup" + ], + "retention": { + "policy": "user-controlled", + "description": "@productStrings.retention-description" + }, + "networkUse": "none", + "networkDomains": [], + "allowsUserConfiguredDomains": false, + "telemetry": "none", + "processesSensitiveUserContent": true, + "diagnosticExportsContainUserData": false + }, + "setup": { + "steps": [], + "optionalSurfaces": [] + }, + "relationships": { + "relatedPluginIDs": [], + "includedPackIDs": [], + "suggestedRecipeIDs": [], + "supersedesPluginIDs": [] + } } diff --git a/README.md b/README.md index 589c51ea..91dbde7b 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ > **Shortcut settings:** Plugin and app shortcut rows keep the action icon/name beside the recorder field, wrapping groups between rows without splitting an individual shortcut control. +> **Pre-install plugin metadata:** The signed plugin catalog can disclose localized product details, requirements, privacy behavior, setup guidance, and static or dynamic action capabilities before a plugin is installed. The same validated metadata is available to Marketplace, search, onboarding, and website clients without loading plugin code or publishing machine-local action entries. + > **Menu-bar panels:** Dashboard cards and their nested controls use shared semantic surfaces and text levels in Light, Dark, and Increased Contrast appearances, without decorative borders or shadows. The surrounding popover and its attachment arrow use one opaque semantic background so wallpaper colors do not reduce contrast. Settings includes independent Light and Dark themes, ten built-in palettes, System Default, live previews, and imports from `.itermcolors` (including `.txt`) or Base16/Base24 YAML/JSON files; themes preserve layout, typography, calendar event colors, and brand colors. > **Preferences backup:** MacTools automatically keeps a local, deduplicated history of portable settings, with a 60-second debounce, a pre-import safety snapshot, human-readable local date-time filenames, and bounded hourly/daily/weekly retention. Each importable backup is limited to 16 MiB, while automatic history is capped at 100 snapshots and 128 MiB. Only successful saved portable-setting changes start or restart the timer; runtime history and cache writes are ignored. Automatic backup is enabled by default and can be disabled, run immediately, or opened in Finder from General settings, which also shows relative backup freshness, the exact timestamp, snapshot count, history size, and whether a manual backup found no changes. Export and import both let you select app preferences, plugin layout, shortcuts, automation, saved Run Links, and each plugin’s portable settings independently, with dependency notes beside every category. Workflow IDs and direct workflow Run Links are preserved; importing an individual workflow preserves its ID on a new Mac and creates a new ID only when that ID already exists. Automation rules bound to a display identifier from the current Mac are omitted with an export warning. Saved Scripts source is excluded unless each script explicitly opts in, and working directories are never included. Apple Shortcuts backup includes only per-shortcut safety policies; the destination Mac discovers its own current Shortcuts library. Permissions, caches, credentials, and other non-portable data are excluded. Missing plugins are never installed automatically: the scrollable preview lets you explicitly select catalog-verified plugins to install, while unavailable or unselected plugins and their settings are skipped. diff --git a/Sources/App/MacToolsSearch.swift b/Sources/App/MacToolsSearch.swift index 2c71a676..9f1af131 100644 --- a/Sources/App/MacToolsSearch.swift +++ b/Sources/App/MacToolsSearch.swift @@ -564,10 +564,6 @@ enum MacToolsSearchIndexBuilder { } items += pluginHost.pluginManagementItems.compactMap { item in - guard item.canUninstall else { - return nil - } - return MacToolsSearchResult( id: "plugin.marketplace.\(item.id)", kind: .navigation, @@ -580,7 +576,8 @@ enum MacToolsSearchIndexBuilder { keywords: pluginMetadataKeywords( pluginID: item.id, category: item.category, - releaseChannel: item.releaseChannel + releaseChannel: item.releaseChannel, + additionalKeywords: item.productSearchKeywords ) + [item.statusText, item.version] + [item.summary].compactMap { $0 }, systemImage: "shippingbox", action: .navigate( @@ -1051,9 +1048,10 @@ enum MacToolsSearchIndexBuilder { static func pluginMetadataKeywords( pluginID: String, category: String?, - releaseChannel: String? + releaseChannel: String?, + additionalKeywords: [String] = [] ) -> [String] { - var keywords = [pluginID] + var keywords = [pluginID] + additionalKeywords if let category = nonEmptyMetadataValue(category) { keywords.append(category) diff --git a/Sources/App/PluginManagementSettingsView.swift b/Sources/App/PluginManagementSettingsView.swift index eb24b229..392b430c 100644 --- a/Sources/App/PluginManagementSettingsView.swift +++ b/Sources/App/PluginManagementSettingsView.swift @@ -164,9 +164,10 @@ struct PluginManagementSettingsView: View { .onChange(of: pluginHost.pluginManagementItems) { _, items in guard let activeSearchTarget, - !items.contains(where: { - $0.id == activeSearchTarget.pluginID && $0.canUninstall - }) + !MarketplacePluginSearchAvailability.contains( + pluginID: activeSearchTarget.pluginID, + in: items + ) else { return } @@ -205,9 +206,10 @@ struct PluginManagementSettingsView: View { return } - guard pluginHost.pluginManagementItems.contains(where: { - $0.id == target.pluginID && $0.canUninstall - }) else { + guard MarketplacePluginSearchAvailability.contains( + pluginID: target.pluginID, + in: pluginHost.pluginManagementItems + ) else { navigationCoordinator.clearSearchRevealRequest(request) return } diff --git a/Sources/App/SettingsNavigationCoordinator.swift b/Sources/App/SettingsNavigationCoordinator.swift index 390a1a21..dfe71162 100644 --- a/Sources/App/SettingsNavigationCoordinator.swift +++ b/Sources/App/SettingsNavigationCoordinator.swift @@ -142,6 +142,12 @@ struct MarketplacePluginSearchTarget: Hashable { } } +enum MarketplacePluginSearchAvailability { + static func contains(pluginID: String, in items: [PluginManagementItem]) -> Bool { + items.contains { $0.id == pluginID } + } +} + struct UnifiedSearchQuickSelectionRequest: Equatable { let id: UInt let number: Int @@ -207,9 +213,10 @@ final class SettingsNavigationCoordinator: ObservableObject { pluginHost.hasPluginSettingsSearchTarget($0) }, isPluginManagementAvailable: { pluginID in - pluginHost.pluginManagementItems.contains { - $0.id == pluginID && $0.canUninstall - } + MarketplacePluginSearchAvailability.contains( + pluginID: pluginID, + in: pluginHost.pluginManagementItems + ) }, isPluginSurfaceAvailable: { target in let items = switch target.surface { diff --git a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift index 7d8e2834..7b78d67d 100644 --- a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift +++ b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalog.swift @@ -21,7 +21,7 @@ struct PluginCatalog: Codable, Equatable { let signature: Signature? init( - schemaVersion: Int = 2, + schemaVersion: Int = 3, catalogID: String, generatedAt: Date, minimumHostVersion: String, @@ -55,6 +55,13 @@ struct PluginCatalogEntry: Codable, Identifiable, Equatable { let category: String? let releaseChannel: String? let localizedMetadata: [String: PluginLocalizedMetadata]? + let presentation: PluginProductMetadata.Presentation? + let discovery: PluginProductMetadata.Discovery? + let requirements: PluginProductMetadata.Requirements? + let privacy: PluginProductMetadata.Privacy? + let actions: PluginProductMetadata.Actions? + let setup: PluginProductMetadata.Setup? + let relationships: PluginProductMetadata.Relationships? init( id: String, @@ -69,7 +76,14 @@ struct PluginCatalogEntry: Codable, Identifiable, Equatable { releaseNotesURL: URL? = nil, category: String? = nil, releaseChannel: String? = nil, - localizedMetadata: [String: PluginLocalizedMetadata]? = nil + localizedMetadata: [String: PluginLocalizedMetadata]? = nil, + presentation: PluginProductMetadata.Presentation? = nil, + discovery: PluginProductMetadata.Discovery? = nil, + requirements: PluginProductMetadata.Requirements? = nil, + privacy: PluginProductMetadata.Privacy? = nil, + actions: PluginProductMetadata.Actions? = nil, + setup: PluginProductMetadata.Setup? = nil, + relationships: PluginProductMetadata.Relationships? = nil ) { self.id = id self.displayName = displayName @@ -84,6 +98,13 @@ struct PluginCatalogEntry: Codable, Identifiable, Equatable { self.category = category self.releaseChannel = releaseChannel self.localizedMetadata = localizedMetadata + self.presentation = presentation + self.discovery = discovery + self.requirements = requirements + self.privacy = privacy + self.actions = actions + self.setup = setup + self.relationships = relationships } var localizedDisplayName: String { diff --git a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogProvider.swift b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogProvider.swift index 6af15bef..2b76cdca 100644 --- a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogProvider.swift +++ b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogProvider.swift @@ -39,11 +39,29 @@ struct PluginCatalogProviderConfiguration { return URL(string: "https://mactools.ggbond.app/plugins/v\(pluginKitVersion)/catalog.json")! } - static let productionCatalogURL = productionCatalogURL( - for: PluginPackageManifestLoader.supportedPluginKitVersion - ) + // Schema 3 lives on its own compatibility endpoint so released PluginKit 5 + // hosts can keep reading the immutable schema-2 catalog they understand. + static let schema3ProductionCatalogURL = URL( + string: "https://mactools.ggbond.app/plugins/v5/schema3/catalog.json" + )! + + static func productionCatalogURL( + forHostVersion hostVersion: String, + pluginKitVersion: Int = PluginPackageManifestLoader.supportedPluginKitVersion + ) -> URL { + guard pluginKitVersion == 5 else { + return productionCatalogURL(for: pluginKitVersion) + } + guard PluginVersionComparator.isVersion(hostVersion, atLeast: "1.2.1") else { + return productionCatalogURL(for: pluginKitVersion) + } + return schema3ProductionCatalogURL + } - static func defaultSource(environment: [String: String] = ProcessInfo.processInfo.environment) -> PluginCatalogSource { + static func defaultSource( + environment: [String: String] = ProcessInfo.processInfo.environment, + hostVersion: String = AppMetadata.shortVersion ?? "0" + ) -> PluginCatalogSource { #if DEBUG if let rawURL = environment["MACTOOLS_PLUGIN_CATALOG_URL"], let url = URL(string: rawURL) { @@ -55,7 +73,7 @@ struct PluginCatalogProviderConfiguration { } #endif - return .production(productionCatalogURL) + return .production(productionCatalogURL(forHostVersion: hostVersion)) } private static func source(forEnvironmentCatalogURL url: URL) -> PluginCatalogSource { diff --git a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogVerifier.swift b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogVerifier.swift index 70a83510..919ab7a3 100644 --- a/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogVerifier.swift +++ b/Sources/Core/Plugins/Dynamic/Catalog/PluginCatalogVerifier.swift @@ -45,7 +45,7 @@ struct PluginCatalogVerifier { sourceKind: PluginCatalogSnapshot.SourceKind, rawData: Data? = nil ) throws { - guard catalog.schemaVersion == 2 else { + guard catalog.schemaVersion == 2 || catalog.schemaVersion == 3 else { throw PluginCatalogVerifierError.unsupportedSchemaVersion(catalog.schemaVersion) } diff --git a/Sources/Core/Plugins/Dynamic/DynamicPluginManager.swift b/Sources/Core/Plugins/Dynamic/DynamicPluginManager.swift index ba5ea3f4..3f95324c 100644 --- a/Sources/Core/Plugins/Dynamic/DynamicPluginManager.swift +++ b/Sources/Core/Plugins/Dynamic/DynamicPluginManager.swift @@ -68,6 +68,11 @@ struct PluginManagementItem: Identifiable, Equatable { let category: String? let releaseChannel: String? let capabilities: PluginPackageManifest.Capabilities? + let productMetadata: PluginProductMetadata? + + var productSearchKeywords: [String] { + productMetadata?.searchKeywords ?? [] + } init( id: String, @@ -80,7 +85,8 @@ struct PluginManagementItem: Identifiable, Equatable { releaseNotesURL: URL?, category: String? = nil, releaseChannel: String? = nil, - capabilities: PluginPackageManifest.Capabilities? = nil + capabilities: PluginPackageManifest.Capabilities? = nil, + productMetadata: PluginProductMetadata? = nil ) { self.id = id self.title = title @@ -93,6 +99,7 @@ struct PluginManagementItem: Identifiable, Equatable { self.category = category self.releaseChannel = releaseChannel self.capabilities = capabilities + self.productMetadata = productMetadata } var statusText: String { @@ -971,7 +978,16 @@ final class DynamicPluginManager: ObservableObject { releaseNotesURL: entry.releaseNotesURL, category: entry.category, releaseChannel: entry.releaseChannel, - capabilities: entry.capabilities + capabilities: entry.capabilities, + productMetadata: PluginProductMetadata( + presentation: entry.presentation, + discovery: entry.discovery, + requirements: entry.requirements, + privacy: entry.privacy, + actions: entry.actions, + setup: entry.setup, + relationships: entry.relationships + ) ) ) } @@ -1035,7 +1051,16 @@ final class DynamicPluginManager: ObservableObject { releaseNotesURL: catalogEntry?.releaseNotesURL, category: catalogEntry?.category ?? record.manifest.category, releaseChannel: catalogEntry?.releaseChannel ?? record.manifest.releaseChannel, - capabilities: catalogEntry?.capabilities ?? record.manifest.capabilities + capabilities: catalogEntry?.capabilities ?? record.manifest.capabilities, + productMetadata: PluginProductMetadata( + presentation: catalogEntry?.presentation ?? record.manifest.presentation, + discovery: catalogEntry?.discovery ?? record.manifest.discovery, + requirements: catalogEntry?.requirements ?? record.manifest.requirements, + privacy: catalogEntry?.privacy ?? record.manifest.privacy, + actions: catalogEntry?.actions ?? record.manifest.actions, + setup: catalogEntry?.setup ?? record.manifest.setup, + relationships: catalogEntry?.relationships ?? record.manifest.relationships + ) ) } diff --git a/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift b/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift index d4d8f93c..653a3bb2 100644 --- a/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift +++ b/Sources/Core/Plugins/Dynamic/PluginPackageManifest.swift @@ -79,6 +79,13 @@ struct PluginPackageManifest: Codable, Equatable { let category: String? let releaseChannel: String? let localizedMetadata: [String: PluginLocalizedMetadata]? + let presentation: PluginProductMetadata.Presentation? + let discovery: PluginProductMetadata.Discovery? + let requirements: PluginProductMetadata.Requirements? + let privacy: PluginProductMetadata.Privacy? + let actions: PluginProductMetadata.Actions? + let setup: PluginProductMetadata.Setup? + let relationships: PluginProductMetadata.Relationships? init( id: String, @@ -92,7 +99,14 @@ struct PluginPackageManifest: Codable, Equatable { permissions: [String] = [], category: String? = nil, releaseChannel: String? = nil, - localizedMetadata: [String: PluginLocalizedMetadata]? = nil + localizedMetadata: [String: PluginLocalizedMetadata]? = nil, + presentation: PluginProductMetadata.Presentation? = nil, + discovery: PluginProductMetadata.Discovery? = nil, + requirements: PluginProductMetadata.Requirements? = nil, + privacy: PluginProductMetadata.Privacy? = nil, + actions: PluginProductMetadata.Actions? = nil, + setup: PluginProductMetadata.Setup? = nil, + relationships: PluginProductMetadata.Relationships? = nil ) { self.id = id self.displayName = displayName @@ -106,6 +120,13 @@ struct PluginPackageManifest: Codable, Equatable { self.category = category self.releaseChannel = releaseChannel self.localizedMetadata = localizedMetadata + self.presentation = presentation + self.discovery = discovery + self.requirements = requirements + self.privacy = privacy + self.actions = actions + self.setup = setup + self.relationships = relationships } var localizedDisplayName: String { diff --git a/Sources/Core/Plugins/Dynamic/PluginProductMetadata.swift b/Sources/Core/Plugins/Dynamic/PluginProductMetadata.swift new file mode 100644 index 00000000..91f91abe --- /dev/null +++ b/Sources/Core/Plugins/Dynamic/PluginProductMetadata.swift @@ -0,0 +1,298 @@ +import Foundation +import MacToolsPluginKit + +struct PluginLocalizedText: Codable, Equatable { + let values: [String: String] + let sourceReference: String? + + init(_ values: [String: String]) { + self.values = values + sourceReference = nil + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let reference = try? container.decode(String.self), + reference == "@displayName" || reference == "@summary" { + values = [:] + sourceReference = reference + } else { + values = try container.decode([String: String].self) + sourceReference = nil + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if let sourceReference { + try container.encode(sourceReference) + } else { + try container.encode(values) + } + } + + func localizedValue(preferredLanguages: [String] = PluginRuntimeLocalization.preferredLanguages) -> String? { + for language in preferredLanguages { + for candidate in PluginRuntimeLocalization.candidateLanguageIdentifiers(for: language) { + if let value = values[candidate] { + return value + } + if let value = values.first(where: { + $0.key.caseInsensitiveCompare(candidate) == .orderedSame + })?.value { + return value + } + } + } + return values["en"] ?? values["zh-Hans"] ?? values.values.first + } +} + +struct PluginProductMetadata: Codable, Equatable { + struct Presentation: Codable, Equatable { + struct Example: Codable, Equatable { + let id: String + let text: PluginLocalizedText + } + + struct Asset: Codable, Equatable { + let id: String + let path: String + let mediaType: String? + let sha256: String? + let size: Int64? + let width: Int? + let height: Int? + let alt: PluginLocalizedText + } + + let longDescription: PluginLocalizedText + let examples: [Example] + let screenshots: [Asset] + let documentationURL: URL? + let supportURL: URL? + let publisher: String + let license: String + } + + struct Discovery: Codable, Equatable { + struct UseCase: Codable, Equatable { + let id: String + let title: PluginLocalizedText + } + + let keywords: [String] + let localizedSynonyms: [String: [String]] + let useCases: [UseCase] + let goalCategories: [String] + let relatedPluginIDs: [String] + let alternativePluginIDs: [String] + } + + struct Requirements: Codable, Equatable { + struct Application: Codable, Equatable { + let bundleID: String + let name: String + } + + let minimumMacOSVersion: String? + let architectures: [String] + let hardware: [String] + let applications: [Application] + let executables: [String] + let permissionIDs: [String] + let setupComplexity: String + let requiresRelaunch: Bool + } + + struct Privacy: Codable, Equatable { + struct Retention: Codable, Equatable { + let policy: String + let description: PluginLocalizedText? + } + + let dataObserved: [String] + let dataPersisted: [String] + let retention: Retention + let networkUse: String + let networkDomains: [String] + let allowsUserConfiguredDomains: Bool? + let telemetry: String + let processesSensitiveUserContent: Bool + let diagnosticExportsContainUserData: Bool + } + + struct Actions: Codable, Equatable { + struct Parameter: Codable, Equatable { + let id: String + let kind: String + let isRequired: Bool + let portability: String + } + + struct StaticAction: Codable, Equatable { + let id: String + let title: PluginLocalizedText + let description: PluginLocalizedText + let keywords: [String] + let systemImage: String + let parameters: [Parameter] + let parameterSummary: PluginLocalizedText? + let permissionIDs: [String] + let risk: String + let surfaces: [String] + let automaticEligible: Bool + let externalInvocation: String + } + + struct DynamicTemplate: Codable, Equatable { + let id: String + let title: PluginLocalizedText + let description: PluginLocalizedText + let entrySource: String + let keywords: [String] + let parameters: [Parameter] + let parameterSummary: PluginLocalizedText + let localOnlyIdentity: Bool + let riskVariesByEntry: Bool? + let automaticEligibilityVariesByEntry: Bool? + let permissionIDs: [String] + let risk: String + let surfaces: [String] + let automaticEligible: Bool + let externalInvocation: String + } + + struct Provider: Codable, Equatable { + let id: String + let kind: String + let staticActions: [StaticAction] + let dynamicTemplates: [DynamicTemplate] + } + + let providers: [Provider] + } + + struct Setup: Codable, Equatable { + struct Step: Codable, Equatable { + let id: String + let title: PluginLocalizedText + let description: PluginLocalizedText + } + + struct TestAction: Codable, Equatable { + let providerID: String + let actionID: String + } + + let steps: [Step] + let suggestedTestAction: TestAction? + let optionalSurfaces: [String] + let missingDependencyHelp: PluginLocalizedText? + } + + struct Relationships: Codable, Equatable { + let relatedPluginIDs: [String] + let includedPackIDs: [String] + let suggestedRecipeIDs: [String] + let supersedesPluginIDs: [String] + } + + let presentation: Presentation? + let discovery: Discovery? + let requirements: Requirements? + let privacy: Privacy? + let actions: Actions? + let setup: Setup? + let relationships: Relationships? + + var searchKeywords: [String] { + Self.searchKeywords( + presentation: presentation, + discovery: discovery, + requirements: requirements, + privacy: privacy, + actions: actions, + setup: setup, + relationships: relationships + ) + } + + static func searchKeywords( + presentation: Presentation?, + discovery: Discovery?, + requirements: Requirements?, + privacy: Privacy?, + actions: Actions?, + setup: Setup?, + relationships: Relationships? + ) -> [String] { + var values: [String] = [] + if let presentation { + values.append(contentsOf: [presentation.longDescription.localizedValue()].compactMap { $0 }) + values.append(contentsOf: presentation.examples.compactMap { $0.text.localizedValue() }) + } + if let discovery { + values.append(contentsOf: discovery.keywords) + values.append(contentsOf: discovery.localizedSynonyms.values.flatMap { $0 }) + values.append(contentsOf: discovery.useCases.compactMap { $0.title.localizedValue() }) + values.append(contentsOf: discovery.goalCategories) + values.append(contentsOf: discovery.relatedPluginIDs) + values.append(contentsOf: discovery.alternativePluginIDs) + } + if let requirements { + values.append(contentsOf: requirements.architectures) + values.append(contentsOf: requirements.hardware) + values.append(contentsOf: requirements.applications.flatMap { [$0.name, $0.bundleID] }) + values.append(contentsOf: requirements.executables) + values.append(contentsOf: requirements.permissionIDs) + } + if let privacy { + values.append(contentsOf: privacy.dataObserved) + values.append(contentsOf: privacy.dataPersisted) + values.append(contentsOf: privacy.networkDomains) + } + if let actions { + for provider in actions.providers { + values.append(provider.id) + for action in provider.staticActions { + values.append(action.id) + values.append(contentsOf: action.keywords) + values.append(contentsOf: [ + action.title.localizedValue(), + action.description.localizedValue(), + action.parameterSummary?.localizedValue(), + ].compactMap { $0 }) + } + for template in provider.dynamicTemplates { + values.append(contentsOf: [template.id, template.entrySource]) + values.append(contentsOf: template.keywords) + values.append(contentsOf: [ + template.title.localizedValue(), + template.description.localizedValue(), + template.parameterSummary.localizedValue(), + ].compactMap { $0 }) + } + } + } + if let setup { + values.append(contentsOf: setup.steps.flatMap { + [$0.title.localizedValue(), $0.description.localizedValue()].compactMap { $0 } + }) + values.append(contentsOf: setup.optionalSurfaces) + values.append(contentsOf: [setup.missingDependencyHelp?.localizedValue()].compactMap { $0 }) + } + if let relationships { + values.append(contentsOf: relationships.relatedPluginIDs) + values.append(contentsOf: relationships.includedPackIDs) + values.append(contentsOf: relationships.suggestedRecipeIDs) + values.append(contentsOf: relationships.supersedesPluginIDs) + } + + var seen: Set = [] + return values.filter { + let normalized = $0.trimmingCharacters(in: .whitespacesAndNewlines) + return !normalized.isEmpty && seen.insert(normalized.lowercased()).inserted + } + } +} diff --git a/Sources/Core/Plugins/PluginCategory.swift b/Sources/Core/Plugins/PluginCategory.swift index a707d879..d4a14af0 100644 --- a/Sources/Core/Plugins/PluginCategory.swift +++ b/Sources/Core/Plugins/PluginCategory.swift @@ -156,8 +156,8 @@ enum PluginListFilter { item.title, item.summary, item.id, - category.displayName - ]) + category.displayName, + ] + item.productSearchKeywords) } static func matches( diff --git a/Sources/Core/Plugins/PluginHost.swift b/Sources/Core/Plugins/PluginHost.swift index d81e2319..fe2c6486 100644 --- a/Sources/Core/Plugins/PluginHost.swift +++ b/Sources/Core/Plugins/PluginHost.swift @@ -1,3 +1,4 @@ +import AppKit import Combine import Foundation import SwiftUI @@ -5583,18 +5584,26 @@ final class PluginHost: ObservableObject { } private func requestPermissionGuidance(forPluginID pluginID: String, permissionID: String) { - guard activePlugins.contains(where: { plugin in - plugin.metadata.id == pluginID - && (guardedValue( - for: plugin, - operation: "read permission requirements", - plugin.permissionRequirements - ) ?? []).contains(where: { $0.id == permissionID }) - }) else { + guard let plugin = activePlugins.first(where: { $0.metadata.id == pluginID }), + let requirement = (guardedValue( + for: plugin, + operation: "read permission requirements", + plugin.permissionRequirements + ) ?? []).first(where: { $0.id == permissionID }) else { return } - presentPluginSettings(pluginID: pluginID) + switch requirement.kind { + case .automation: + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation" + ) else { + return + } + NSWorkspace.shared.open(url) + default: + presentPluginSettings(pluginID: pluginID) + } } private func permissionActionTitle( @@ -5616,6 +5625,8 @@ final class PluginHost: ObservableObject { : AppL10n.plugins("plugin.permission.requestAuthorization", defaultValue: "请求授权") case .automation: return AppL10n.plugins("plugin.permission.openSettings", defaultValue: "打开设置") + case .finderExtension: + return AppL10n.plugins("plugin.permission.openSettings", defaultValue: "打开设置") case .screenRecording: return isGranted ? AppL10n.plugins("plugin.permission.checkStatus", defaultValue: "检查授权状态") @@ -5633,6 +5644,8 @@ final class PluginHost: ObservableObject { return "calendar" case .automation: return "cursorarrow.click.2" + case .finderExtension: + return "puzzlepiece.extension" case .screenRecording: return "rectangle.dashed.badge.record" } diff --git a/Sources/MacToolsPluginKit/PluginModels.swift b/Sources/MacToolsPluginKit/PluginModels.swift index cd43c920..7ff9b165 100644 --- a/Sources/MacToolsPluginKit/PluginModels.swift +++ b/Sources/MacToolsPluginKit/PluginModels.swift @@ -70,6 +70,7 @@ public enum PluginPermissionKind { case calendarFullAccess case automation case screenRecording + case finderExtension } public enum SettingsDestination: Hashable { diff --git a/Tests/App/MacToolsSearchTests.swift b/Tests/App/MacToolsSearchTests.swift index e0f3bd43..885e8585 100644 --- a/Tests/App/MacToolsSearchTests.swift +++ b/Tests/App/MacToolsSearchTests.swift @@ -938,6 +938,68 @@ final class MacToolsSearchTests: XCTestCase { ) } + func testAvailablePluginIsDiscoverableByCatalogOnlyKeywords() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "MacToolsSearchTests-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let defaults = UserDefaults( + suiteName: "MacToolsSearchTests-\(UUID().uuidString)" + )! + let store = PluginPackageStore( + rootDirectory: root, + userDefaults: defaults, + hostVersion: "1.2.1" + ) + let manager = DynamicPluginManager(packageStore: store) + let entry = PluginCatalogEntry( + id: "com.example.presentation", + displayName: "Presentation Helper", + summary: "Keeps a Mac ready for presenting", + version: "1.0.0", + minimumHostVersion: "1.2.0", + package: PluginCatalogPackage( + url: URL(fileURLWithPath: "/tmp/PresentationHelper.mactoolsplugin"), + sha256: String(repeating: "a", count: 64), + size: 42 + ), + discovery: PluginProductMetadata.Discovery( + keywords: ["caffeine"], + localizedSynonyms: [:], + useCases: [], + goalCategories: [], + relatedPluginIDs: [], + alternativePluginIDs: [] + ) + ) + manager.rebuildManagementItems( + catalogSnapshot: PluginCatalogSnapshot( + catalog: PluginCatalog( + catalogID: "com.example.catalog", + generatedAt: Date(timeIntervalSince1970: 0), + minimumHostVersion: "1.2.1", + plugins: [entry] + ), + sourceURL: URL(fileURLWithPath: "/tmp/catalog.json"), + sourceKind: .production, + loadedAt: Date(timeIntervalSince1970: 0) + ) + ) + let host = makePluginHostForTests( + plugins: [], + dynamicPluginManager: manager, + loadDynamicPluginsOnInit: false + ) + + let results = MacToolsSearchIndexBuilder.build(pluginHost: host) + .results(matching: "caffeine") + + XCTAssertEqual(results.map(\.id), ["plugin.marketplace.com.example.presentation"]) + XCTAssertEqual(manager.pluginManagementItems.first?.state, .available) + } + func testInstalledIncompatiblePluginIsDiscoverableInMarketplace() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent( diff --git a/Tests/App/SettingsNavigationCoordinatorTests.swift b/Tests/App/SettingsNavigationCoordinatorTests.swift index 1837f20b..1b831d25 100644 --- a/Tests/App/SettingsNavigationCoordinatorTests.swift +++ b/Tests/App/SettingsNavigationCoordinatorTests.swift @@ -446,6 +446,26 @@ final class SettingsNavigationCoordinatorTests: XCTestCase { ) } + func testAvailableManagementItemCanBeRevealedFromMarketplaceSearch() { + let item = PluginManagementItem( + id: "available-plugin", + title: "Available Plugin", + summary: nil, + version: "1.0.0", + state: .available, + packageURL: nil, + requiresRestartToFullyUnload: false, + releaseNotesURL: nil + ) + + XCTAssertTrue( + MarketplacePluginSearchAvailability.contains( + pluginID: item.id, + in: [item] + ) + ) + } + func testSearchNavigationDismissesPaletteAndPublishesExactRevealTarget() throws { let coordinator = SettingsNavigationCoordinator( isPluginConfigurationAvailable: { $0 == "keep-awake" } diff --git a/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift b/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift index 90b47d60..d01084a5 100644 --- a/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift +++ b/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift @@ -25,13 +25,37 @@ final class PluginCatalogTests: XCTestCase { ) } - func testCurrentPluginKitUsesVersion5CatalogURL() throws { + func testReleasedPluginKit5CatalogKeepsSchema2URL() throws { XCTAssertEqual( - PluginCatalogProviderConfiguration.productionCatalogURL, + PluginCatalogProviderConfiguration.productionCatalogURL(for: 5), URL(string: "https://mactools.ggbond.app/plugins/v5/catalog.json") ) } + func testReleasedVersionKeepsSchema2CompatibilityCatalogURL() throws { + XCTAssertEqual( + PluginCatalogProviderConfiguration.productionCatalogURL(forHostVersion: "1.2.0"), + URL(string: "https://mactools.ggbond.app/plugins/v5/catalog.json") + ) + } + + func testSchema3HostUsesSchema3CompatibilityCatalogURL() throws { + XCTAssertEqual( + PluginCatalogProviderConfiguration.productionCatalogURL(forHostVersion: "1.2.1"), + URL(string: "https://mactools.ggbond.app/plugins/v5/schema3/catalog.json") + ) + } + + func testFuturePluginKitUsesItsOwnVersionedCatalogURL() throws { + XCTAssertEqual( + PluginCatalogProviderConfiguration.productionCatalogURL( + forHostVersion: "1.3.0", + pluginKitVersion: 6 + ), + URL(string: "https://mactools.ggbond.app/plugins/v6/catalog.json") + ) + } + func testCurrentVerifierRejectsSchemaVersion1() throws { let catalog = makeCatalog(schemaVersion: 1) let verifier = PluginCatalogVerifier.localDevelopment(hostVersion: "1.0.0") @@ -81,6 +105,25 @@ final class PluginCatalogTests: XCTestCase { ) } + func testLocalDevelopmentAcceptsSchema3EnrichedCatalog() throws { + let manifest = try appearanceManifest() + let catalog = makeCatalog( + schemaVersion: 3, + plugins: [makeEntry(from: manifest)] + ) + let verifier = PluginCatalogVerifier.localDevelopment(hostVersion: "1.2.0") + + XCTAssertNoThrow( + try verifier.verify(catalog, sourceKind: .localDevelopment) + ) + XCTAssertEqual( + catalog.plugins.first?.presentation?.longDescription.localizedValue( + preferredLanguages: ["en-US"] + ), + "Switch macOS between light and dark appearance from any MacTools action surface." + ) + } + func testProductionCatalogRequiresSignature() throws { let catalog = makeCatalog() let verifier = PluginCatalogVerifier.production(hostVersion: "1.0.0", publicKey: nil) @@ -163,6 +206,54 @@ final class PluginCatalogTests: XCTestCase { ) } + func testEnrichedMetadataIsCoveredByCatalogSignature() throws { + let privateKey = Curve25519.Signing.PrivateKey() + let manifest = try appearanceManifest() + let entry = makeEntry(from: manifest) + let unsignedCatalog = makeCatalog(schemaVersion: 3, plugins: [entry]) + let unsignedData = try PluginCatalogCoding.encoder.encode(unsignedCatalog) + let payload = try PluginCatalogSigning.signedPayload(fromCatalogData: unsignedData) + let signature = try privateKey.signature(for: payload).base64EncodedString() + let signedCatalog = makeCatalog( + schemaVersion: 3, + plugins: [entry], + signature: PluginCatalog.Signature(algorithm: "ed25519", value: signature) + ) + let signedData = try PluginCatalogCoding.encoder.encode(signedCatalog) + let verifier = PluginCatalogVerifier.production( + hostVersion: "1.2.0", + publicKey: privateKey.publicKey + ) + + XCTAssertNoThrow( + try verifier.verify(signedCatalog, sourceKind: .production, rawData: signedData) + ) + + let presentation = try XCTUnwrap(manifest.presentation) + let tamperedPresentation = PluginProductMetadata.Presentation( + longDescription: presentation.longDescription, + examples: presentation.examples, + screenshots: presentation.screenshots, + documentationURL: presentation.documentationURL, + supportURL: presentation.supportURL, + publisher: "Tampered Publisher", + license: presentation.license + ) + let tamperedEntry = makeEntry(from: manifest, presentation: tamperedPresentation) + let tamperedCatalog = makeCatalog( + schemaVersion: 3, + plugins: [tamperedEntry], + signature: PluginCatalog.Signature(algorithm: "ed25519", value: signature) + ) + let tamperedData = try PluginCatalogCoding.encoder.encode(tamperedCatalog) + + XCTAssertThrowsError( + try verifier.verify(tamperedCatalog, sourceKind: .production, rawData: tamperedData) + ) { error in + XCTAssertEqual(error as? PluginCatalogVerifierError, .invalidSignature) + } + } + private func makeCatalog( schemaVersion: Int = 2, minimumHostVersion: String = "0.1.0", @@ -200,4 +291,41 @@ final class PluginCatalogTests: XCTestCase { ) ) } + + private func makeEntry( + from manifest: PluginPackageManifest, + presentation: PluginProductMetadata.Presentation? = nil + ) -> PluginCatalogEntry { + PluginCatalogEntry( + id: manifest.id, + displayName: manifest.displayName, + summary: manifest.localizedMetadata?["en"]?.summary ?? manifest.displayName, + version: manifest.version, + minimumHostVersion: manifest.minHostVersion, + pluginKitVersion: manifest.pluginKitVersion, + capabilities: manifest.capabilities, + permissions: manifest.permissions, + package: PluginCatalogPackage( + url: URL(fileURLWithPath: "/tmp/\(manifest.id).mactoolsplugin"), + sha256: String(repeating: "a", count: 64), + size: 42 + ), + category: manifest.category, + localizedMetadata: manifest.localizedMetadata, + presentation: presentation ?? manifest.presentation, + discovery: manifest.discovery, + requirements: manifest.requirements, + privacy: manifest.privacy, + actions: manifest.actions, + setup: manifest.setup, + relationships: manifest.relationships + ) + } + + private func appearanceManifest() throws -> PluginPackageManifest { + return try JSONDecoder().decode( + PluginPackageManifest.self, + from: PluginSourceManifestTestProjection.data(pluginDirectoryName: "Appearance") + ) + } } diff --git a/Tests/Core/Plugins/Dynamic/PluginManifestActionAssertions.swift b/Tests/Core/Plugins/Dynamic/PluginManifestActionAssertions.swift new file mode 100644 index 00000000..d757b810 --- /dev/null +++ b/Tests/Core/Plugins/Dynamic/PluginManifestActionAssertions.swift @@ -0,0 +1,118 @@ +import Foundation +import XCTest +import MacToolsPluginKit + +enum PluginManifestActionAssertions { + static func dynamicTemplate( + pluginDirectoryName: String, + id: String + ) throws -> [String: Any] { + let manifest = try sourceManifest(pluginDirectoryName: pluginDirectoryName) + let actions = try XCTUnwrap(manifest["actions"] as? [String: Any]) + let providers = try XCTUnwrap(actions["providers"] as? [[String: Any]]) + let templates = providers.flatMap { + $0["dynamicTemplates"] as? [[String: Any]] ?? [] + } + return try XCTUnwrap(templates.first { $0["id"] as? String == id }) + } + + @MainActor + static func assertConsistency( + pluginDirectoryName: String, + definitions: [ActionDefinition], + permissionIDs: (ActionKey) -> [String], + file: StaticString = #filePath, + line: UInt = #line + ) throws { + let manifest = try sourceManifest(pluginDirectoryName: pluginDirectoryName) + let actions = try XCTUnwrap(manifest["actions"] as? [String: Any], file: file, line: line) + let providers = try XCTUnwrap(actions["providers"] as? [[String: Any]], file: file, line: line) + let descriptors: [(providerID: String, kind: String, value: [String: Any])] = providers.flatMap { provider in + let providerID = provider["id"] as? String ?? "" + let staticActions = provider["staticActions"] as? [[String: Any]] ?? [] + let dynamicTemplates = provider["dynamicTemplates"] as? [[String: Any]] ?? [] + return staticActions.map { (providerID, "static", $0) } + + dynamicTemplates.map { (providerID, "dynamic", $0) } + } + + XCTAssertEqual( + Set(descriptors.map { "\($0.providerID)/\($0.value["id"] as? String ?? "")" }), + Set(definitions.map(\.key.id)), + "Manifest actions must exactly match runtime action identities.", + file: file, + line: line + ) + + for definition in definitions { + let descriptor = try XCTUnwrap( + descriptors.first { + $0.providerID == definition.key.providerID + && $0.value["id"] as? String == definition.key.actionID + }, + "Missing manifest descriptor for \(definition.key.id)", + file: file, + line: line + ) + XCTAssertEqual(descriptor.value["risk"] as? String, definition.risk.rawValue, file: file, line: line) + XCTAssertEqual( + descriptor.value["externalInvocation"] as? String, + definition.externalInvocationPolicy.rawValue, + file: file, + line: line + ) + XCTAssertEqual( + descriptor.value["automaticEligible"] as? Bool, + definition.capabilities.contains(.automatic), + file: file, + line: line + ) + XCTAssertEqual( + descriptor.value["permissionIDs"] as? [String] ?? [], + permissionIDs(definition.key), + file: file, + line: line + ) + if descriptor.kind == "static" { + XCTAssertEqual( + descriptor.value["systemImage"] as? String, + definition.systemImage, + file: file, + line: line + ) + } + let manifestParameters = descriptor.value["parameters"] as? [[String: Any]] ?? [] + XCTAssertEqual( + manifestParameters.compactMap { $0["id"] as? String }, + definition.parameters.map(\.id), + file: file, + line: line + ) + for parameter in definition.parameters { + let value = try XCTUnwrap( + manifestParameters.first { $0["id"] as? String == parameter.id }, + file: file, + line: line + ) + XCTAssertEqual(value["kind"] as? String, parameter.kind.rawValue, file: file, line: line) + XCTAssertEqual(value["isRequired"] as? Bool, parameter.isRequired, file: file, line: line) + XCTAssertEqual(value["portability"] as? String, parameter.portability.rawValue, file: file, line: line) + } + } + } + + private static func sourceManifest(pluginDirectoryName: String) throws -> [String: Any] { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repositoryRoot + .appendingPathComponent("Plugins", isDirectory: true) + .appendingPathComponent(pluginDirectoryName, isDirectory: true) + .appendingPathComponent("plugin.json") + return try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any] + ) + } +} diff --git a/Tests/Core/Plugins/Dynamic/PluginPackageManifestTests.swift b/Tests/Core/Plugins/Dynamic/PluginPackageManifestTests.swift index 8aecced0..2bd1ff75 100644 --- a/Tests/Core/Plugins/Dynamic/PluginPackageManifestTests.swift +++ b/Tests/Core/Plugins/Dynamic/PluginPackageManifestTests.swift @@ -119,7 +119,9 @@ final class PluginPackageManifestTests: XCTestCase { let manifestURL = repositoryRoot.appendingPathComponent(relativePath) let manifest = try JSONDecoder().decode( PluginPackageManifest.self, - from: Data(contentsOf: manifestURL) + from: PluginSourceManifestTestProjection.data( + pluginDirectoryName: manifestURL.deletingLastPathComponent().lastPathComponent + ) ) XCTAssertEqual(manifest.minHostVersion, expectation.minimum) @@ -179,7 +181,9 @@ final class PluginPackageManifestTests: XCTestCase { guard FileManager.default.fileExists(atPath: manifestURL.path) else { continue } let manifest = try JSONDecoder().decode( PluginPackageManifest.self, - from: Data(contentsOf: manifestURL) + from: PluginSourceManifestTestProjection.data( + pluginDirectoryName: pluginURL.lastPathComponent + ) ) XCTAssertNoThrow( @@ -242,6 +246,55 @@ final class PluginPackageManifestTests: XCTestCase { XCTAssertNil(manifest.releaseChannel) } + func testRichProjectedManifestDecodesProductMetadata() throws { + let manifest = try JSONDecoder().decode( + PluginPackageManifest.self, + from: PluginSourceManifestTestProjection.data(pluginDirectoryName: "Appearance") + ) + + XCTAssertEqual(manifest.presentation?.publisher, "MacTools") + XCTAssertEqual( + manifest.presentation?.longDescription.localizedValue(preferredLanguages: ["en-US"]), + "Switch macOS between light and dark appearance from any MacTools action surface." + ) + XCTAssertEqual(manifest.actions?.providers.first?.kind, "static") + XCTAssertEqual( + manifest.actions?.providers.first?.staticActions.map(\.id), + ["toggle", "set-enabled"] + ) + XCTAssertEqual(manifest.requirements?.architectures, ["arm64", "x86_64"]) + XCTAssertEqual(manifest.privacy?.networkUse, "none") + let searchKeywords = PluginProductMetadata.searchKeywords( + presentation: manifest.presentation, + discovery: manifest.discovery, + requirements: manifest.requirements, + privacy: manifest.privacy, + actions: manifest.actions, + setup: manifest.setup, + relationships: manifest.relationships + ) + XCTAssertTrue(searchKeywords.contains("Toggle Appearance")) + XCTAssertTrue(searchKeywords.contains("night-shift")) + } + + func testUnknownOptionalProductFieldDoesNotBreakRuntimeDecoding() throws { + let json = """ + { + "id": "demo", + "displayName": "Demo", + "version": "1.0.0", + "minHostVersion": "1.0.0", + "pluginKitVersion": 4, + "bundleRelativePath": "Demo.bundle", + "capabilities": {"primaryPanel": false, "componentPanel": false, "settings": "none"}, + "permissions": [], + "futureProductSection": {"newField": true} + } + """.data(using: .utf8)! + + XCTAssertNoThrow(try JSONDecoder().decode(PluginPackageManifest.self, from: json)) + } + func testLocalizedMetadataMatchesPreferredLanguageAndFallbacks() { let metadata = [ "en": PluginLocalizedMetadata(displayName: "Calendar", summary: "Events"), @@ -272,3 +325,57 @@ final class PluginPackageManifestTests: XCTestCase { ) } } + +enum PluginSourceManifestTestProjection { + static func data(pluginDirectoryName: String) throws -> Data { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let sourceURL = repositoryRoot + .appendingPathComponent("Plugins", isDirectory: true) + .appendingPathComponent(pluginDirectoryName, isDirectory: true) + .appendingPathComponent("plugin.json") + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("mactools-manifest-projection-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let destinationURL = temporaryDirectory.appendingPathComponent("plugin.json") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + process.currentDirectoryURL = repositoryRoot + process.arguments = [ + repositoryRoot.appendingPathComponent("scripts/plugins/copy-plugin-manifest.py").path, + "copy", + "--source", sourceURL.path, + "--destination", destinationURL.path, + "--configuration", "Release", + "--app-version-config", + repositoryRoot.appendingPathComponent("Configs/AppVersion.xcconfig").path, + ] + let errorPipe = Pipe() + process.standardError = errorPipe + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let message = String(data: errorData, encoding: .utf8) ?? "Unknown projection error" + throw projectionError(message.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return try Data(contentsOf: destinationURL) + } + + private static func projectionError(_ message: String) -> NSError { + NSError( + domain: "PluginSourceManifestTestProjection", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } +} diff --git a/Tests/Core/Plugins/Dynamic/PluginPackageResolverTests.swift b/Tests/Core/Plugins/Dynamic/PluginPackageResolverTests.swift index a707b7be..c396e696 100644 --- a/Tests/Core/Plugins/Dynamic/PluginPackageResolverTests.swift +++ b/Tests/Core/Plugins/Dynamic/PluginPackageResolverTests.swift @@ -162,6 +162,36 @@ final class PluginPackageResolverTests: XCTestCase { XCTAssertEqual(metrics, expected) } + func testDirectoryPackageMetricsSkipSymbolicLinks() throws { + let packageURL = try makePackage(id: "com.example.demo", version: "1.0.0") + let payloadURL = packageURL.appendingPathComponent("payload", isDirectory: false) + let linkURL = packageURL.appendingPathComponent("linked-payload", isDirectory: false) + try Data("included".utf8).write(to: payloadURL) + try FileManager.default.createSymbolicLink(at: linkURL, withDestinationURL: payloadURL) + + let metrics = try PluginPackageResolver.packageMetrics(for: packageURL) + let expected = stableDirectoryMetrics( + root: packageURL, + files: ["payload", "plugin.json"] + ) + + XCTAssertEqual(metrics, expected) + } + + func testDirectoryPackageMetricsSkipFinderHiddenFiles() throws { + let packageURL = try makePackage(id: "com.example.demo", version: "1.0.0") + var payloadURL = packageURL.appendingPathComponent("payload", isDirectory: false) + try Data("hidden".utf8).write(to: payloadURL) + var resourceValues = URLResourceValues() + resourceValues.isHidden = true + try payloadURL.setResourceValues(resourceValues) + + let metrics = try PluginPackageResolver.packageMetrics(for: packageURL) + let expected = stableDirectoryMetrics(root: packageURL, files: ["plugin.json"]) + + XCTAssertEqual(metrics, expected) + } + private func makePackage(id: String, version: String) throws -> URL { let packageURL = temporaryRoot .appendingPathComponent("\(id)-\(version)", isDirectory: true) diff --git a/Tests/Core/Plugins/Dynamic/PluginRuntimeActionSnapshotTests.swift b/Tests/Core/Plugins/Dynamic/PluginRuntimeActionSnapshotTests.swift new file mode 100644 index 00000000..461c10af --- /dev/null +++ b/Tests/Core/Plugins/Dynamic/PluginRuntimeActionSnapshotTests.swift @@ -0,0 +1,360 @@ +import Foundation +import XCTest +@testable import MacTools +import MacToolsPluginKit +import ActionGridPlugin +import ActivityBarPlugin +import AppHotkeyPlugin +import AppVolumePlugin +import AppearancePlugin +import AppleShortcutsPlugin +import AutoHideDockPlugin +import AutoHideMenuBarPlugin +import AutoInputPlugin +import BatteryChargeLimitPlugin +import ClipboardClearPlugin +import CloudflareR2Plugin +import DiskCleanPlugin +import DisplayBrightnessPlugin +import DisplayResolutionPlugin +import DisplaySleepPlugin +import DisplayTrueColorPlugin +import DockLockPlugin +import EjectDiskPlugin +import EmptyTrashPlugin +import FanControlPlugin +import FixDamagedAppPlugin +import HideNotchPlugin +import HomebrewPlugin +import IPOverviewPlugin +import KeepAwakePlugin +import LaunchControlPlugin +import LaunchpadPlugin +import LockScreenPlugin +import MicrophoneMutePlugin +import MiddleClickPlugin +import NightShiftPlugin +import PhysicalCleanModePlugin +import QuitAppsPlugin +import SavedScriptsPlugin +import SidecarPlugin +import StageManagerPlugin +import SystemMutePlugin +import SystemSoftRestartPlugin +import TranslatorPlugin +import WindowLayoutsPlugin +import WindowSwitcherPlugin +import XcodeCleanPlugin + +@MainActor +final class PluginRuntimeActionSnapshotTests: XCTestCase { + func testEveryRuntimeActionProviderMatchesManifestDescriptors() throws { + for registration in Self.registrations { + let context = PluginRuntimeContext( + pluginID: registration.pluginID, + storage: SnapshotPluginStorage() + ) + let provider = try registration.makeProvider(context) + let plugins = provider.makePlugins() + XCTAssertEqual(plugins.count, 1, registration.pluginID) + let definitions = plugins + .compactMap { $0 as? any PluginActionProviding } + .flatMap(\.actionDefinitions) + try assertManifestConsistency( + pluginID: registration.pluginID, + plugin: try XCTUnwrap(plugins.first), + definitions: definitions + ) + } + } + + private func assertManifestConsistency( + pluginID: String, + plugin: any MacToolsPlugin, + definitions: [ActionDefinition] + ) throws { + let manifest = try sourceManifest(pluginID: pluginID) + let actions = try XCTUnwrap(manifest["actions"] as? [String: Any], pluginID) + let providers = try XCTUnwrap(actions["providers"] as? [[String: Any]], pluginID) + let provider = try XCTUnwrap( + providers.first { $0["id"] as? String == pluginID }, + pluginID + ) + let staticActions = provider["staticActions"] as? [[String: Any]] ?? [] + let dynamicTemplates = provider["dynamicTemplates"] as? [[String: Any]] ?? [] + let expectedKind: String + if staticActions.isEmpty { + expectedKind = "dynamic" + } else if dynamicTemplates.isEmpty { + expectedKind = "static" + } else { + expectedKind = "mixed" + } + XCTAssertEqual(provider["kind"] as? String, expectedKind, pluginID) + + let runtimeByID = Dictionary( + uniqueKeysWithValues: definitions.map { ($0.key.actionID, $0) } + ) + XCTAssertEqual( + Set(staticActions.compactMap { $0["id"] as? String }), + Set(runtimeByID.keys.filter { actionID in + dynamicTemplateID(pluginID: pluginID, actionID: actionID) == nil + }), + pluginID + ) + + for definition in definitions { + let actionID = definition.key.actionID + let descriptor: [String: Any] + let isStatic: Bool + if let value = staticActions.first(where: { $0["id"] as? String == actionID }) { + descriptor = value + isStatic = true + } else { + let templateID = try XCTUnwrap( + dynamicTemplateID(pluginID: pluginID, actionID: actionID), + "Missing dynamic classification for \(pluginID)/\(actionID)" + ) + descriptor = try XCTUnwrap( + dynamicTemplates.first { $0["id"] as? String == templateID }, + "Missing dynamic template for \(pluginID)/\(actionID)" + ) + isStatic = false + } + let riskVariesByEntry = descriptor["riskVariesByEntry"] as? Bool == true + let automaticEligibilityVariesByEntry = + descriptor["automaticEligibilityVariesByEntry"] as? Bool == true + if !riskVariesByEntry { + XCTAssertEqual(descriptor["risk"] as? String, definition.risk.rawValue, definition.key.id) + } + if descriptor["externalInvocation"] as? String == "configurable" { + XCTAssertTrue( + definition.externalInvocationPolicy == .allowed + || definition.externalInvocationPolicy == .confirmAlways + || definition.externalInvocationPolicy == .unavailable, + definition.key.id + ) + } else { + XCTAssertEqual( + descriptor["externalInvocation"] as? String, + definition.externalInvocationPolicy.rawValue, + definition.key.id + ) + } + if !automaticEligibilityVariesByEntry { + XCTAssertEqual( + descriptor["automaticEligible"] as? Bool, + definition.capabilities.contains(.automatic), + definition.key.id + ) + } + let surfaces = Set(descriptor["surfaces"] as? [String] ?? []) + let supportsUnattendedExecution = definition.risk == .safe + && definition.capabilities.contains(.automatic) + && definition.capabilities.contains(.background) + let canBeSafe = descriptor["risk"] as? String == "safe" || riskVariesByEntry + let canBeAutomatic = descriptor["automaticEligible"] as? Bool == true + || automaticEligibilityVariesByEntry + if !canBeSafe || !canBeAutomatic { + XCTAssertFalse(surfaces.contains("automatic-rule"), definition.key.id) + } else if riskVariesByEntry || automaticEligibilityVariesByEntry { + if supportsUnattendedExecution { + XCTAssertTrue(surfaces.contains("automatic-rule"), definition.key.id) + } + } else { + XCTAssertEqual( + surfaces.contains("automatic-rule"), + supportsUnattendedExecution, + definition.key.id + ) + } + let hasOnlyPortableParameters = definition.parameters.allSatisfy { + $0.portability == .portable + } + let hasLocalOnlyIdentity = !isStatic + && (descriptor["localOnlyIdentity"] as? Bool) == true + let exposurePolicy = (plugin as? any PluginActionExposureProviding)? + .exposurePolicy( + for: ActionReference(key: definition.key), + on: .appIntents + ) ?? .automatic + let supportsAppIntent = supportsUnattendedExecution + && hasOnlyPortableParameters + && !hasLocalOnlyIdentity + && exposurePolicy != .excluded + if !canBeSafe || !canBeAutomatic || !hasOnlyPortableParameters || hasLocalOnlyIdentity { + XCTAssertFalse(surfaces.contains("app-intent"), definition.key.id) + } else if riskVariesByEntry || automaticEligibilityVariesByEntry { + if supportsAppIntent { + XCTAssertTrue(surfaces.contains("app-intent"), definition.key.id) + } + } else { + XCTAssertEqual( + surfaces.contains("app-intent"), + supportsAppIntent, + definition.key.id + ) + } + let manifestPermissionIDs = Set(descriptor["permissionIDs"] as? [String] ?? []) + if !manifestPermissionIDs.isEmpty { + guard let permissionProvider = plugin as? any PluginActionPermissionProviding else { + XCTFail( + "\(definition.key.id) declares action permissions without a runtime provider" + ) + continue + } + XCTAssertEqual( + manifestPermissionIDs, + Set(permissionProvider.permissionRequirementIDs(for: definition.key)), + definition.key.id + ) + } else if let permissionProvider = plugin as? any PluginActionPermissionProviding { + XCTAssertEqual( + manifestPermissionIDs, + Set(permissionProvider.permissionRequirementIDs(for: definition.key)), + definition.key.id + ) + } + if isStatic { + XCTAssertEqual( + descriptor["systemImage"] as? String, + definition.systemImage, + definition.key.id + ) + } + let manifestParameters = descriptor["parameters"] as? [[String: Any]] ?? [] + XCTAssertEqual( + manifestParameters.compactMap { $0["id"] as? String }, + definition.parameters.map(\.id), + definition.key.id + ) + for parameter in definition.parameters { + let value = try XCTUnwrap( + manifestParameters.first { $0["id"] as? String == parameter.id }, + definition.key.id + ) + XCTAssertEqual(value["kind"] as? String, parameter.kind.rawValue, definition.key.id) + XCTAssertEqual(value["isRequired"] as? Bool, parameter.isRequired, definition.key.id) + XCTAssertEqual( + value["portability"] as? String, + parameter.portability.rawValue, + definition.key.id + ) + } + } + } + + private func dynamicTemplateID(pluginID: String, actionID: String) -> String? { + if Self.dynamicActionIDs[pluginID]?.contains(actionID) == true { + return actionID + } + if pluginID == "sidecar", actionID.hasPrefix("device.") { + return "device" + } + if pluginID == "window-layouts", actionID.hasPrefix("custom.") { + return "custom-command" + } + return nil + } + + private func sourceManifest(pluginID: String) throws -> [String: Any] { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let paths = try FileManager.default.contentsOfDirectory( + at: repositoryRoot.appendingPathComponent("Plugins", isDirectory: true), + includingPropertiesForKeys: nil + ) + for path in paths { + let manifestURL = path.appendingPathComponent("plugin.json") + guard let data = try? Data(contentsOf: manifestURL), + let object = try? JSONSerialization.jsonObject(with: data), + let manifest = object as? [String: Any], + manifest["id"] as? String == pluginID else { continue } + return manifest + } + throw XCTSkip("Missing source manifest for \(pluginID)") + } + + private struct Registration { + let pluginID: String + let makeProvider: (PluginRuntimeContext) throws -> any PluginProvider + } + + private static let registrations: [Registration] = [ + .init(pluginID: "action-grid", makeProvider: ActionGridPluginFactory.makeProvider), + .init(pluginID: "activity-bar", makeProvider: ActivityBarPluginFactory.makeProvider), + .init(pluginID: "app-hotkey", makeProvider: AppHotkeyPluginFactory.makeProvider), + .init(pluginID: "app-volume", makeProvider: AppVolumePluginFactory.makeProvider), + .init(pluginID: "appearance", makeProvider: AppearancePluginFactory.makeProvider), + .init(pluginID: "apple-shortcuts", makeProvider: AppleShortcutsPluginFactory.makeProvider), + .init(pluginID: "auto-hide-dock", makeProvider: AutoHideDockPluginFactory.makeProvider), + .init(pluginID: "auto-hide-menu-bar", makeProvider: AutoHideMenuBarPluginFactory.makeProvider), + .init(pluginID: "auto-input", makeProvider: AutoInputPluginFactory.makeProvider), + .init(pluginID: "battery-charge-limit", makeProvider: BatteryChargeLimitPluginFactory.makeProvider), + .init(pluginID: "clipboard-clear", makeProvider: ClipboardClearPluginFactory.makeProvider), + .init(pluginID: "cloudflare-r2", makeProvider: CloudflareR2PluginFactory.makeProvider), + .init(pluginID: "disk-clean", makeProvider: DiskCleanPluginFactory.makeProvider), + .init(pluginID: "display-brightness", makeProvider: DisplayBrightnessPluginFactory.makeProvider), + .init(pluginID: "display-resolution", makeProvider: DisplayResolutionPluginFactory.makeProvider), + .init(pluginID: "display-sleep", makeProvider: DisplaySleepPluginFactory.makeProvider), + .init(pluginID: "display-true-color", makeProvider: DisplayTrueColorPluginFactory.makeProvider), + .init(pluginID: "dock-lock", makeProvider: DockLockPluginFactory.makeProvider), + .init(pluginID: "eject-disk", makeProvider: EjectDiskPluginFactory.makeProvider), + .init(pluginID: "empty-trash", makeProvider: EmptyTrashPluginFactory.makeProvider), + .init(pluginID: "fan-control", makeProvider: FanControlPluginFactory.makeProvider), + .init(pluginID: "fix-damaged-app", makeProvider: FixDamagedAppPluginFactory.makeProvider), + .init(pluginID: "hide-notch", makeProvider: HideNotchPluginFactory.makeProvider), + .init(pluginID: "homebrew", makeProvider: HomebrewPluginFactory.makeProvider), + .init(pluginID: "ip-overview", makeProvider: IPOverviewPluginFactory.makeProvider), + .init(pluginID: "keep-awake", makeProvider: KeepAwakePluginFactory.makeProvider), + .init(pluginID: "launch-control", makeProvider: LaunchControlPluginFactory.makeProvider), + .init(pluginID: "launchpad", makeProvider: LaunchpadPluginFactory.makeProvider), + .init(pluginID: "lock-screen", makeProvider: LockScreenPluginFactory.makeProvider), + .init(pluginID: "microphone-mute", makeProvider: MicrophoneMutePluginFactory.makeProvider), + .init(pluginID: "middle-click", makeProvider: MiddleClickPluginFactory.makeProvider), + .init(pluginID: "night-shift", makeProvider: NightShiftPluginFactory.makeProvider), + .init(pluginID: "physical-clean-mode", makeProvider: PhysicalCleanModePluginFactory.makeProvider), + .init(pluginID: "quit-apps", makeProvider: QuitAppsPluginFactory.makeProvider), + .init(pluginID: "saved-scripts", makeProvider: SavedScriptsPluginFactory.makeProvider), + .init(pluginID: "sidecar", makeProvider: SidecarPluginFactory.makeProvider), + .init(pluginID: "stage-manager", makeProvider: StageManagerPluginFactory.makeProvider), + .init(pluginID: "system-mute", makeProvider: SystemMutePluginFactory.makeProvider), + .init(pluginID: "system-soft-restart", makeProvider: SystemSoftRestartPluginFactory.makeProvider), + .init(pluginID: "translator", makeProvider: TranslatorPluginFactory.makeProvider), + .init(pluginID: "window-layouts", makeProvider: WindowLayoutsPluginFactory.makeProvider), + .init(pluginID: "window-switcher", makeProvider: WindowSwitcherPluginFactory.makeProvider), + .init(pluginID: "xcode-clean", makeProvider: XcodeCleanPluginFactory.makeProvider), + ] + + private static let dynamicActionIDs: [String: Set] = [ + "app-hotkey": ["launch"], + "app-volume": ["set-volume"], + "auto-input": ["select-input-source"], + "battery-charge-limit": ["set-limit"], + "display-resolution": ["set-resolution"], + "fan-control": ["apply-preset"], + "launch-control": ["start-favorite", "stop-favorite", "restart-favorite"], + ] +} + +@MainActor +private final class SnapshotPluginStorage: PluginStorage { + private var values: [String: Any] = [:] + + func object(forKey key: String) -> Any? { values[key] } + func data(forKey key: String) -> Data? { values[key] as? Data } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } + func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func removeObject(forKey key: String) { values.removeValue(forKey: key) } + func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) { + guard values[key] == nil, let value = values.removeValue(forKey: legacyKey) else { return } + values[key] = value + } +} diff --git a/Tests/Core/Plugins/PluginCategoryTests.swift b/Tests/Core/Plugins/PluginCategoryTests.swift index 235500cd..6155cc19 100644 --- a/Tests/Core/Plugins/PluginCategoryTests.swift +++ b/Tests/Core/Plugins/PluginCategoryTests.swift @@ -63,6 +63,24 @@ final class PluginListFilterTests: XCTestCase { XCTAssertFalse(PluginListFilter.matches(managementItem: item, query: "", filter: .category(.display))) } + func testManagementItemMatchesEnrichedProductKeywords() { + let item = makeManagementItem( + id: "keep-awake", + title: "阻止休眠", + summary: "阻止系统空闲休眠", + category: "system", + productSearchKeywords: ["presentation mode", "caffeine"] + ) + + XCTAssertTrue( + PluginListFilter.matches( + managementItem: item, + query: "caffeine", + filter: .all + ) + ) + } + func testCountsByFilterAggregatesCorrectly() { let items = [ makeManagementItem(id: "a", title: "深色模式", summary: "切换", category: "display"), @@ -98,9 +116,26 @@ final class PluginListFilterTests: XCTestCase { id: String, title: String, summary: String?, - category: String? + category: String?, + productSearchKeywords: [String] = [] ) -> PluginManagementItem { - PluginManagementItem( + let productMetadata = productSearchKeywords.isEmpty ? nil : PluginProductMetadata( + presentation: nil, + discovery: PluginProductMetadata.Discovery( + keywords: productSearchKeywords, + localizedSynonyms: [:], + useCases: [], + goalCategories: [], + relatedPluginIDs: [], + alternativePluginIDs: [] + ), + requirements: nil, + privacy: nil, + actions: nil, + setup: nil, + relationships: nil + ) + return PluginManagementItem( id: id, title: title, summary: summary, @@ -109,7 +144,8 @@ final class PluginListFilterTests: XCTestCase { packageURL: nil, requiresRestartToFullyUnload: false, releaseNotesURL: nil, - category: category + category: category, + productMetadata: productMetadata ) } } diff --git a/changes/unreleased/automation-permission-guidance.md b/changes/unreleased/automation-permission-guidance.md new file mode 100644 index 00000000..fca221ed --- /dev/null +++ b/changes/unreleased/automation-permission-guidance.md @@ -0,0 +1,6 @@ +--- +release: app +type: fixed +--- + +Automation permission guidance now opens the matching macOS Privacy & Security pane. diff --git a/changes/unreleased/automation-permission-status.md b/changes/unreleased/automation-permission-status.md new file mode 100644 index 00000000..2536d7ad --- /dev/null +++ b/changes/unreleased/automation-permission-status.md @@ -0,0 +1,6 @@ +--- +release: plugin +type: fixed +--- + +Automation-dependent plugins now show authorization as an on-demand macOS prompt instead of incorrectly reporting it as already granted. diff --git a/changes/unreleased/catalog-capability-metadata.md b/changes/unreleased/catalog-capability-metadata.md new file mode 100644 index 00000000..f98a4cb4 --- /dev/null +++ b/changes/unreleased/catalog-capability-metadata.md @@ -0,0 +1,7 @@ +--- +release: app +type: added +area: Plugins +--- + +Plugin catalogs can now securely expose localized product details, requirements, privacy disclosures, setup guidance, and static or dynamic action capabilities before installation. diff --git a/changes/unreleased/catalog-capability-pilots.md b/changes/unreleased/catalog-capability-pilots.md new file mode 100644 index 00000000..f504e034 --- /dev/null +++ b/changes/unreleased/catalog-capability-pilots.md @@ -0,0 +1,7 @@ +--- +release: plugin +type: added +area: Plugins +--- + +Every bundled plugin now publishes validated pre-install product, requirement, privacy, setup, relationship, and applicable action metadata in every supported language. diff --git a/docs/github-actions.md b/docs/github-actions.md index 36b42e1f..cdea52bf 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -6,7 +6,7 @@ - `Prepare Release`:在 GitHub Actions 页面手动触发。输入发布类型、目标版本和是否继续发布;它会检查、bump、提交版本变更、创建 tag,并在需要时显式触发 `Release` 或 `Plugin Release`。 - `Release`:在推送 `v*.*.*` 或 `v*.*.*-*` tag,或手动输入 tag 时运行。构建 Release 版本,使用 Developer ID 签名、公证、打包 DMG,创建或更新 GitHub Release;稳定版会明确标记为 GitHub Latest,并更新官网使用的 `docs/app-release.json`,预发布不会覆盖稳定版下载元数据。 - `Homebrew Cask Update`:手动输入版本时运行;未输入版本则从稳定 `v*` App Release 中查找同时包含 `MacTools.dmg` 与 `MacTools.sha256` 的最新版,通过 `brew bump-cask-pr` 向官方 `Homebrew/homebrew-cask` 提交 cask bump PR。 -- `Plugin Release` runs for a pushed `plugins-*` tag or a manually selected plugin batch tag. PluginKit v2 keeps `docs/plugins/catalog.json`, PluginKit v3 and later use `docs/plugins/vN/catalog.json`, the v4 catalog remains immutable for MacTools through 1.1.6, and MacTools 1.2 uses the new `docs/plugins/v5/catalog.json`. The first release of an ABI line rebuilds and signs every plugin. Plugin batches use `--latest=false` and never replace the latest App release. +- `Plugin Release` runs for a pushed `plugins-*` tag or a manually selected plugin batch tag. Legacy PluginKit catalogs below v5 are immutable, and the schema-3 workflow rejects attempts to republish them. PluginKit v3 and later use versioned paths, the v4 catalog remains available to MacTools through 1.1.6, MacTools 1.2.0 keeps `docs/plugins/v5/catalog.json`, and hosts from 1.2.1 use `docs/plugins/v5/schema3/catalog.json`. The first release of a new ABI or schema compatibility line rebuilds and signs every plugin. Plugin batches use `--latest=false` and never replace the latest App release. - `Deploy Pages`:在 `site/**` 或 `docs/app-release.json` 合入 `main`、`Release` / `Plugin Release` 成功完成,或手动触发时运行。它先构建 `site/` 下的 Astro 官网,再合并 `docs/` 中的 App 发布元数据、appcast、插件 catalog、图标库等静态发布资源并发布到 GitHub Pages;PR 不会触发这条流水线。 ## 需要配置的 Secrets @@ -172,7 +172,7 @@ make release ARGS="--type plugin --version 1.1.0 --plugin-mode all --yes" 应用内是否显示“可更新”只比较插件版本,不比较 batch tag 或 asset URL。因此只有实际变化的插件需要递增各自 `plugin.json.version`;未变化插件不会因为新批次 tag 而显示可更新或无效。 -`pluginKitVersion` is the plugin ABI boundary. Raising it requires rebuilding every plugin package and incrementing each plugin's own `plugin.json.version`. The release helper automatically switches an ABI migration to `plugin_mode=all` and writes a complete catalog for the new ABI. PluginKit v5 writes `docs/plugins/v5/catalog.json` and does not modify the v4 catalog used by MacTools through 1.1.6. Catalog validation rejects mixed PluginKit versions so the host never loads a binary from an incompatible ABI. +`pluginKitVersion` is the plugin ABI boundary. Raising it requires rebuilding every plugin package and incrementing each plugin's own `plugin.json.version`. The release helper automatically switches an ABI or catalog-schema compatibility migration to `plugin_mode=all` and writes a complete catalog for the new line. PluginKit v5 schema 3 writes `docs/plugins/v5/schema3/catalog.json` and does not modify the schema-2 catalog used by MacTools 1.2.0 or the v4 catalog used by earlier hosts. Catalog validation rejects mixed PluginKit versions so the host never loads a binary from an incompatible ABI. 推送插件批次 tag: @@ -190,7 +190,7 @@ git push origin plugins-1.0.1 5. 用 Developer ID 重新签名这些插件 bundle,并打包为 `*.mactoolsplugin.zip`。 6. 创建或更新对应的 `plugins-*` GitHub Release,并只上传本批变化插件的 zip。catalog-only 变化可以创建没有 zip asset 的插件 Release。 7. 相同 ABI 内生成本批 delta catalog 并合并进该 ABI 的 catalog;ABI 首次升级则生成包含全部插件的完整 catalog。 -8. Sign the catalog with `PLUGIN_CATALOG_PRIVATE_KEY_BASE64` and write `docs/plugins/catalog.json` for v2 or `docs/plugins/vN/catalog.json` for PluginKit N >= 3. PluginKit v5 writes `docs/plugins/v5/catalog.json` and never overwrites the v4 compatibility baseline. +8. Sign the catalog with `PLUGIN_CATALOG_PRIVATE_KEY_BASE64` and write it to the selected compatibility path. PluginKit v5 schema 3 writes `docs/plugins/v5/schema3/catalog.json` and never overwrites the schema-2 or v4 compatibility baselines. 9. 将对应版本化 catalog 提交回 `main`,再由 `Deploy Pages` 发布到 GitHub Pages。 如果 `auto` 模式没有发现插件包或 catalog 变化,工作流会成功结束,不创建或更新 GitHub Release。 @@ -280,7 +280,7 @@ Finder right-click menu items now stay hidden when the plugin is disabled. - PR 构建不读取发布 Secrets,只执行未签名构建和测试。 - Release 工作流只使用 `contents: write` 创建或更新 GitHub Release,并把 `docs/appcast.xml` 与 `docs/app-release.json` 提交回 `main`;普通 Build 工作流只有 `contents: read`。 -- The Plugin Release workflow uses `contents: write` only to create or update a plugin batch release and commit the signed catalog to `main`. PluginKit v2 writes `docs/plugins/catalog.json`; PluginKit v3 and later write `docs/plugins/vN/catalog.json`. The v4 catalog remains available to MacTools through 1.1.6 while PluginKit v5 writes `docs/plugins/v5/catalog.json`. +- The Plugin Release workflow uses `contents: write` only to create or update a plugin batch release and commit the signed catalog to `main`. The v4 and PluginKit v5/schema-2 catalogs remain available to released hosts while schema-3 PluginKit v5 releases write `docs/plugins/v5/schema3/catalog.json`. - Deploy Pages 工作流在官网源码或 App 下载元数据合入 `main`、Release / Plugin Release 成功后发布站点,使用 `contents: read`、`pages: write` 和 `id-token: write`。 - 签名证书导入临时 keychain,任务结束后清理。 - App Store Connect `.p8`、Sparkle 私钥和插件 catalog 私钥只写入 runner 临时目录或进程环境,使用后删除。 diff --git a/docs/plugins/local-native-plugins.md b/docs/plugins/local-native-plugins.md index bbe71af5..c2b533fd 100644 --- a/docs/plugins/local-native-plugins.md +++ b/docs/plugins/local-native-plugins.md @@ -76,6 +76,8 @@ In this repository, plugin Xcode targets are generated before XcodeGen runs. The The manifest ID is the stable identity of the package. It must match the runtime `PluginMetadata.id`, and a package must return exactly one plugin instance. Use lower-case, readable IDs such as `display-brightness` unless there is a strong reason to use a reverse-DNS identifier. The ID `marketplace` is reserved for the host-owned URL route and is not a valid plugin ID. +The same source manifest may add optional product metadata for pre-install discovery, requirements, privacy, actions, setup, and plugin relationships. Follow [`plugin-manifest.schema.json`](plugin-manifest.schema.json); do not create a second marketplace metadata file. Declare localized product copy under the source-only `productStrings` table. Each entry either reuses `@displayName` or `@summary`, imports an existing `Resources/Localizable.xcstrings` key with `@localizable.`, uses a standard localized toggle or enabled-state label with `@standardAction.`, renders declared requirements with `@standardSetup.requirements.title` or `@standardSetup.requirements.description`, or supplies all 11 supported locale values; localized product fields uniformly reference `@productStrings.`. Packaging and catalog generation expand the references and remove the source table before distribution. Keep screenshots under `MarketplaceAssets/`, and describe dynamic machine-local action entries with templates instead of enumerating local values. The catalog generator validates localization, identifiers, permission and surface values, references, domains, assets, and action completeness before projection. + When a plugin uses a private Apple framework, it must load that framework dynamically at runtime and validate every required class and selector before use. Do not add a static framework link: unsupported systems must present a clear plugin error instead of crashing. ## Development Steps diff --git a/docs/plugins/plugin-catalog.md b/docs/plugins/plugin-catalog.md index a33cff8e..ab4fd41c 100644 --- a/docs/plugins/plugin-catalog.md +++ b/docs/plugins/plugin-catalog.md @@ -2,13 +2,13 @@ MacTools dynamic plugins use one catalog-driven flow for both production distribution and local development. -- PluginKit 2 production builds read the legacy `catalog.json` URL. PluginKit 3 and later builds read versioned URLs. MacTools through 1.1.6 remains on the immutable PluginKit v4 catalog at `v4/catalog.json`; MacTools 1.2 and later use the new PluginKit v5 catalog at `v5/catalog.json`. +- PluginKit 2 production builds read the legacy `catalog.json` URL. PluginKit 3 and later builds read versioned URLs. MacTools through 1.1.6 remains on the immutable PluginKit v4 catalog at `v4/catalog.json`; MacTools 1.2.0 remains on the PluginKit v5/schema-2 catalog at `v5/catalog.json`; schema-3 hosts use `v5/schema3/catalog.json`. - Each catalog contains packages for one PluginKit ABI line. The legacy v2 catalog is kept unchanged when a new ABI is released, so older app builds continue to work. - `minimumHostVersion` at the catalog root is the oldest host that can parse that catalog schema. Each entry declares its own install requirement; older hosts keep the catalog available and show newer entries as incompatible instead of rejecting the whole marketplace. - Local development reads a Debug-only `file://` catalog, usually configured with `MACTOOLS_PLUGIN_CATALOG_URL`. - Both flows resolve catalog entries into local staged packages, verify checksum and manifest compatibility, then install through the same package store. The marketplace can update one plugin at a time or run a batch update for every currently updateable plugin. -## Catalog v2 +## Catalog v2 and v3 ```json { @@ -63,23 +63,69 @@ MacTools dynamic plugins use one catalog-driven flow for both production distrib Catalog schema 2 follows PluginKit 4 and later manifests: `capabilities.settings` is `none`, `form`, or `workspace`. Schema 1 and its boolean `configuration` capability remain in older ABI catalogs and are not rewritten. A newer host may decode an installed package from an older ABI only far enough to identify and update it; it never loads or renders an incompatible settings API. +Catalog schema 3 is additive. It preserves every schema-2 package field and may also project `presentation`, `discovery`, `requirements`, `privacy`, `actions`, `setup`, and `relationships` from the checked-in source manifest. Schema-3 hosts accept both schema 2 and schema 3, so existing sparse catalogs and caches continue to work. The schema-3 catalog uses a separate compatibility endpoint and a minimum host version of 1.2.1, leaving the released 1.2.0 endpoint unchanged. The catalog signature covers every enriched field. + +## Product and Capability Metadata + +`Plugins//plugin.json` is the only checked-in metadata source. Do not add a parallel marketplace manifest. The optional product sections are ignored safely by package loaders that only need the runtime envelope and are validated by the catalog generator: + +- `presentation`: localized long description and examples, screenshot references, documentation and support URLs, publisher, and license. +- `discovery`: keywords, localized synonyms, use cases, goal categories, related plugins, and genuine alternatives. +- `requirements`: macOS, architecture, hardware, application, executable, permission, setup-complexity, and relaunch requirements. +- `privacy`: observed and persisted data, retention, network use and domains, telemetry, sensitive-content processing, and diagnostic-export disclosure. +- `actions`: static descriptors, dynamic templates, parameters, permissions, risk, supported surfaces, automatic eligibility, and external-invocation policy. +- `setup`: localized first-use steps, a suggested static test action, optional next surfaces, and missing-dependency help. +- `relationships`: related plugins, packs, recipes, and superseded plugin IDs. + +Localized product copy is declared once in the source-only `productStrings` table. Each entry either contains `ar`, `de`, `en`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh-Hans`, and `zh-Hant`, reuses the plugin's complete localized metadata with `@displayName` or `@summary`, imports an existing `Resources/Localizable.xcstrings` entry with `@localizable.`, uses a standard `@standardAction.toggle.*` or `@standardAction.set-enabled.*` label, or renders declared permission, hardware, application, and executable requirements with `@standardSetup.requirements.*`. Every localized field in `presentation`, `discovery`, `privacy`, `actions`, and `setup` references `@productStrings.`; inline locale objects and direct base references are rejected. Validation expands these references before package and catalog projection, rejects missing and unused entries, and removes `productStrings` from generated artifacts. Every repository plugin follows this source shape, while `Appearance`, `AppVolume`, `IPOverview`, and `WindowSwitcher` remain useful examples of fully hand-authored entries rather than inherited baseline copy. + +```json +{ + "productStrings": { + "display-name": "@displayName", + "summary": "@summary", + "long-description": { + "ar": "…", + "de": "…", + "en": "Detailed product copy", + "es": "…", + "fr": "…", + "ja": "…", + "ko": "…", + "pt": "…", + "ru": "…", + "zh-Hans": "…", + "zh-Hant": "…" + } + }, + "presentation": { + "longDescription": "@productStrings.long-description" + } +} +``` + +Static action entries describe fixed runtime `ActionDefinition` identities. Dynamic templates describe machine-local entries without putting local application IDs, devices, paths, or other discovered values in the signed catalog. A provider declares `static`, `dynamic`, or `mixed` and must populate the matching collections. `automatic-rule` is valid only for safe automatic actions, while `app-intent` additionally requires portable identity and parameters. External invocation is `unavailable`, `allowed`, `confirmAlways`, or `configurable` when each generated action owns the setting. A dynamic template whose generated entries can differ in risk or automatic eligibility declares `riskVariesByEntry` or `automaticEligibilityVariesByEntry`; its surfaces are the complete set that any generated entry may support, while fixed fields remain exact. Repository-wide XCTest coverage compares static identities and dynamic families, fixed and variable safety policy, external policy, supported surfaces, permissions, system images, and parameter policy against runtime definitions and focused dynamic-provider fixtures. + +Screenshot sources live under `Plugins//MarketplaceAssets/`. The generator rejects traversal, missing or unsupported files, files over 10 MiB, and images over 7680 pixels per dimension. It adds media type, size, dimensions where available, and SHA-256 to the signed projection. Asset bytes are never embedded in `plugin.json`. + Release catalogs must include an Ed25519 signature. Debug local catalogs may omit `signature`, but they still go through package checksum, manifest, staging, and same-team code signature validation. Catalog verification validates every entry's identity and PluginKit ABI without requiring every package to support the current host. Package installation and loading continue to enforce the entry's `minimumHostVersion` strictly. -For an app version that switches to a new production catalog URL, release order is enforced: publish the compatible plugin batch first, wait for Pages to deploy the committed signed catalog, then prepare and publish the app. `scripts/plugins/preflight-app-plugin-catalog.swift` checks that the production URL returns the same nonempty, signed PluginKit catalog committed in the release ref. Both `scripts/release.py --type app` and the final app release workflow run this preflight, so the app cannot be published while its catalog is missing, stale, unsigned, or invalid. +For an app version that switches to a new production catalog URL, release order is enforced: publish the compatible plugin batch first, wait for Pages to deploy the committed signed catalog, then prepare and publish the app. `scripts/plugins/preflight-app-plugin-catalog.swift` checks that the production URL returns the same nonempty, signed PluginKit catalog committed in the release ref. It also prevents this schema-3 source from being released with the already-shipped 1.2.0 version number. Both `scripts/release.py --type app` and the final app release workflow run this preflight, so the app cannot be published while its catalog is missing, stale, unsigned, or invalid. ## Versioned Catalog URLs -The catalog URL is selected by the host's supported PluginKit version: +The catalog URL is selected by the host's supported PluginKit and catalog-schema version. In particular, MacTools 1.2.0 remains on the v5/schema-2 endpoint, while hosts from 1.2.1 use v5/schema 3: ```text PluginKit 2 -> https://mactools.ggbond.app/plugins/catalog.json PluginKit 3 -> https://mactools.ggbond.app/plugins/v3/catalog.json PluginKit 4 -> https://mactools.ggbond.app/plugins/v4/catalog.json -PluginKit 5 -> https://mactools.ggbond.app/plugins/v5/catalog.json +PluginKit 5 / schema 2 -> https://mactools.ggbond.app/plugins/v5/catalog.json +PluginKit 5 / schema 3 -> https://mactools.ggbond.app/plugins/v5/schema3/catalog.json PluginKit N -> https://mactools.ggbond.app/plugins/vN/catalog.json ``` -The first release for a new PluginKit version uses the previous ABI catalog only as a comparison baseline. It publishes a complete catalog containing every rebuilt plugin under the new versioned path. Later releases for the same PluginKit version may use incremental merges within that path. Never overwrite an older ABI catalog with newer packages. +The first release for a new PluginKit or catalog-schema compatibility line uses the previous catalog only as a comparison baseline. It publishes a complete catalog containing every rebuilt plugin under the new path. Later releases on that compatibility line may use incremental merges. Legacy PluginKit lines below v5 are immutable and the schema-3 release workflow rejects attempts to republish them. Never overwrite a catalog consumed by hosts that cannot parse the new schema. ## Local Development @@ -106,7 +152,9 @@ MacToolsPlugins/ Tests/ ``` -`plugin.json` declares the plugin ID, version, capabilities, bundle path, optional `releaseChannel`, and build scheme. In this repository `make generate`, `make build`, `make run`, and `make build-plugin` first scan `Plugins/*/plugin.json` and generate the local XcodeGen plugin targets. External repositories may provide their own `project.yml`, `.xcodeproj`, or the declared bundle directory. The built package contains only `plugin.json` and the signed `.bundle`; extra executables must already be copied into the bundle resources and listed in `plugin.json.package.signPaths` when they require an individual code signature. +`plugin.json` declares the plugin ID, version, capabilities, bundle path, optional `releaseChannel`, and build scheme. In this repository `make generate`, `make build`, `make run`, and `make build-plugin` first scan `Plugins/*/plugin.json` and generate the local XcodeGen plugin targets. External repositories may provide their own `project.yml`, `.xcodeproj`, or the declared bundle directory. The package projection removes the source-only `build` section while retaining the runtime envelope, signing paths, and optional product metadata. The built package contains only that projected `plugin.json` and the signed `.bundle`; extra executables must already be copied into the bundle resources and listed in `plugin.json.package.signPaths` when they require an individual code signature. + +Current source and packaged manifests are validated against the complete runtime envelope before package copy or catalog projection. Source manifests use `productStrings` references; package manifests must contain the expanded projection and must match the source metadata when the source is available. A valid package can still generate a local catalog when its source repository is unavailable, in which case its projected manifest is authoritative. Legacy manifests must retain runtime-decodable `capabilities` and `permissions`, including the PluginKit v3 `capabilities.configuration` form; omitting newer product fields is accepted only below PluginKit 5 through the explicit `--allow-sparse-legacy` local-debug compatibility path. Release catalog generation rejects this mode. From the MacTools repository, build all local plugins and generate the Debug catalog: @@ -165,7 +213,7 @@ Recommended production flow is an incremental batch plugin release: 7. If package-relevant files changed inside a plugin or shared PluginKit code changed but that plugin version did not increase, the workflow fails before signing or uploading. A `pluginKitVersion` change automatically becomes a full `mode=all` rebuild and replaces the catalog for that ABI line; other exceptional shared paths can still be supplied explicitly with `--shared-path`. 8. The workflow builds, signs, zips, and uploads only the selected plugin packages. 9. For an ABI migration, the workflow generates a complete catalog from all rebuilt packages. For later releases within an ABI line, it generates a delta catalog and merges it into that line's catalog, keeping unchanged entries pointing at their existing assets. -10. The signed catalog is committed to `docs/plugins/catalog.json` for v2 or `docs/plugins/vN/catalog.json` for PluginKit N >= 3. The current PluginKit v5 catalog is `docs/plugins/v5/catalog.json`. +10. The signed catalog is committed to its compatibility path. The released PluginKit v5/schema-2 catalog remains at `docs/plugins/v5/catalog.json`; schema 3 is written to `docs/plugins/v5/schema3/catalog.json`. 11. `Deploy Pages` publishes the signed catalog to GitHub Pages. The batch tag is stored per plugin entry through `package.url` and `releaseNotesURL`, so one catalog can point different plugins to different release tags without changing host code. @@ -219,7 +267,7 @@ Generated local output: build/PluginRelease/ Assets/*.mactoolsplugin.zip catalog.json -docs/plugins/v5/catalog.json +docs/plugins/v5/schema3/catalog.json ``` The lower-level scripts are still useful for external plugin repositories. `build-plugin-release-assets.sh` can build all plugins or a subset with repeated `--plugin` arguments: @@ -246,6 +294,8 @@ scripts/plugins/generate-plugin-catalog.sh \ --mode release \ --base-url https://github.com/ggbond268/MacTools/releases/download/plugins-1.0.1 \ --output dist/catalog.json \ + --plugins-root Plugins \ + --website-output dist/website/plugins.json \ --package dist/Demo.mactoolsplugin.zip \ --release-notes-url https://github.com/ggbond268/MacTools/releases/tag/plugins-1.0.1 @@ -255,6 +305,10 @@ scripts/plugins/sign-plugin-catalog.sh \ --private-key-base64 "$PLUGIN_CATALOG_PRIVATE_KEY_BASE64" ``` +`--website-output` writes a package-URL-free deterministic projection for website builds. Referenced screenshots are copied beside it under `assets/` with checksum-based names. Use `--generated-at` in fixtures or reproducibility checks when the catalog timestamp must also be stable. + +Catalog generation rejects duplicate plugin IDs, malformed HTTPS URLs or timestamps, and packages larger than 200 MiB. ZIP packages are inspected from their central directory without extraction: archive paths and member types must be safe, symlinks must remain inside the package root, and member count and expanded size are bounded. Catalog projection of screenshots still requires the matching source assets. + The catalog private key, Developer ID identity, and GitHub token must come from local environment variables or CI secrets. Do not commit them. The catalog public key is safe to embed in the app as `PLUGIN_CATALOG_PUBLIC_KEY`. ## Runtime Lifecycle diff --git a/docs/plugins/plugin-manifest.schema.json b/docs/plugins/plugin-manifest.schema.json new file mode 100644 index 00000000..eb33956f --- /dev/null +++ b/docs/plugins/plugin-manifest.schema.json @@ -0,0 +1,338 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mactools.ggbond.app/schemas/plugin-manifest-v1.json", + "title": "MacTools source plugin manifest", + "type": "object", + "required": [ + "id", + "displayName", + "version", + "minHostVersion", + "pluginKitVersion", + "bundleRelativePath", + "capabilities", + "permissions", + "category" + ], + "properties": { + "id": {"$ref": "#/$defs/pluginIdentifier"}, + "displayName": {"type": "string", "minLength": 1}, + "summary": {"type": "string", "minLength": 1}, + "version": {"$ref": "#/$defs/version"}, + "minHostVersion": {"$ref": "#/$defs/version"}, + "pluginKitVersion": {"type": "integer", "minimum": 1}, + "bundleRelativePath": {"$ref": "#/$defs/bundleRelativePath"}, + "factoryClass": {"type": "string", "minLength": 1}, + "capabilities": {"$ref": "#/$defs/capabilities"}, + "permissions": {"type": "array", "items": {"$ref": "#/$defs/permission"}, "uniqueItems": true}, + "category": {"enum": ["display", "audio", "system", "storage", "productivity", "monitoring", "other"]}, + "releaseChannel": {"type": "string", "minLength": 1}, + "releaseNotesURL": {"type": "string", "format": "uri", "pattern": "^https://"}, + "localizedMetadata": {"$ref": "#/$defs/localizedMetadata"}, + "productStrings": { + "type": "object", + "minProperties": 1, + "propertyNames": {"$ref": "#/$defs/identifier"}, + "additionalProperties": { + "oneOf": [ + {"enum": ["@displayName", "@summary"]}, + {"type": "string", "pattern": "^@localizable\\.[^\\s]+$"}, + {"type": "string", "pattern": "^@standardAction\\.(?:toggle|set-enabled)\\.(?:title|description)$"}, + {"type": "string", "pattern": "^@standardSetup\\.requirements\\.(?:title|description)$"}, + {"$ref": "#/$defs/completeLocalizedText"} + ] + } + }, + "presentation": { + "type": "object", + "required": ["longDescription", "examples", "screenshots", "publisher", "license"], + "properties": { + "longDescription": {"$ref": "#/$defs/localizedText"}, + "examples": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "text"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "text": {"$ref": "#/$defs/localizedText"} + } + } + }, + "screenshots": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "path", "alt"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "path": {"type": "string", "pattern": "^MarketplaceAssets/"}, + "alt": {"$ref": "#/$defs/localizedText"} + } + } + }, + "documentationURL": {"type": "string", "format": "uri", "pattern": "^https://"}, + "supportURL": {"type": "string", "format": "uri", "pattern": "^https://"}, + "publisher": {"type": "string", "minLength": 1}, + "license": {"type": "string", "minLength": 1} + } + }, + "discovery": { + "type": "object", + "required": ["keywords", "localizedSynonyms", "useCases", "goalCategories", "relatedPluginIDs", "alternativePluginIDs"], + "properties": { + "keywords": {"$ref": "#/$defs/uniqueStrings"}, + "localizedSynonyms": {"$ref": "#/$defs/completeLocalizedStringArrays"}, + "useCases": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "title": {"$ref": "#/$defs/localizedText"} + } + } + }, + "goalCategories": {"$ref": "#/$defs/uniqueStrings"}, + "relatedPluginIDs": {"$ref": "#/$defs/uniqueStrings"}, + "alternativePluginIDs": {"$ref": "#/$defs/uniqueStrings"} + } + }, + "requirements": { + "type": "object", + "required": ["architectures", "hardware", "applications", "executables", "permissionIDs", "setupComplexity", "requiresRelaunch"], + "properties": { + "minimumMacOSVersion": {"$ref": "#/$defs/version"}, + "architectures": {"type": "array", "items": {"enum": ["arm64", "x86_64"]}, "uniqueItems": true}, + "hardware": {"$ref": "#/$defs/uniqueStrings"}, + "applications": { + "type": "array", + "items": { + "type": "object", + "required": ["bundleID", "name"], + "properties": { + "bundleID": {"type": "string", "minLength": 1}, + "name": {"type": "string", "minLength": 1} + } + } + }, + "executables": {"$ref": "#/$defs/uniqueStrings"}, + "permissionIDs": {"type": "array", "items": {"$ref": "#/$defs/permission"}, "uniqueItems": true}, + "setupComplexity": {"enum": ["none", "simple", "guided", "advanced"]}, + "requiresRelaunch": {"type": "boolean"} + } + }, + "privacy": { + "type": "object", + "required": ["dataObserved", "dataPersisted", "retention", "networkUse", "networkDomains", "telemetry", "processesSensitiveUserContent", "diagnosticExportsContainUserData"], + "properties": { + "dataObserved": {"$ref": "#/$defs/uniqueStrings"}, + "dataPersisted": {"$ref": "#/$defs/uniqueStrings"}, + "retention": { + "type": "object", + "required": ["policy"], + "properties": { + "policy": {"enum": ["none", "session", "until-disabled", "until-uninstalled", "user-controlled"]}, + "description": {"$ref": "#/$defs/localizedText"} + } + }, + "networkUse": {"enum": ["none", "optional", "required"]}, + "networkDomains": {"$ref": "#/$defs/uniqueStrings"}, + "allowsUserConfiguredDomains": {"type": "boolean"}, + "telemetry": {"enum": ["none", "optional", "required"]}, + "processesSensitiveUserContent": {"type": "boolean"}, + "diagnosticExportsContainUserData": {"type": "boolean"} + } + }, + "actions": { + "type": "object", + "required": ["providers"], + "properties": { + "providers": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "kind", "staticActions", "dynamicTemplates"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "kind": {"enum": ["static", "dynamic", "mixed"]}, + "staticActions": {"type": "array", "items": {"$ref": "#/$defs/staticAction"}}, + "dynamicTemplates": {"type": "array", "items": {"$ref": "#/$defs/dynamicTemplate"}} + } + } + } + } + }, + "setup": { + "type": "object", + "required": ["steps", "optionalSurfaces"], + "properties": { + "steps": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title", "description"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "title": {"$ref": "#/$defs/localizedText"}, + "description": {"$ref": "#/$defs/localizedText"} + } + } + }, + "suggestedTestAction": { + "type": "object", + "required": ["providerID", "actionID"], + "properties": { + "providerID": {"$ref": "#/$defs/identifier"}, + "actionID": {"$ref": "#/$defs/identifier"} + } + }, + "optionalSurfaces": {"type": "array", "items": {"$ref": "#/$defs/surface"}, "uniqueItems": true}, + "missingDependencyHelp": {"$ref": "#/$defs/localizedText"} + } + }, + "relationships": { + "type": "object", + "required": ["relatedPluginIDs", "includedPackIDs", "suggestedRecipeIDs", "supersedesPluginIDs"], + "properties": { + "relatedPluginIDs": {"$ref": "#/$defs/uniqueStrings"}, + "includedPackIDs": {"$ref": "#/$defs/uniqueStrings"}, + "suggestedRecipeIDs": {"$ref": "#/$defs/uniqueStrings"}, + "supersedesPluginIDs": {"$ref": "#/$defs/uniqueStrings"} + } + } + }, + "$defs": { + "pluginIdentifier": { + "type": "string", + "minLength": 3, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$", + "not": {"const": "marketplace"} + }, + "identifier": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+){0,2}$"}, + "bundleRelativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" + }, + "capabilities": { + "type": "object", + "required": ["primaryPanel", "componentPanel", "settings"], + "properties": { + "primaryPanel": {"type": "boolean"}, + "componentPanel": {"type": "boolean"}, + "settings": {"enum": ["none", "form", "workspace"]} + }, + "additionalProperties": false + }, + "localizedMetadata": { + "type": "object", + "propertyNames": {"type": "string", "minLength": 1}, + "additionalProperties": { + "type": "object", + "properties": { + "displayName": {"type": "string", "minLength": 1}, + "summary": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + } + }, + "uniqueStrings": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, + "localizedText": { + "type": "string", + "pattern": "^@productStrings\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "completeLocalizedText": { + "type": "object", + "required": ["ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant"], + "properties": { + "ar": {"type": "string", "minLength": 1}, "de": {"type": "string", "minLength": 1}, + "en": {"type": "string", "minLength": 1}, "es": {"type": "string", "minLength": 1}, + "fr": {"type": "string", "minLength": 1}, "ja": {"type": "string", "minLength": 1}, + "ko": {"type": "string", "minLength": 1}, "pt": {"type": "string", "minLength": 1}, + "ru": {"type": "string", "minLength": 1}, "zh-Hans": {"type": "string", "minLength": 1}, + "zh-Hant": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + }, + "completeLocalizedStringArrays": { + "type": "object", + "required": ["ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant"], + "properties": { + "ar": {"$ref": "#/$defs/uniqueStrings"}, "de": {"$ref": "#/$defs/uniqueStrings"}, + "en": {"$ref": "#/$defs/uniqueStrings"}, "es": {"$ref": "#/$defs/uniqueStrings"}, + "fr": {"$ref": "#/$defs/uniqueStrings"}, "ja": {"$ref": "#/$defs/uniqueStrings"}, + "ko": {"$ref": "#/$defs/uniqueStrings"}, "pt": {"$ref": "#/$defs/uniqueStrings"}, + "ru": {"$ref": "#/$defs/uniqueStrings"}, "zh-Hans": {"$ref": "#/$defs/uniqueStrings"}, + "zh-Hant": {"$ref": "#/$defs/uniqueStrings"} + }, + "additionalProperties": false + }, + "permission": {"enum": ["accessibility", "automation", "calendarFullAccess", "inputMonitoring", "screen-recording", "system-audio-recording"]}, + "surface": {"enum": ["unified-search", "global-shortcut", "run-link", "workflow", "automatic-rule", "action-grid", "trackpad-gesture", "app-intent", "manual"]}, + "parameter": { + "type": "object", + "required": ["id", "kind", "isRequired", "portability"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "kind": {"enum": ["boolean", "integer", "double", "string"]}, + "isRequired": {"type": "boolean"}, + "portability": {"enum": ["portable", "localOnly"]} + } + }, + "actionPolicy": { + "type": "object", + "required": ["permissionIDs", "risk", "surfaces", "automaticEligible", "externalInvocation"], + "properties": { + "permissionIDs": {"type": "array", "items": {"$ref": "#/$defs/permission"}, "uniqueItems": true}, + "risk": {"enum": ["safe", "confirmationRequired"]}, + "surfaces": {"type": "array", "items": {"$ref": "#/$defs/surface"}, "uniqueItems": true}, + "automaticEligible": {"type": "boolean"}, + "externalInvocation": {"enum": ["unavailable", "allowed", "confirmAlways", "configurable"]} + } + }, + "staticAction": { + "allOf": [ + {"$ref": "#/$defs/actionPolicy"}, + { + "type": "object", + "required": ["id", "title", "description", "keywords", "systemImage", "parameters"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "title": {"$ref": "#/$defs/localizedText"}, + "description": {"$ref": "#/$defs/localizedText"}, + "keywords": {"$ref": "#/$defs/uniqueStrings"}, + "systemImage": {"type": "string", "minLength": 1}, + "parameters": {"type": "array", "items": {"$ref": "#/$defs/parameter"}}, + "parameterSummary": {"$ref": "#/$defs/localizedText"} + } + } + ] + }, + "dynamicTemplate": { + "allOf": [ + {"$ref": "#/$defs/actionPolicy"}, + { + "type": "object", + "required": ["id", "title", "description", "keywords", "parameters", "entrySource", "parameterSummary", "localOnlyIdentity"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "title": {"$ref": "#/$defs/localizedText"}, + "description": {"$ref": "#/$defs/localizedText"}, + "keywords": {"$ref": "#/$defs/uniqueStrings"}, + "parameters": {"type": "array", "items": {"$ref": "#/$defs/parameter"}}, + "entrySource": {"type": "string", "minLength": 1}, + "parameterSummary": {"$ref": "#/$defs/localizedText"}, + "localOnlyIdentity": {"type": "boolean"}, + "riskVariesByEntry": {"type": "boolean"}, + "automaticEligibilityVariesByEntry": {"type": "boolean"} + } + } + ] + } + } +} diff --git a/scripts/plugins/build-local-plugins.sh b/scripts/plugins/build-local-plugins.sh index 95344ae0..b782d29b 100755 --- a/scripts/plugins/build-local-plugins.sh +++ b/scripts/plugins/build-local-plugins.sh @@ -411,6 +411,8 @@ if [[ "$SKIP_CATALOG" != "1" ]]; then "$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" \ --mode debug \ --output "$CATALOG_PATH" \ + --plugins-root "$SOURCE_DIR" \ + --allow-sparse-legacy \ "${catalog_args[@]}" fi diff --git a/scripts/plugins/build-plugin-release-assets.sh b/scripts/plugins/build-plugin-release-assets.sh index dfa66ecf..202ff793 100755 --- a/scripts/plugins/build-plugin-release-assets.sh +++ b/scripts/plugins/build-plugin-release-assets.sh @@ -129,7 +129,7 @@ cd "$REPO_ROOT" if [[ -z "$MINIMUM_HOST_VERSION" ]]; then # This is the catalog schema compatibility floor, not the newest package's # host requirement. Individual entries retain their manifest minimums. - MINIMUM_HOST_VERSION="1.1.6" + MINIMUM_HOST_VERSION="1.2.1" fi if [[ -z "$MINIMUM_HOST_VERSION" ]]; then echo "Unable to determine the catalog minimum host version." >&2 @@ -179,6 +179,7 @@ catalog_args=( --mode release --base-url "$BASE_URL" --output "$CATALOG_OUTPUT" + --plugins-root "$SOURCE_DIR" --minimum-host-version "$MINIMUM_HOST_VERSION" ) if [[ -n "$RELEASE_NOTES_URL" ]]; then diff --git a/scripts/plugins/copy-plugin-manifest.py b/scripts/plugins/copy-plugin-manifest.py index 6b2e6117..eabbcdbc 100644 --- a/scripts/plugins/copy-plugin-manifest.py +++ b/scripts/plugins/copy-plugin-manifest.py @@ -9,6 +9,8 @@ import re import shutil +from plugin_source_manifest import expand_localized_references, validate_runtime_envelope + MARKETING_VERSION_PATTERN = re.compile( r"^\s*MARKETING_VERSION\s*=\s*(\S+)\s*$", @@ -28,13 +30,26 @@ def copy_manifest( destination: pathlib.Path, configuration: str, app_version_config: pathlib.Path, + allow_sparse_legacy: bool = False, ) -> None: - if configuration != "Debug": + manifest = json.loads(source.read_text(encoding="utf-8")) + had_build_metadata = "build" in manifest + manifest.pop("build", None) + expanded_manifest = expand_localized_references(manifest, source) + had_localization_references = expanded_manifest != manifest + manifest = expanded_manifest + validate_runtime_envelope( + manifest, + source, + allow_sparse_legacy=allow_sparse_legacy, + ) + + if configuration != "Debug" and not had_build_metadata and not had_localization_references: shutil.copy2(source, destination) return - manifest = json.loads(source.read_text(encoding="utf-8")) - manifest["minHostVersion"] = development_host_version(app_version_config) + if configuration == "Debug": + manifest["minHostVersion"] = development_host_version(app_version_config) destination.write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", @@ -50,6 +65,7 @@ def main() -> None: copy_parser.add_argument("--destination", type=pathlib.Path, required=True) copy_parser.add_argument("--configuration", required=True) copy_parser.add_argument("--app-version-config", type=pathlib.Path, required=True) + copy_parser.add_argument("--allow-sparse-legacy", action="store_true") version_parser = subparsers.add_parser("host-version") version_parser.add_argument("--app-version-config", type=pathlib.Path, required=True) @@ -64,6 +80,7 @@ def main() -> None: destination=args.destination, configuration=args.configuration, app_version_config=args.app_version_config, + allow_sparse_legacy=args.allow_sparse_legacy, ) diff --git a/scripts/plugins/generate-plugin-catalog.py b/scripts/plugins/generate-plugin-catalog.py new file mode 100755 index 00000000..7d734739 --- /dev/null +++ b/scripts/plugins/generate-plugin-catalog.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +"""Generate schema-3 plugin and website catalogs from plugin.json.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import stat +import unicodedata +import zipfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse + +from plugin_source_manifest import ( + ManifestValidationError, + expand_localized_references, + load_known_plugin_ids, + validate_and_project_manifest, + validate_https_url, + validate_projected_manifest, + validate_runtime_envelope, +) + + +SCHEMA3_MINIMUM_HOST_VERSION = "1.2.1" +MAX_PACKAGE_BYTES = 200 * 1024 * 1024 +MAX_ARCHIVE_ENTRIES = 10_000 +MAX_ARCHIVE_EXPANDED_BYTES = MAX_PACKAGE_BYTES +MAX_ARCHIVE_MANIFEST_BYTES = 4 * 1024 * 1024 +MAX_ARCHIVE_SYMLINK_BYTES = 4096 +SUPPORTED_ZIP_COMPRESSION = {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED} + + +def version_tuple(value: str) -> tuple[int, ...]: + if not isinstance(value, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+){0,2}", value): + raise SystemExit(f"Invalid minimum host version: {value}") + return tuple(int(component) for component in value.split(".")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", choices=("debug", "release"), required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--package", type=Path, action="append", required=True) + parser.add_argument("--base-url") + parser.add_argument("--release-notes-url") + parser.add_argument("--catalog-id", default="com.ggbond.mactools.plugins") + parser.add_argument("--minimum-host-version") + parser.add_argument("--plugin-kit-version", type=int) + parser.add_argument("--plugins-root", type=Path, default=Path("Plugins")) + parser.add_argument("--website-output", type=Path) + parser.add_argument("--generated-at") + parser.add_argument("--allow-sparse-legacy", action="store_true") + return parser.parse_args() + + +def directory_metrics(path: Path) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + hidden_flag = getattr(stat, "UF_HIDDEN", 0) + + def is_hidden(candidate: Path) -> bool: + relative = candidate.relative_to(path) + current = path + for part in relative.parts: + current = current / part + if part.startswith("."): + return True + if hidden_flag and current.lstat().st_flags & hidden_flag: + return True + return False + + files = sorted( + candidate for candidate in path.rglob("*") + if candidate.is_file() + and not candidate.is_symlink() + and not is_hidden(candidate) + ) + for file_path in files: + relative = file_path.relative_to(path).as_posix() + data = file_path.read_bytes() + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(data) + digest.update(b"\0") + size += len(data) + return digest.hexdigest(), size + + +def file_metrics(path: Path) -> tuple[str, int]: + digest = hashlib.sha256() + size = 0 + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + size += len(chunk) + return digest.hexdigest(), size + + +def _archive_parts(name: str, archive_path: Path) -> tuple[str, ...]: + if not name or "\\" in name: + raise SystemExit(f"{archive_path} contains an invalid archive path: {name!r}") + relative = PurePosixPath(name) + if relative.is_absolute() or ".." in relative.parts: + raise SystemExit(f"{archive_path} contains an unsafe archive path: {name}") + parts = tuple(part for part in relative.parts if part not in {"", "."}) + if not parts: + raise SystemExit(f"{archive_path} contains an empty archive path") + return parts + + +def _archive_member_kind(info: zipfile.ZipInfo, archive_path: Path) -> str: + if info.is_dir(): + return "directory" + file_type = stat.S_IFMT((info.external_attr >> 16) & 0o177777) + if file_type in {0, stat.S_IFREG}: + return "file" + if file_type == stat.S_IFLNK: + return "symlink" + raise SystemExit(f"{archive_path} contains unsupported archive member {info.filename}") + + +def _validate_archive_member_data( + archive: zipfile.ZipFile, + info: zipfile.ZipInfo, + archive_path: Path, +) -> None: + if info.flag_bits & 0x1: + raise SystemExit(f"{archive_path} contains an encrypted archive member: {info.filename}") + if info.compress_type not in SUPPORTED_ZIP_COMPRESSION: + raise SystemExit( + f"{archive_path} contains an unsupported compression method: {info.filename}" + ) + try: + with archive.open(info) as member: + while member.read(1024 * 1024): + pass + except (EOFError, NotImplementedError, OSError, RuntimeError, zipfile.BadZipFile) as error: + raise SystemExit(f"{archive_path} contains unreadable data: {info.filename}") from error + + +def _validate_archive_symlink( + archive: zipfile.ZipFile, + info: zipfile.ZipInfo, + parts: tuple[str, ...], + package_root: str, + archive_path: Path, +) -> None: + if info.file_size <= 0 or info.file_size > MAX_ARCHIVE_SYMLINK_BYTES: + raise SystemExit(f"{archive_path} contains an invalid symlink {info.filename}") + try: + target_text = archive.read(info).decode("utf-8") + except (UnicodeDecodeError, RuntimeError, zipfile.BadZipFile) as error: + raise SystemExit(f"{archive_path} contains an unreadable symlink {info.filename}") from error + if not target_text or "\\" in target_text or "\0" in target_text: + raise SystemExit(f"{archive_path} contains an invalid symlink {info.filename}") + target = PurePosixPath(target_text) + if target.is_absolute(): + raise SystemExit(f"{archive_path} contains an escaping symlink {info.filename}") + resolved = list(parts[:-1]) + for component in target.parts: + if component in {"", "."}: + continue + if component == "..": + if not resolved: + raise SystemExit(f"{archive_path} contains an escaping symlink {info.filename}") + resolved.pop() + else: + resolved.append(component) + if not resolved or resolved[0] != package_root: + raise SystemExit(f"{archive_path} contains an escaping symlink {info.filename}") + + +def packaged_manifest(path: Path) -> tuple[dict, Path]: + if path.suffix != ".zip": + manifest_path = path / "plugin.json" + if not manifest_path.is_file(): + raise SystemExit(f"Missing plugin.json: {path}") + if manifest_path.stat().st_size <= 0 or manifest_path.stat().st_size > MAX_ARCHIVE_MANIFEST_BYTES: + raise SystemExit(f"{path} contains an invalid package plugin.json size") + return json.loads(manifest_path.read_text(encoding="utf-8")), manifest_path + + if path.stat().st_size > MAX_PACKAGE_BYTES: + raise SystemExit(f"Package exceeds {MAX_PACKAGE_BYTES} bytes: {path}") + try: + archive = zipfile.ZipFile(path) + except zipfile.BadZipFile as error: + raise SystemExit(f"Invalid ZIP package: {path}") from error + with archive: + infos = archive.infolist() + if not infos or len(infos) > MAX_ARCHIVE_ENTRIES: + raise SystemExit( + f"{path} must contain 1...{MAX_ARCHIVE_ENTRIES} archive members" + ) + expanded_size = sum(info.file_size for info in infos) + if expanded_size > MAX_ARCHIVE_EXPANDED_BYTES: + raise SystemExit( + f"{path} expands beyond {MAX_ARCHIVE_EXPANDED_BYTES} bytes" + ) + + members: list[tuple[zipfile.ZipInfo, tuple[str, ...], str]] = [] + normalized_member_paths: set[tuple[str, ...]] = set() + package_roots: set[str] = set() + for info in infos: + parts = _archive_parts(info.filename, path) + normalized_parts = tuple( + unicodedata.normalize("NFC", part).casefold() + for part in parts + ) + if normalized_parts in normalized_member_paths: + raise SystemExit(f"{path} contains a duplicate archive path: {info.filename}") + normalized_member_paths.add(normalized_parts) + kind = _archive_member_kind(info, path) + members.append((info, parts, kind)) + if parts[0].endswith(".mactoolsplugin"): + package_roots.add(parts[0]) + if len(package_roots) != 1: + raise SystemExit(f"{path} must contain exactly one .mactoolsplugin root") + package_root = next(iter(package_roots)) + + for info, parts, kind in members: + if parts[0] not in {package_root, "__MACOSX"}: + raise SystemExit(f"{path} contains unexpected top-level content: {parts[0]}") + if kind == "symlink": + if parts[0] != package_root: + raise SystemExit(f"{path} contains an unsupported metadata symlink") + _validate_archive_symlink(archive, info, parts, package_root, path) + if kind != "directory": + _validate_archive_member_data(archive, info, path) + + manifest_members = [ + info + for info, parts, kind in members + if parts == (package_root, "plugin.json") and kind == "file" + ] + if len(manifest_members) != 1: + raise SystemExit(f"{path} must contain exactly one package plugin.json") + manifest_info = manifest_members[0] + if manifest_info.file_size <= 0 or manifest_info.file_size > MAX_ARCHIVE_MANIFEST_BYTES: + raise SystemExit(f"{path} contains an invalid package plugin.json size") + try: + manifest = json.loads(archive.read(manifest_info).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, RuntimeError, zipfile.BadZipFile) as error: + raise SystemExit(f"{path} contains an invalid package plugin.json") from error + return manifest, Path(f"{path}!/{package_root}/plugin.json") + + +def source_manifest_path(plugins_root: Path, plugin_id: str, packaged_path: Path) -> Path | None: + matches = [] + if plugins_root.is_dir(): + candidates = list(plugins_root.glob("*/plugin.json")) + if (plugins_root / "plugin.json").is_file(): + candidates.append(plugins_root / "plugin.json") + for path in candidates: + if path.resolve() == packaged_path.resolve(): + continue + manifest = json.loads(path.read_text(encoding="utf-8")) + if manifest.get("id") == plugin_id: + matches.append(path) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise SystemExit(f"Multiple source manifests match plugin ID {plugin_id}") + return None + + +def _validated_https_url(value: str, field: str) -> None: + try: + validate_https_url(value, "catalog", field) + except ManifestValidationError as error: + raise SystemExit(str(error)) from error + + +def _source_package_projection(source_manifest: dict, source_path: Path) -> dict: + projected = expand_localized_references(source_manifest, source_path) + projected.pop("build", None) + return projected + + +def _validate_source_package_parity( + source_manifest: dict, + source_path: Path, + packaged: dict, + packaged_path: Path, + mode: str, +) -> None: + expected = _source_package_projection(source_manifest, source_path) + if mode == "debug" and "minHostVersion" in packaged: + expected["minHostVersion"] = packaged["minHostVersion"] + differing = sorted( + key for key in set(expected) | set(packaged) + if key not in expected + or key not in packaged + or expected[key] != packaged[key] + ) + if differing: + raise SystemExit( + f"{packaged_path} does not match its source manifest: " + ", ".join(differing) + ) + + +def _validated_generated_at(value: str | None) -> str: + generated_at = value or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", generated_at): + raise SystemExit("--generated-at must use YYYY-MM-DDTHH:MM:SSZ") + try: + datetime.strptime(generated_at, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as error: + raise SystemExit("--generated-at must use YYYY-MM-DDTHH:MM:SSZ") from error + return generated_at + + +def validate_catalog(catalog: dict, mode: str) -> None: + if not isinstance(catalog.get("catalogID"), str) or not catalog["catalogID"].strip(): + raise SystemExit("--catalog-id must not be blank") + _validated_generated_at(catalog.get("generatedAt")) + version_tuple(catalog.get("minimumHostVersion")) + if type(catalog.get("pluginKitVersion")) is not int or catalog["pluginKitVersion"] < 1: + raise SystemExit("Catalog pluginKitVersion must be a positive integer") + seen_ids: set[str] = set() + for entry in catalog.get("plugins", []): + plugin_id = entry["id"] + if plugin_id in seen_ids: + raise SystemExit(f"Catalog contains duplicate plugin ID: {plugin_id}") + seen_ids.add(plugin_id) + package = entry["package"] + if ( + type(package.get("size")) is not int + or package["size"] <= 0 + or package["size"] > MAX_PACKAGE_BYTES + ): + raise SystemExit(f"{plugin_id}: package size is outside the supported range") + if not isinstance(package.get("sha256"), str) or not re.fullmatch( + r"[a-fA-F0-9]{64}", package["sha256"] + ): + raise SystemExit(f"{plugin_id}: package checksum must be SHA-256") + package_url = package.get("url") + parsed = urlparse(package_url) if isinstance(package_url, str) else None + if mode == "debug" and parsed is not None and parsed.scheme == "file": + pass + elif isinstance(package_url, str): + _validated_https_url(package_url, f"plugins.{plugin_id}.package.url") + else: + raise SystemExit(f"{plugin_id}: package URL is invalid") + release_notes_url = entry.get("releaseNotesURL") + if release_notes_url is not None: + _validated_https_url(release_notes_url, f"plugins.{plugin_id}.releaseNotesURL") + + +def main() -> None: + args = parse_args() + if args.mode == "release" and not args.base_url: + raise SystemExit("--base-url is required in release mode") + if args.mode == "release" and args.allow_sparse_legacy: + raise SystemExit("--allow-sparse-legacy is available only for local debug catalogs") + if not args.catalog_id.strip(): + raise SystemExit("--catalog-id must not be blank") + generated_at = _validated_generated_at(args.generated_at) + if args.base_url is not None: + _validated_https_url(args.base_url, "--base-url") + parsed_base_url = urlparse(args.base_url) + if parsed_base_url.query or parsed_base_url.fragment: + raise SystemExit("--base-url must not contain a query or fragment") + if args.release_notes_url is not None: + _validated_https_url(args.release_notes_url, "--release-notes-url") + minimum_host_version = args.minimum_host_version or ( + SCHEMA3_MINIMUM_HOST_VERSION if args.mode == "release" else "0.1.0" + ) + if args.mode == "release" and version_tuple(minimum_host_version) < version_tuple( + SCHEMA3_MINIMUM_HOST_VERSION + ): + raise SystemExit( + "Schema 3 release catalogs require MacTools " + f"{SCHEMA3_MINIMUM_HOST_VERSION} or later." + ) + try: + known_plugin_ids = load_known_plugin_ids(args.plugins_root) if args.plugins_root.is_dir() else set() + except ManifestValidationError as error: + raise SystemExit(str(error)) from error + entries = [] + website_plugins = [] + plugin_kit_versions = set() + seen_package_ids: dict[str, Path] = {} + for raw_path in args.package: + package_path = raw_path.expanduser().resolve() + if not package_path.exists(): + raise SystemExit(f"Package not found: {package_path}") + packaged, packaged_manifest_path = packaged_manifest(package_path) + try: + plugin_id = validate_runtime_envelope( + packaged, + packaged_manifest_path, + allow_sparse_legacy=args.allow_sparse_legacy, + ) + except ManifestValidationError as error: + raise SystemExit(str(error)) from error + previous_package = seen_package_ids.get(plugin_id) + if previous_package is not None: + raise SystemExit( + f"Duplicate package plugin ID {plugin_id}: {previous_package} and {package_path}" + ) + seen_package_ids[plugin_id] = package_path + + source_path = source_manifest_path(args.plugins_root, plugin_id, packaged_manifest_path) + try: + if source_path is None: + projected = validate_projected_manifest( + packaged, + packaged_manifest_path, + known_plugin_ids or {plugin_id}, + allow_sparse_legacy=args.allow_sparse_legacy, + validate_plugin_references=False, + ) + assets = [] + else: + source_manifest = json.loads(source_path.read_text(encoding="utf-8")) + projected, assets = validate_and_project_manifest( + source_manifest, + source_path, + known_plugin_ids or {plugin_id}, + allow_sparse_legacy=args.allow_sparse_legacy, + ) + validate_projected_manifest( + packaged, + packaged_manifest_path, + known_plugin_ids or {plugin_id}, + allow_sparse_legacy=args.allow_sparse_legacy, + ) + _validate_source_package_parity( + source_manifest, + source_path, + packaged, + packaged_manifest_path, + args.mode, + ) + except ManifestValidationError as error: + raise SystemExit(str(error)) from error + + if ( + source_path is None + and projected.get("presentation", {}).get("screenshots") + ): + raise SystemExit( + f"{packaged_manifest_path}: catalog screenshots require a matching source manifest" + ) + + manifest_plugin_kit_version = int(packaged["pluginKitVersion"]) + plugin_kit_versions.add(manifest_plugin_kit_version) + if args.plugin_kit_version is not None and manifest_plugin_kit_version != args.plugin_kit_version: + raise SystemExit( + f"{packaged_manifest_path} uses pluginKitVersion {manifest_plugin_kit_version}, " + f"but --plugin-kit-version is {args.plugin_kit_version}" + ) + digest, size = directory_metrics(package_path) if package_path.is_dir() else file_metrics(package_path) + if size <= 0 or size > MAX_PACKAGE_BYTES: + raise SystemExit( + f"{package_path}: package size must be 1...{MAX_PACKAGE_BYTES} bytes" + ) + package_url = ( + package_path.as_uri() + if args.mode == "debug" + else args.base_url.rstrip("/") + "/" + package_path.name + ) + entry = { + "id": plugin_id, + "displayName": projected.get("displayName", plugin_id), + "summary": projected.get("summary", projected.get("displayName", plugin_id)), + "localizedMetadata": projected.get("localizedMetadata"), + "version": packaged["version"], + "minimumHostVersion": packaged.get("minHostVersion", minimum_host_version), + "pluginKitVersion": manifest_plugin_kit_version, + "capabilities": packaged.get("capabilities", { + "primaryPanel": False, "componentPanel": False, "settings": "none" + }), + "permissions": packaged.get("permissions", []), + "package": {"url": package_url, "sha256": digest, "size": size}, + "releaseNotesURL": projected.get("releaseNotesURL") or args.release_notes_url, + "category": projected.get("category"), + "releaseChannel": projected.get("releaseChannel"), + } + for section in ( + "presentation", "discovery", "requirements", "privacy", + "actions", "setup", "relationships", + ): + if section in projected: + entry[section] = projected[section] + entries.append(entry) + website_entry = { + key: value for key, value in entry.items() + if key not in {"package", "releaseChannel"} + } + website_plugins.append((website_entry, assets)) + + if args.plugin_kit_version is None: + if len(plugin_kit_versions) != 1: + raise SystemExit( + "Packages must use one pluginKitVersion: " + + ", ".join(map(str, sorted(plugin_kit_versions))) + ) + catalog_plugin_kit_version = next(iter(plugin_kit_versions)) + else: + catalog_plugin_kit_version = args.plugin_kit_version + catalog = { + "schemaVersion": 3, + "catalogID": args.catalog_id, + "generatedAt": generated_at, + "minimumHostVersion": minimum_host_version, + "pluginKitVersion": catalog_plugin_kit_version, + "plugins": sorted(entries, key=lambda entry: entry["id"]), + "revoked": [], + } + validate_catalog(catalog, args.mode) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + if args.website_output is not None: + assets_root = args.website_output.parent / "assets" + website_values = [] + for website_entry, assets in sorted(website_plugins, key=lambda value: value[0]["id"]): + for asset in assets: + destination_name = f"{asset.catalog['sha256']}{asset.source.suffix.lower()}" + assets_root.mkdir(parents=True, exist_ok=True) + shutil.copy2(asset.source, assets_root / destination_name) + for screenshot in website_entry.get("presentation", {}).get("screenshots", []): + if screenshot["id"] == asset.catalog["id"]: + screenshot["path"] = f"assets/{destination_name}" + website_values.append(website_entry) + website = {"schemaVersion": 1, "plugins": website_values} + args.website_output.parent.mkdir(parents=True, exist_ok=True) + args.website_output.write_text( + json.dumps(website, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/plugins/generate-plugin-catalog.sh b/scripts/plugins/generate-plugin-catalog.sh index 4a9241e4..fbf8f293 100755 --- a/scripts/plugins/generate-plugin-catalog.sh +++ b/scripts/plugins/generate-plugin-catalog.sh @@ -1,223 +1,7 @@ #!/bin/zsh set -euo pipefail -if [[ -z "${PYTHON3:-}" ]]; then - if [[ -x /usr/bin/python3 ]]; then - PYTHON3=/usr/bin/python3 - else - PYTHON3=python3 - fi -fi +SCRIPT_DIR="${0:A:h}" +PYTHON_BIN="${PYTHON3:-/usr/bin/python3}" -usage() { - cat <<'USAGE' -Usage: - generate-plugin-catalog.sh --mode debug --output catalog.dev.json --package Demo.mactoolsplugin [--package More.mactoolsplugin] - generate-plugin-catalog.sh --mode release --base-url https://github.com/owner/repo/releases/download/tag --output catalog.json --package Demo.mactoolsplugin.zip - -Options: - --mode debug|release Debug uses file:// package URLs; release uses --base-url. - --output PATH Catalog JSON output path. - --package PATH .mactoolsplugin directory or .mactoolsplugin.zip. Repeatable. - --base-url URL Release asset base URL. - --release-notes-url URL Optional release notes URL used when plugin.json omits one. - --catalog-id ID Defaults to com.ggbond.mactools.plugins. - --minimum-host-version VER Catalog schema compatibility floor. Defaults to 1.1.6. - --plugin-kit-version INT Override catalog PluginKit version; defaults to package manifests. - -The script does not sign the catalog. Run sign-plugin-catalog.sh for release catalogs. -USAGE -} - -MODE="" -OUTPUT="" -BASE_URL="" -RELEASE_NOTES_URL="" -CATALOG_ID="com.ggbond.mactools.plugins" -MINIMUM_HOST_VERSION="1.1.6" -PLUGIN_KIT_VERSION="" -PACKAGES=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --mode) - MODE="${2:-}" - shift 2 - ;; - --output) - OUTPUT="${2:-}" - shift 2 - ;; - --package) - PACKAGES+=("${2:-}") - shift 2 - ;; - --base-url) - BASE_URL="${2:-}" - shift 2 - ;; - --release-notes-url) - RELEASE_NOTES_URL="${2:-}" - shift 2 - ;; - --catalog-id) - CATALOG_ID="${2:-}" - shift 2 - ;; - --minimum-host-version) - MINIMUM_HOST_VERSION="${2:-}" - shift 2 - ;; - --plugin-kit-version) - PLUGIN_KIT_VERSION="${2:-}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 1 - ;; - esac -done - -if [[ "$MODE" != "debug" && "$MODE" != "release" ]]; then - echo "--mode must be debug or release." >&2 - exit 1 -fi - -if [[ -z "$OUTPUT" || ${#PACKAGES[@]} -eq 0 ]]; then - echo "--output and at least one --package are required." >&2 - usage >&2 - exit 1 -fi - -if [[ "$MODE" == "release" && -z "$BASE_URL" ]]; then - echo "--base-url is required in release mode." >&2 - exit 1 -fi - -"$PYTHON3" - "$MODE" "$OUTPUT" "$BASE_URL" "$RELEASE_NOTES_URL" "$CATALOG_ID" "$MINIMUM_HOST_VERSION" "$PLUGIN_KIT_VERSION" "${PACKAGES[@]}" <<'PY' -import hashlib -import json -import pathlib -import sys -import tempfile -import zipfile -from datetime import datetime, timezone - -mode, output, base_url, release_notes_url, catalog_id, minimum_host_version, plugin_kit_version, *packages = sys.argv[1:] -requested_plugin_kit_version = int(plugin_kit_version) if plugin_kit_version else None - -def directory_metrics(path: pathlib.Path): - h = hashlib.sha256() - size = 0 - for file_path in sorted(p for p in path.rglob("*") if p.is_file() and not any(part.startswith(".") for part in p.relative_to(path).parts)): - rel = file_path.relative_to(path).as_posix() - data = file_path.read_bytes() - h.update(rel.encode("utf-8")) - h.update(b"\0") - h.update(data) - h.update(b"\0") - size += len(data) - return h.hexdigest(), size - -def file_metrics(path: pathlib.Path): - h = hashlib.sha256() - size = 0 - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - size += len(chunk) - return h.hexdigest(), size - -def package_root(path: pathlib.Path): - if path.suffix == ".zip": - temp = pathlib.Path(tempfile.mkdtemp(prefix="mactools-plugin-catalog-")) - with zipfile.ZipFile(path) as zf: - zf.extractall(temp) - roots = [p for p in temp.iterdir() if p.name.endswith(".mactoolsplugin")] - if len(roots) != 1: - raise SystemExit(f"{path} must contain exactly one .mactoolsplugin root") - return roots[0] - return path - -entries = [] -plugin_kit_versions = set() -for raw in packages: - package_path = pathlib.Path(raw).expanduser().resolve() - if not package_path.exists(): - raise SystemExit(f"Package not found: {package_path}") - - root = package_root(package_path) - manifest_path = root / "plugin.json" - if not manifest_path.exists(): - raise SystemExit(f"Missing plugin.json: {root}") - - manifest = json.loads(manifest_path.read_text()) - if "pluginKitVersion" not in manifest: - raise SystemExit(f"Missing pluginKitVersion: {manifest_path}") - manifest_plugin_kit_version = int(manifest["pluginKitVersion"]) - plugin_kit_versions.add(manifest_plugin_kit_version) - if requested_plugin_kit_version is not None and manifest_plugin_kit_version != requested_plugin_kit_version: - raise SystemExit( - f"{manifest_path} uses pluginKitVersion {manifest_plugin_kit_version}, " - f"but --plugin-kit-version is {requested_plugin_kit_version}" - ) - digest, size = directory_metrics(package_path) if package_path.is_dir() else file_metrics(package_path) - if mode == "debug": - url = package_path.as_uri() - else: - url = base_url.rstrip("/") + "/" + package_path.name - - entries.append({ - "id": manifest["id"], - "displayName": manifest.get("displayName", manifest["id"]), - "summary": manifest.get("summary", manifest.get("displayName", manifest["id"])), - "localizedMetadata": manifest.get("localizedMetadata"), - "version": manifest["version"], - "minimumHostVersion": manifest.get("minHostVersion", minimum_host_version), - "pluginKitVersion": manifest_plugin_kit_version, - "capabilities": manifest.get("capabilities", { - "primaryPanel": False, - "componentPanel": False, - "settings": "none", - }), - "permissions": manifest.get("permissions", []), - "package": { - "url": url, - "sha256": digest, - "size": size, - }, - "releaseNotesURL": manifest.get("releaseNotesURL") or release_notes_url or None, - "category": manifest.get("category"), - "releaseChannel": manifest.get("releaseChannel"), - }) - -if requested_plugin_kit_version is None: - if len(plugin_kit_versions) != 1: - raise SystemExit( - "Packages must use one pluginKitVersion: " - + ", ".join(str(version) for version in sorted(plugin_kit_versions)) - ) - catalog_plugin_kit_version = next(iter(plugin_kit_versions)) -else: - catalog_plugin_kit_version = requested_plugin_kit_version - -catalog = { - "schemaVersion": 2, - "catalogID": catalog_id, - "generatedAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), - "minimumHostVersion": minimum_host_version, - "pluginKitVersion": catalog_plugin_kit_version, - "plugins": sorted(entries, key=lambda entry: entry["id"]), - "revoked": [], -} - -output_path = pathlib.Path(output) -output_path.parent.mkdir(parents=True, exist_ok=True) -output_path.write_text(json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n") -PY +exec "$PYTHON_BIN" "$SCRIPT_DIR/generate-plugin-catalog.py" "$@" diff --git a/scripts/plugins/merge-plugin-catalog.py b/scripts/plugins/merge-plugin-catalog.py index 9f408dc7..40ff05f8 100755 --- a/scripts/plugins/merge-plugin-catalog.py +++ b/scripts/plugins/merge-plugin-catalog.py @@ -7,6 +7,7 @@ DEFAULT_CATALOG_ID = "com.ggbond.mactools.plugins" DEFAULT_MINIMUM_HOST_VERSION = "1.1.6" +SCHEMA3_MINIMUM_HOST_VERSION = "1.2.1" def parse_args(): @@ -54,6 +55,10 @@ def now_iso8601(): return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") +def version_tuple(value): + return tuple(int(part) for part in value.split(".")) + + def main(): args = parse_args() previous = unsigned(load_json(args.previous, required=False)) @@ -97,7 +102,7 @@ def main(): merged_entries[plugin_id] = update_entries[plugin_id] base_catalog = previous or updates - schema_version = update_field(base_catalog, updates, "schemaVersion", 2) + schema_version = update_field(base_catalog, updates, "schemaVersion", 3) plugin_kit_version = update_field(base_catalog, updates, "pluginKitVersion", args.plugin_kit_version) if plugin_kit_version != args.plugin_kit_version: raise SystemExit( @@ -116,24 +121,39 @@ def main(): + ", ".join(incompatible_entries) ) + minimum_host_version = args.minimum_host_version or min( + ( + value + for value in [ + (previous or {}).get("minimumHostVersion"), + (updates or {}).get("minimumHostVersion"), + ] + if value + ), + key=version_tuple, + default=( + SCHEMA3_MINIMUM_HOST_VERSION + if schema_version >= 3 + else DEFAULT_MINIMUM_HOST_VERSION + ), + ) + if schema_version >= 3 and version_tuple(minimum_host_version) < version_tuple( + SCHEMA3_MINIMUM_HOST_VERSION + ): + if args.minimum_host_version: + raise SystemExit( + "Schema 3 catalogs require minimumHostVersion " + f"{SCHEMA3_MINIMUM_HOST_VERSION} or later." + ) + minimum_host_version = SCHEMA3_MINIMUM_HOST_VERSION + catalog = { "schemaVersion": schema_version, "catalogID": update_field(base_catalog, updates, "catalogID", DEFAULT_CATALOG_ID), "generatedAt": now_iso8601(), # The catalog floor describes schema compatibility. Per-entry host # requirements may be newer and are enforced by each client. - "minimumHostVersion": args.minimum_host_version or min( - ( - value - for value in [ - (previous or {}).get("minimumHostVersion"), - (updates or {}).get("minimumHostVersion"), - ] - if value - ), - key=lambda value: tuple(int(part) for part in value.split(".")), - default=DEFAULT_MINIMUM_HOST_VERSION, - ), + "minimumHostVersion": minimum_host_version, "pluginKitVersion": plugin_kit_version, "plugins": sorted(merged_entries.values(), key=lambda entry: entry["id"]), "revoked": update_field(base_catalog, updates, "revoked", []), diff --git a/scripts/plugins/plan-plugin-release.py b/scripts/plugins/plan-plugin-release.py index 059fc81d..6dc950ff 100755 --- a/scripts/plugins/plan-plugin-release.py +++ b/scripts/plugins/plan-plugin-release.py @@ -20,6 +20,14 @@ def parse_args(): parser.add_argument("--source-dir", default="Plugins") parser.add_argument("--previous-catalog", default="docs/plugins/catalog.json") parser.add_argument("--output", required=True) + parser.add_argument( + "--require-version-bump", + action="store_true", + help=( + "Require every plugin already present in the previous catalog to use a " + "strictly newer version. Intended for full compatibility-line migrations." + ), + ) parser.add_argument( "--shared-path", action="append", @@ -151,6 +159,9 @@ def normalize_selected(raw_values, plugins): def plan_release(args): + if args.require_version_bump and args.mode != "all": + raise SystemExit("--require-version-bump requires --mode all") + plugins = read_plugins(args.source_dir) plugin_kit_version = current_plugin_kit_version(plugins) previous_catalog = load_json(args.previous_catalog) @@ -266,6 +277,13 @@ def handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp): f"({previous_version} -> {current_version})" ) continue + if args.require_version_bump and version_cmp == 0: + errors.append( + f"{plugin_id}: compatibility migration requires a version bump " + f"above {previous_version}. Bump {plugin['manifestPath']} version " + "before publishing the full rebuild." + ) + continue handle_plugin_kit_change(plugin_id, plugin, previous_entry, version_cmp) select(plugin_id, "all mode") elif args.mode == "selected": diff --git a/scripts/plugins/plugin_source_manifest.py b/scripts/plugins/plugin_source_manifest.py new file mode 100755 index 00000000..7d5de983 --- /dev/null +++ b/scripts/plugins/plugin_source_manifest.py @@ -0,0 +1,1331 @@ +#!/usr/bin/env python3 +"""Validation and projection for checked-in MacTools plugin manifests.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import re +import struct +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse + + +SUPPORTED_LOCALE_ORDER = ( + "ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant" +) +SUPPORTED_LOCALES = frozenset(SUPPORTED_LOCALE_ORDER) +SUPPORTED_LOCALE_SET = SUPPORTED_LOCALES +BASE_LOCALIZED_REFERENCES = {"@displayName", "@summary"} +LOCALIZABLE_STRING_REFERENCE_PREFIX = "@localizable." +STANDARD_ACTION_REFERENCE_PREFIX = "@standardAction." +STANDARD_SETUP_REFERENCE_PREFIX = "@standardSetup." +PRODUCT_STRING_REFERENCE_PREFIX = "@productStrings." +STANDARD_ACTION_TEMPLATES = { + "toggle.title": { + "ar": "تبديل حالة {displayName}", "de": "{displayName} umschalten", + "en": "Toggle {displayName}", "es": "Alternar {displayName}", + "fr": "Activer ou désactiver {displayName}", "ja": "{displayName}を切り替える", + "ko": "{displayName} 전환", "pt": "Alternar {displayName}", + "ru": "Переключить «{displayName}»", "zh-Hans": "切换{displayName}", + "zh-Hant": "切換{displayName}", + }, + "toggle.description": { + "ar": "بدّل حالة «{displayName}» بين التشغيل والإيقاف.", + "de": "Schaltet „{displayName}“ ein oder aus.", + "en": "Switch {displayName} between on and off.", + "es": "Activa o desactiva {displayName}.", + "fr": "Active ou désactive {displayName}.", + "ja": "{displayName}のオン/オフを切り替えます。", + "ko": "{displayName}을(를) 켜거나 끕니다.", + "pt": "Ativa ou desativa {displayName}.", + "ru": "Включает или выключает функцию «{displayName}».", + "zh-Hans": "在开启和关闭之间切换{displayName}。", + "zh-Hant": "在開啟和關閉之間切換{displayName}。", + }, + "set-enabled.title": { + "ar": "تعيين حالة {displayName}", "de": "{displayName}-Status festlegen", + "en": "Set {displayName} State", "es": "Definir estado de {displayName}", + "fr": "Définir l’état de {displayName}", "ja": "{displayName}の状態を設定", + "ko": "{displayName} 상태 설정", "pt": "Definir estado de {displayName}", + "ru": "Задать состояние «{displayName}»", "zh-Hans": "设置{displayName}状态", + "zh-Hant": "設定{displayName}狀態", + }, + "set-enabled.description": { + "ar": "عيّن ما إذا كان «{displayName}» مفعّلًا.", + "de": "Legt fest, ob „{displayName}“ aktiviert ist.", + "en": "Set whether {displayName} is enabled.", + "es": "Define si {displayName} está activado.", + "fr": "Définit si {displayName} est activé.", + "ja": "{displayName}を有効にするかどうかを設定します。", + "ko": "{displayName}의 활성화 여부를 설정합니다.", + "pt": "Define se {displayName} está ativado.", + "ru": "Определяет, включена ли функция «{displayName}».", + "zh-Hans": "设置是否启用{displayName}。", + "zh-Hant": "設定是否啟用{displayName}。", + }, +} +STANDARD_SETUP_TEMPLATES = { + "requirements.title": { + "ar": "إعداد {displayName}", "de": "{displayName} einrichten", + "en": "Set Up {displayName}", "es": "Configurar {displayName}", + "fr": "Configurer {displayName}", "ja": "{displayName}を設定", + "ko": "{displayName} 설정", "pt": "Configurar {displayName}", + "ru": "Настройка «{displayName}»", "zh-Hans": "设置{displayName}", + "zh-Hant": "設定{displayName}", + }, + "requirements.description": { + "ar": "قبل استخدام هذه الإضافة، راجع المتطلبات التالية واستوفها: {requirements}.", + "de": "Prüfe und erfülle vor der Verwendung dieses Plugins folgende Anforderungen: {requirements}.", + "en": "Before using this plugin, review and satisfy these requirements: {requirements}.", + "es": "Antes de usar este plugin, revisa y cumple estos requisitos: {requirements}.", + "fr": "Avant d’utiliser ce module, vérifiez et remplissez les conditions suivantes : {requirements}.", + "ja": "このプラグインを使用する前に、次の要件を確認して満たしてください:{requirements}。", + "ko": "이 플러그인을 사용하기 전에 다음 요구 사항을 확인하고 충족하세요: {requirements}.", + "pt": "Antes de usar este plugin, revise e cumpra estes requisitos: {requirements}.", + "ru": "Перед использованием плагина проверьте и выполните следующие требования: {requirements}.", + "zh-Hans": "使用此插件前,请检查并满足以下要求:{requirements}。", + "zh-Hant": "使用此外掛程式前,請檢查並滿足以下要求:{requirements}。", + }, +} + + +def _localized_requirement( + en: str, + *, + ar: str, + de: str, + es: str, + fr: str, + ja: str, + ko: str, + pt: str, + ru: str, + zh_hans: str, + zh_hant: str, +) -> dict[str, str]: + return { + "ar": ar, "de": de, "en": en, "es": es, "fr": fr, "ja": ja, + "ko": ko, "pt": pt, "ru": ru, "zh-Hans": zh_hans, "zh-Hant": zh_hant, + } + + +LOCALIZED_REQUIREMENT_NAMES = { + "accessibility": _localized_requirement( + "Accessibility permission", ar="إذن تسهيلات الاستخدام", de="Bedienungshilfen-Berechtigung", + es="permiso de Accesibilidad", fr="autorisation Accessibilité", ja="アクセシビリティ権限", + ko="손쉬운 사용 권한", pt="permissão de Acessibilidade", ru="доступ к Универсальному доступу", + zh_hans="辅助功能权限", zh_hant="輔助使用權限", + ), + "automation": _localized_requirement( + "Automation permission", ar="إذن الأتمتة", de="Automation-Berechtigung", + es="permiso de Automatización", fr="autorisation Automatisation", ja="オートメーション権限", + ko="자동화 권한", pt="permissão de Automação", ru="доступ к Автоматизации", + zh_hans="自动化权限", zh_hant="自動化權限", + ), + "calendarFullAccess": _localized_requirement( + "Full Calendar Access", ar="وصول كامل إلى التقويم", de="Vollzugriff auf Kalender", + es="acceso total al Calendario", fr="accès complet au calendrier", ja="カレンダーへのフルアクセス", + ko="캘린더 전체 접근", pt="acesso total ao Calendário", ru="полный доступ к Календарю", + zh_hans="日历完全访问权限", zh_hant="行事曆完整取用權限", + ), + "inputMonitoring": _localized_requirement( + "Input Monitoring permission", ar="إذن مراقبة الإدخال", de="Eingabeüberwachung-Berechtigung", + es="permiso de Monitorización de entrada", fr="autorisation Surveillance de l’entrée", + ja="入力監視権限", ko="입력 모니터링 권한", pt="permissão de Monitoramento de Entrada", + ru="доступ к Мониторингу ввода", zh_hans="输入监控权限", zh_hant="輸入監控權限", + ), + "screen-recording": _localized_requirement( + "Screen Recording permission", ar="إذن تسجيل الشاشة", de="Bildschirmaufnahme-Berechtigung", + es="permiso de Grabación de pantalla", fr="autorisation Enregistrement de l’écran", + ja="画面収録権限", ko="화면 기록 권한", pt="permissão de Gravação de Tela", + ru="доступ к Записи экрана", zh_hans="屏幕录制权限", zh_hant="螢幕錄製權限", + ), + "system-audio-recording": _localized_requirement( + "System Audio Recording permission", ar="إذن تسجيل صوت النظام", + de="Systemaudioaufnahme-Berechtigung", es="permiso de grabación de audio del sistema", + fr="autorisation d’enregistrement audio du système", ja="システムオーディオ録音権限", + ko="시스템 오디오 녹음 권한", pt="permissão de gravação de áudio do sistema", + ru="доступ к записи системного аудио", zh_hans="系统音频录制权限", zh_hant="系統音訊錄製權限", + ), + "built-in battery": _localized_requirement( + "built-in battery", ar="بطارية مدمجة", de="integrierter Akku", es="batería integrada", + fr="batterie intégrée", ja="内蔵バッテリー", ko="내장 배터리", pt="bateria integrada", + ru="встроенный аккумулятор", zh_hans="内置电池", zh_hant="內建電池", + ), + "connected display": _localized_requirement( + "connected display", ar="شاشة متصلة", de="angeschlossenes Display", es="pantalla conectada", + fr="écran connecté", ja="接続済みディスプレイ", ko="연결된 디스플레이", + pt="monitor conectado", ru="подключённый дисплей", zh_hans="已连接的显示器", zh_hant="已連接的顯示器", + ), + "controllable system fans": _localized_requirement( + "controllable system fans", ar="مراوح نظام قابلة للتحكم", de="steuerbare Systemlüfter", + es="ventiladores del sistema controlables", fr="ventilateurs système contrôlables", + ja="制御可能なシステムファン", ko="제어 가능한 시스템 팬", pt="ventoinhas do sistema controláveis", + ru="управляемые системные вентиляторы", zh_hans="可控制的系统风扇", zh_hant="可控制的系統風扇", + ), + "Sidecar-compatible Mac and display": _localized_requirement( + "Sidecar-compatible Mac and display", ar="جهاز Mac وشاشة متوافقان مع Sidecar", + de="Sidecar-kompatibler Mac und Bildschirm", es="Mac y pantalla compatibles con Sidecar", + fr="Mac et écran compatibles avec Sidecar", ja="Sidecar 対応の Mac とディスプレイ", + ko="Sidecar 호환 Mac 및 디스플레이", pt="Mac e monitor compatíveis com Sidecar", + ru="Mac и дисплей с поддержкой Sidecar", zh_hans="支持 Sidecar 的 Mac 和显示器", + zh_hant="支援 Sidecar 的 Mac 和顯示器", + ), +} +VALID_CATEGORIES = { + "display", "audio", "system", "storage", "productivity", "monitoring", "other" +} +VALID_PERMISSION_IDS = { + "accessibility", "automation", "calendarFullAccess", "inputMonitoring", + "screen-recording", "system-audio-recording" +} +VALID_SURFACES = { + "unified-search", "global-shortcut", "run-link", "workflow", "automatic-rule", + "action-grid", "trackpad-gesture", "app-intent", "manual" +} +VALID_RISKS = {"safe", "confirmationRequired"} +VALID_EXTERNAL_POLICIES = {"unavailable", "allowed", "confirmAlways", "configurable"} +VALID_PROVIDER_KINDS = {"static", "dynamic", "mixed"} +VALID_PARAMETER_KINDS = {"boolean", "integer", "double", "string"} +VALID_PORTABILITY = {"portable", "localOnly"} +VALID_ARCHITECTURES = {"arm64", "x86_64"} +VALID_SETUP_COMPLEXITIES = {"none", "simple", "guided", "advanced"} +VALID_NETWORK_USE = {"none", "optional", "required"} +VALID_TELEMETRY = {"none", "optional", "required"} +VALID_RETENTION = {"none", "session", "until-disabled", "until-uninstalled", "user-controlled"} +VALID_SETTINGS_CAPABILITIES = {"none", "form", "workspace"} +CURRENT_SOURCE_PLUGIN_KIT_VERSION = 5 +MAX_ASSET_BYTES = 10 * 1024 * 1024 +MAX_ASSET_DIMENSION = 7680 +IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +PLUGIN_IDENTIFIER_PATTERN = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$" +) +VERSION_PATTERN = re.compile(r"^[0-9]+(?:\.[0-9]+){0,2}$") +DOMAIN_PATTERN = re.compile( + r"^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+" + r"[A-Za-z]{2,63}$" +) + + +class ManifestValidationError(ValueError): + pass + + +@dataclass(frozen=True) +class AssetProjection: + source: Path + catalog: dict + + +def _localized_source_fields(manifest: dict): + presentation = manifest.get("presentation") + if isinstance(presentation, dict): + if "longDescription" in presentation: + yield "presentation.longDescription", presentation["longDescription"] + examples = presentation.get("examples") + if isinstance(examples, list): + for index, example in enumerate(examples): + if isinstance(example, dict) and "text" in example: + yield f"presentation.examples[{index}].text", example["text"] + screenshots = presentation.get("screenshots") + if isinstance(screenshots, list): + for index, screenshot in enumerate(screenshots): + if isinstance(screenshot, dict) and "alt" in screenshot: + yield f"presentation.screenshots[{index}].alt", screenshot["alt"] + + discovery = manifest.get("discovery") + if isinstance(discovery, dict): + use_cases = discovery.get("useCases") + if isinstance(use_cases, list): + for index, use_case in enumerate(use_cases): + if isinstance(use_case, dict) and "title" in use_case: + yield f"discovery.useCases[{index}].title", use_case["title"] + + privacy = manifest.get("privacy") + if isinstance(privacy, dict): + retention = privacy.get("retention") + if isinstance(retention, dict) and "description" in retention: + yield "privacy.retention.description", retention["description"] + + actions = manifest.get("actions") + if isinstance(actions, dict): + providers = actions.get("providers") + if isinstance(providers, list): + for provider_index, provider in enumerate(providers): + if not isinstance(provider, dict): + continue + for collection_name in ("staticActions", "dynamicTemplates"): + entries = provider.get(collection_name) + if not isinstance(entries, list): + continue + for entry_index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + for field in ("title", "description", "parameterSummary"): + if field in entry: + yield ( + f"actions.providers[{provider_index}].{collection_name}" + f"[{entry_index}].{field}", + entry[field], + ) + + setup = manifest.get("setup") + if isinstance(setup, dict): + steps = setup.get("steps") + if isinstance(steps, list): + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + for field in ("title", "description"): + if field in step: + yield f"setup.steps[{index}].{field}", step[field] + if "missingDependencyHelp" in setup: + yield "setup.missingDependencyHelp", setup["missingDependencyHelp"] + + +def validate_runtime_envelope( + manifest: dict, + manifest_path: Path, + *, + allow_sparse_legacy: bool = False, +) -> str: + """Validate fields decoded by PluginPackageManifest before projection or packaging.""" + fallback_id = manifest_path.parent.name + plugin_id = manifest.get("id", fallback_id) + plugin_kit_version = manifest.get("pluginKitVersion") + is_sparse_legacy = ( + allow_sparse_legacy + and type(plugin_kit_version) is int + and plugin_kit_version < CURRENT_SOURCE_PLUGIN_KIT_VERSION + ) + required = { + "id", + "displayName", + "version", + "minHostVersion", + "pluginKitVersion", + "bundleRelativePath", + "capabilities", + "permissions", + } + if not is_sparse_legacy: + required.add("category") + missing = sorted(required - set(manifest)) + if missing: + _fail(plugin_id, "manifest", "missing required keys: " + ", ".join(missing)) + + if "id" in manifest: + if ( + not isinstance(manifest["id"], str) + or manifest["id"] == "marketplace" + or not PLUGIN_IDENTIFIER_PATTERN.fullmatch(manifest["id"]) + ): + _fail(plugin_id, "id", "must be a valid non-reserved plugin identifier") + plugin_id = manifest["id"] + if "displayName" in manifest: + _non_empty_string(manifest["displayName"], plugin_id, "displayName") + if "summary" in manifest: + _non_empty_string(manifest["summary"], plugin_id, "summary") + for key in ("version", "minHostVersion"): + if key in manifest: + _version(manifest[key], plugin_id, key) + if "pluginKitVersion" in manifest: + if type(manifest["pluginKitVersion"]) is not int or manifest["pluginKitVersion"] < 1: + _fail(plugin_id, "pluginKitVersion", "must be a positive integer") + if "bundleRelativePath" in manifest: + bundle_path = manifest["bundleRelativePath"] + if ( + not isinstance(bundle_path, str) + or not bundle_path + or bundle_path.startswith("/") + or ".." in bundle_path.split("/") + ): + _fail(plugin_id, "bundleRelativePath", "must be a safe relative path") + if "factoryClass" in manifest: + _non_empty_string(manifest["factoryClass"], plugin_id, "factoryClass") + if "capabilities" in manifest: + capabilities = manifest["capabilities"] + if not isinstance(capabilities, dict): + _fail(plugin_id, "capabilities", "must be an object") + if is_sparse_legacy: + allowed_capabilities = { + "primaryPanel", "componentPanel", "settings", "configuration" + } + required_capabilities = set() + else: + allowed_capabilities = {"primaryPanel", "componentPanel", "settings"} + required_capabilities = allowed_capabilities + missing_capabilities = sorted(required_capabilities - set(capabilities)) + if missing_capabilities: + _fail( + plugin_id, + "capabilities", + "missing required keys: " + ", ".join(missing_capabilities), + ) + unexpected_capabilities = sorted(set(capabilities) - allowed_capabilities) + if unexpected_capabilities: + _fail( + plugin_id, + "capabilities", + "contains unsupported keys: " + ", ".join(unexpected_capabilities), + ) + for key in ("primaryPanel", "componentPanel"): + if key in capabilities and type(capabilities[key]) is not bool: + _fail(plugin_id, f"capabilities.{key}", "must be a boolean") + if "settings" in capabilities: + settings = capabilities["settings"] + if not isinstance(settings, str) or settings not in VALID_SETTINGS_CAPABILITIES: + _fail(plugin_id, "capabilities.settings", "is not supported") + if "configuration" in capabilities and type(capabilities["configuration"]) is not bool: + _fail(plugin_id, "capabilities.configuration", "must be a boolean") + if "permissions" in manifest: + _unique_strings(manifest["permissions"], plugin_id, "permissions") + invalid_permissions = sorted(set(manifest["permissions"]) - VALID_PERMISSION_IDS) + if invalid_permissions: + _fail(plugin_id, "permissions", "unknown: " + ", ".join(invalid_permissions)) + if "category" in manifest: + category = manifest["category"] + if not isinstance(category, str) or category not in VALID_CATEGORIES: + _fail(plugin_id, "category", "is not a supported category") + if "releaseChannel" in manifest: + _non_empty_string(manifest["releaseChannel"], plugin_id, "releaseChannel") + if "releaseNotesURL" in manifest: + _https_url(manifest["releaseNotesURL"], plugin_id, "releaseNotesURL") + if "localizedMetadata" in manifest: + metadata = manifest["localizedMetadata"] + if not isinstance(metadata, dict): + _fail(plugin_id, "localizedMetadata", "must be an object") + for locale, localized in metadata.items(): + if not isinstance(locale, str) or not locale.strip(): + _fail(plugin_id, "localizedMetadata", "must use non-empty locale keys") + if not isinstance(localized, dict): + _fail(plugin_id, f"localizedMetadata.{locale}", "must be an object") + unexpected = sorted(set(localized) - {"displayName", "summary"}) + if unexpected: + _fail( + plugin_id, + f"localizedMetadata.{locale}", + "contains unsupported keys: " + ", ".join(unexpected), + ) + for field in ("displayName", "summary"): + if field in localized: + _non_empty_string( + localized[field], + plugin_id, + f"localizedMetadata.{locale}.{field}", + ) + return plugin_id + + +def _localizable_string( + reference: str, + manifest_path: Path | None, + plugin_id: str, + field: str, +) -> dict[str, str]: + if manifest_path is None: + _fail(plugin_id, field, f"{reference} requires a source manifest path") + catalog_path = manifest_path.parent / "Resources" / "Localizable.xcstrings" + try: + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + _fail(plugin_id, field, f"cannot read {catalog_path}: {error}") + key = reference.removeprefix(LOCALIZABLE_STRING_REFERENCE_PREFIX) + entry = catalog.get("strings", {}).get(key) + if not isinstance(entry, dict): + _fail(plugin_id, field, f"references missing Localizable.xcstrings key {key}") + localizations = entry.get("localizations") + if not isinstance(localizations, dict): + _fail(plugin_id, field, f"Localizable.xcstrings key {key} has no localizations") + localized_value = {} + for locale in SUPPORTED_LOCALE_ORDER: + localized = localizations.get(locale) + string_unit = localized.get("stringUnit") if isinstance(localized, dict) else None + value = string_unit.get("value") if isinstance(string_unit, dict) else None + if not isinstance(value, str) or not value.strip(): + _fail( + plugin_id, + field, + f"Localizable.xcstrings key {key} is missing locale {locale}", + ) + localized_value[locale] = value + return localized_value + + +def _standard_setup_string( + reference: str, + manifest: dict, + plugin_id: str, + field: str, +) -> dict[str, str]: + template_key = reference.removeprefix(STANDARD_SETUP_REFERENCE_PREFIX) + templates = STANDARD_SETUP_TEMPLATES.get(template_key) + if templates is None: + _fail(plugin_id, field, f"unknown standard setup string {template_key}") + + metadata = manifest.get("localizedMetadata") + if not isinstance(metadata, dict) or set(metadata) != SUPPORTED_LOCALE_SET: + _fail( + plugin_id, + field, + f"{reference} requires localizedMetadata for all supported locales", + ) + requirements = manifest.get("requirements") + if not isinstance(requirements, dict): + _fail(plugin_id, field, f"{reference} requires a requirements section") + + requirement_values: list[dict[str, str]] = [] + for permission_id in requirements.get("permissionIDs", []): + localized = LOCALIZED_REQUIREMENT_NAMES.get(permission_id) + if localized is None: + _fail(plugin_id, field, f"cannot localize permission requirement {permission_id}") + requirement_values.append(localized) + for hardware in requirements.get("hardware", []): + localized = LOCALIZED_REQUIREMENT_NAMES.get(hardware) + if localized is None: + _fail(plugin_id, field, f"cannot localize hardware requirement {hardware}") + requirement_values.append(localized) + for application in requirements.get("applications", []): + name = application.get("name") if isinstance(application, dict) else None + if not isinstance(name, str) or not name.strip(): + _fail(plugin_id, field, "cannot localize an unnamed application requirement") + requirement_values.append({locale: name for locale in SUPPORTED_LOCALE_ORDER}) + for executable in requirements.get("executables", []): + if not isinstance(executable, str) or not executable.strip(): + _fail(plugin_id, field, "cannot localize an unnamed executable requirement") + requirement_values.append({locale: executable for locale in SUPPORTED_LOCALE_ORDER}) + if not requirement_values: + _fail(plugin_id, field, f"{reference} requires at least one declared requirement") + + separators = {"ar": "، ", "ja": "、", "zh-Hans": "、", "zh-Hant": "、"} + localized_value = {} + for locale in SUPPORTED_LOCALE_ORDER: + locale_metadata = metadata.get(locale) + display_name = locale_metadata.get("displayName") if isinstance(locale_metadata, dict) else None + if not isinstance(display_name, str) or not display_name.strip(): + _fail(plugin_id, f"localizedMetadata.{locale}.displayName", "must be a non-empty string") + joined_requirements = separators.get(locale, ", ").join( + value[locale] for value in requirement_values + ) + localized_value[locale] = templates[locale].format( + displayName=display_name, + requirements=joined_requirements, + ) + return localized_value + + +def expand_localized_references( + manifest: dict, + manifest_path: Path | None = None, +) -> dict: + """Expand source-only product-string references for catalog and package projection.""" + projected = json.loads(json.dumps(manifest)) + localized_fields = list(_localized_source_fields(projected)) + product_strings = projected.get("productStrings") + plugin_id = projected.get("id", "unknown-plugin") + + if not localized_fields: + if product_strings is not None: + _fail(plugin_id, "productStrings", "is not allowed without localized product fields") + return projected + if not isinstance(product_strings, dict) or not product_strings: + _fail(plugin_id, "productStrings", "must be a non-empty object") + + metadata = projected.get("localizedMetadata") + reference_values = {} + used_product_strings = set() + for key, value in product_strings.items(): + _identifier(key, plugin_id, f"productStrings.{key}") + if isinstance(value, str) and value in BASE_LOCALIZED_REFERENCES: + field = value.removeprefix("@") + if not isinstance(metadata, dict) or set(metadata) != SUPPORTED_LOCALE_SET: + _fail( + plugin_id, + f"productStrings.{key}", + f"{value} requires localizedMetadata for all supported locales", + ) + localized_value = {} + for locale in SUPPORTED_LOCALE_ORDER: + locale_metadata = metadata.get(locale) + if not isinstance(locale_metadata, dict): + _fail(plugin_id, f"localizedMetadata.{locale}", "must be an object") + text = locale_metadata.get(field) + if not isinstance(text, str) or not text.strip(): + _fail( + plugin_id, + f"localizedMetadata.{locale}.{field}", + "must be a non-empty string", + ) + localized_value[locale] = text + reference_values[key] = localized_value + elif isinstance(value, str) and value.startswith(LOCALIZABLE_STRING_REFERENCE_PREFIX): + reference_values[key] = _localizable_string( + value, + manifest_path, + plugin_id, + f"productStrings.{key}", + ) + elif isinstance(value, str) and value.startswith(STANDARD_ACTION_REFERENCE_PREFIX): + template_key = value.removeprefix(STANDARD_ACTION_REFERENCE_PREFIX) + templates = STANDARD_ACTION_TEMPLATES.get(template_key) + if templates is None: + _fail(plugin_id, f"productStrings.{key}", f"unknown standard action string {template_key}") + if not isinstance(metadata, dict) or set(metadata) != SUPPORTED_LOCALE_SET: + _fail( + plugin_id, + f"productStrings.{key}", + f"{value} requires localizedMetadata for all supported locales", + ) + localized_value = {} + for locale in SUPPORTED_LOCALE_ORDER: + locale_metadata = metadata.get(locale) + if not isinstance(locale_metadata, dict): + _fail(plugin_id, f"localizedMetadata.{locale}", "must be an object") + display_name = locale_metadata.get("displayName") + if not isinstance(display_name, str) or not display_name.strip(): + _fail( + plugin_id, + f"localizedMetadata.{locale}.displayName", + "must be a non-empty string", + ) + localized_value[locale] = templates[locale].format(displayName=display_name) + reference_values[key] = localized_value + elif isinstance(value, str) and value.startswith(STANDARD_SETUP_REFERENCE_PREFIX): + reference_values[key] = _standard_setup_string( + value, + projected, + plugin_id, + f"productStrings.{key}", + ) + elif isinstance(value, dict): + _localized_text(value, plugin_id, f"productStrings.{key}") + reference_values[key] = dict(value) + else: + _fail( + plugin_id, + f"productStrings.{key}", + "must be @displayName, @summary, @localizable., @standardAction., " + "@standardSetup., or a complete locale-to-string object", + ) + + for field, value in localized_fields: + if not isinstance(value, str) or not value.startswith(PRODUCT_STRING_REFERENCE_PREFIX): + _fail( + plugin_id, + field, + f"must reference {PRODUCT_STRING_REFERENCE_PREFIX}; inline localized text is not allowed", + ) + key = value.removeprefix(PRODUCT_STRING_REFERENCE_PREFIX) + if key not in reference_values: + _fail(plugin_id, field, f"references missing productStrings entry {key}") + used_product_strings.add(key) + + unused_product_strings = sorted(set(reference_values) - used_product_strings) + if unused_product_strings: + _fail( + plugin_id, + "productStrings", + "contains unused entries: " + ", ".join(unused_product_strings), + ) + + def expand(value: object) -> object: + if isinstance(value, str) and value.startswith(PRODUCT_STRING_REFERENCE_PREFIX): + key = value.removeprefix(PRODUCT_STRING_REFERENCE_PREFIX) + return dict(reference_values[key]) + if isinstance(value, list): + return [expand(item) for item in value] + if isinstance(value, dict): + return {key: expand(item) for key, item in value.items()} + return value + + for section in ( + "presentation", "discovery", "requirements", "privacy", "actions", "setup", "relationships" + ): + if section in projected: + projected[section] = expand(projected[section]) + projected.pop("productStrings", None) + return projected + + +def _fail(plugin_id: str, field: str, message: str) -> None: + raise ManifestValidationError(f"{plugin_id}: {field}: {message}") + + +def _require_keys(value: dict, keys: set[str], plugin_id: str, field: str) -> None: + if not isinstance(value, dict): + _fail(plugin_id, field, "must be an object") + missing = sorted(keys - value.keys()) + if missing: + _fail(plugin_id, field, "missing " + ", ".join(missing)) + + +def _unique_strings(values: list, plugin_id: str, field: str) -> None: + if not isinstance(values, list): + _fail(plugin_id, field, "must be an array") + if not all(isinstance(value, str) and value.strip() for value in values): + _fail(plugin_id, field, "must contain non-empty strings") + if len(values) != len(set(values)): + _fail(plugin_id, field, "contains duplicate values") + + +def _non_empty_string(value: object, plugin_id: str, field: str) -> None: + if not isinstance(value, str) or not value.strip(): + _fail(plugin_id, field, "must be a non-empty string") + + +def _localized_text(value: object, plugin_id: str, field: str, require_all: bool = True) -> None: + if not isinstance(value, dict) or not value: + _fail(plugin_id, field, "must be a locale-to-string object") + unknown = sorted(set(value) - SUPPORTED_LOCALE_SET) + if unknown: + _fail(plugin_id, field, "uses unsupported locales: " + ", ".join(unknown)) + if require_all: + missing = sorted(SUPPORTED_LOCALE_SET - set(value)) + if missing: + _fail(plugin_id, field, "missing locale fallback values: " + ", ".join(missing)) + if not all(isinstance(text, str) and text.strip() for text in value.values()): + _fail(plugin_id, field, "contains an empty localized value") + + +def _identifier(value: object, plugin_id: str, field: str) -> None: + if not isinstance(value, str) or not IDENTIFIER_PATTERN.fullmatch(value): + _fail(plugin_id, field, "must be a stable identifier") + + +def _version(value: object, plugin_id: str, field: str) -> None: + if not isinstance(value, str) or not VERSION_PATTERN.fullmatch(value): + _fail(plugin_id, field, "must contain one to three numeric version components") + + +def _https_url(value: object, plugin_id: str, field: str) -> None: + if not isinstance(value, str) or any(character.isspace() for character in value): + _fail(plugin_id, field, "must be an HTTPS URL") + try: + parsed = urlparse(value) + hostname = parsed.hostname + parsed.port + except ValueError: + _fail(plugin_id, field, "must be an HTTPS URL") + if ( + parsed.scheme != "https" + or not parsed.netloc + or not hostname + or parsed.username is not None + or parsed.password is not None + ): + _fail(plugin_id, field, "must be an HTTPS URL") + try: + ipaddress.ip_address(hostname) + except ValueError: + if not DOMAIN_PATTERN.fullmatch(hostname): + _fail(plugin_id, field, "must be an HTTPS URL") + + +def validate_https_url(value: object, owner: str, field: str) -> None: + _https_url(value, owner, field) + + +def _image_dimensions(path: Path, media_type: str) -> tuple[int | None, int | None]: + data = path.read_bytes() + if media_type == "image/png" and len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n": + return struct.unpack(">II", data[16:24]) + if media_type == "image/jpeg": + index = 2 + while index + 9 < len(data): + if data[index] != 0xFF: + index += 1 + continue + marker = data[index + 1] + index += 2 + if marker in {0xD8, 0xD9}: + continue + if index + 2 > len(data): + break + length = int.from_bytes(data[index:index + 2], "big") + if marker in { + 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF, + }: + if index + 7 <= len(data): + return ( + int.from_bytes(data[index + 5:index + 7], "big"), + int.from_bytes(data[index + 3:index + 5], "big"), + ) + break + index += max(length, 2) + if media_type == "image/webp" and len(data) >= 20 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": + index = 12 + while index + 8 <= len(data): + chunk_type = data[index:index + 4] + chunk_size = int.from_bytes(data[index + 4:index + 8], "little") + payload = data[index + 8:index + 8 + chunk_size] + if len(payload) != chunk_size: + break + if chunk_type == b"VP8X" and len(payload) >= 10: + return ( + 1 + int.from_bytes(payload[4:7], "little"), + 1 + int.from_bytes(payload[7:10], "little"), + ) + if chunk_type == b"VP8L" and len(payload) >= 5 and payload[0] == 0x2F: + width = 1 + payload[1] + ((payload[2] & 0x3F) << 8) + height = 1 + (payload[2] >> 6) + (payload[3] << 2) + ((payload[4] & 0x0F) << 10) + return width, height + if chunk_type == b"VP8 " and len(payload) >= 10 and payload[3:6] == b"\x9d\x01\x2a": + return ( + int.from_bytes(payload[6:8], "little") & 0x3FFF, + int.from_bytes(payload[8:10], "little") & 0x3FFF, + ) + index += 8 + chunk_size + (chunk_size % 2) + return None, None + + +def _image_media_type(path: Path) -> str | None: + header = path.read_bytes()[:16] + if header.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if header.startswith(b"\xff\xd8\xff"): + return "image/jpeg" + if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP": + return "image/webp" + return None + + +def _validate_asset(asset: dict, plugin_root: Path, plugin_id: str, field: str) -> AssetProjection: + _require_keys(asset, {"id", "path", "alt"}, plugin_id, field) + _identifier(asset["id"], plugin_id, f"{field}.id") + _localized_text(asset["alt"], plugin_id, f"{field}.alt") + _non_empty_string(asset["path"], plugin_id, f"{field}.path") + relative = PurePosixPath(asset["path"]) + if relative.is_absolute() or ".." in relative.parts or relative.parts[:1] != ("MarketplaceAssets",): + _fail(plugin_id, f"{field}.path", "must stay under MarketplaceAssets/") + resolved_plugin_root = plugin_root.resolve() + asset_root = plugin_root.joinpath("MarketplaceAssets").resolve() + try: + asset_root.relative_to(resolved_plugin_root) + except ValueError: + _fail(plugin_id, f"{field}.path", "MarketplaceAssets must stay inside the plugin directory") + source = plugin_root.joinpath(*relative.parts) + if not source.is_file(): + _fail(plugin_id, f"{field}.path", f"asset does not exist: {relative}") + try: + resolved_source = source.resolve(strict=True) + resolved_source.relative_to(asset_root) + except ValueError: + _fail(plugin_id, f"{field}.path", "must resolve inside MarketplaceAssets/") + source = resolved_source + size = source.stat().st_size + if size <= 0 or size > MAX_ASSET_BYTES: + _fail(plugin_id, f"{field}.path", f"asset size must be 1...{MAX_ASSET_BYTES} bytes") + media_type = _image_media_type(source) + if media_type is None: + _fail(plugin_id, f"{field}.path", "must be PNG, JPEG, or WebP") + width, height = _image_dimensions(source, media_type) + if width is None or height is None: + _fail(plugin_id, f"{field}.path", "image dimensions could not be parsed") + if width <= 0 or height <= 0 or width > MAX_ASSET_DIMENSION or height > MAX_ASSET_DIMENSION: + _fail(plugin_id, f"{field}.path", f"dimensions must not exceed {MAX_ASSET_DIMENSION}px") + digest = hashlib.sha256(source.read_bytes()).hexdigest() + projected = dict(asset) + projected.update({"mediaType": media_type, "sha256": digest, "size": size}) + projected.update({"width": width, "height": height}) + return AssetProjection(source=source, catalog=projected) + + +def _validate_projected_asset(asset: dict, plugin_id: str, field: str) -> dict: + _require_keys(asset, {"id", "path", "alt"}, plugin_id, field) + _identifier(asset["id"], plugin_id, f"{field}.id") + _localized_text(asset["alt"], plugin_id, f"{field}.alt") + _non_empty_string(asset["path"], plugin_id, f"{field}.path") + relative = PurePosixPath(asset["path"]) + if relative.is_absolute() or ".." in relative.parts or relative.parts[:1] != ("MarketplaceAssets",): + _fail(plugin_id, f"{field}.path", "must stay under MarketplaceAssets/") + return json.loads(json.dumps(asset)) + + +def _validate_parameters(parameters: object, plugin_id: str, field: str) -> None: + if not isinstance(parameters, list): + _fail(plugin_id, field, "must be an array") + seen: set[str] = set() + for index, parameter in enumerate(parameters): + item_field = f"{field}[{index}]" + if not isinstance(parameter, dict): + _fail(plugin_id, item_field, "must be an object") + _require_keys(parameter, {"id", "kind", "isRequired", "portability"}, plugin_id, item_field) + _identifier(parameter["id"], plugin_id, f"{item_field}.id") + if parameter["id"] in seen: + _fail(plugin_id, f"{item_field}.id", "duplicates a parameter ID") + seen.add(parameter["id"]) + if parameter["kind"] not in VALID_PARAMETER_KINDS: + _fail(plugin_id, f"{item_field}.kind", "is not supported") + if parameter["portability"] not in VALID_PORTABILITY: + _fail(plugin_id, f"{item_field}.portability", "is not supported") + if not isinstance(parameter["isRequired"], bool): + _fail(plugin_id, f"{item_field}.isRequired", "must be a boolean") + + +def _validate_action_policy( + action: dict, + plugin_id: str, + field: str, + risk_varies_by_entry: bool = False, + automatic_eligibility_varies_by_entry: bool = False, +) -> None: + for key in ("permissionIDs", "surfaces", "keywords"): + _unique_strings(action[key], plugin_id, f"{field}.{key}") + invalid_permissions = sorted(set(action["permissionIDs"]) - VALID_PERMISSION_IDS) + if invalid_permissions: + _fail(plugin_id, f"{field}.permissionIDs", "unknown: " + ", ".join(invalid_permissions)) + invalid_surfaces = sorted(set(action["surfaces"]) - VALID_SURFACES) + if invalid_surfaces: + _fail(plugin_id, f"{field}.surfaces", "unknown: " + ", ".join(invalid_surfaces)) + if action["risk"] not in VALID_RISKS: + _fail(plugin_id, f"{field}.risk", "is not supported") + if action["externalInvocation"] not in VALID_EXTERNAL_POLICIES: + _fail(plugin_id, f"{field}.externalInvocation", "is not supported") + if not isinstance(action["automaticEligible"], bool): + _fail(plugin_id, f"{field}.automaticEligible", "must be a boolean") + can_be_safe = action["risk"] == "safe" or risk_varies_by_entry + can_be_automatic = ( + action["automaticEligible"] or automatic_eligibility_varies_by_entry + ) + if "automatic-rule" in action["surfaces"] and not ( + can_be_safe and can_be_automatic + ): + _fail(plugin_id, field, "automatic-rule requires a potentially safe automatic action") + if "app-intent" in action["surfaces"] and ( + not can_be_safe + or not can_be_automatic + or any(parameter["portability"] != "portable" for parameter in action["parameters"]) + or action.get("localOnlyIdentity") is True + ): + _fail(plugin_id, field, "app-intent requires a potentially safe, automatic, portable action") + has_run_link = "run-link" in action["surfaces"] + supports_run_link = action["externalInvocation"] != "unavailable" + if has_run_link != supports_run_link: + _fail(plugin_id, field, "run-link must match the external invocation policy") + + +def _validate_actions(actions: dict, plugin_id: str) -> None: + _require_keys(actions, {"providers"}, plugin_id, "actions") + if not isinstance(actions["providers"], list) or not actions["providers"]: + _fail(plugin_id, "actions.providers", "must be a non-empty array") + seen_keys: set[tuple[str, str]] = set() + seen_providers: set[str] = set() + for provider_index, provider in enumerate(actions["providers"]): + field = f"actions.providers[{provider_index}]" + _require_keys(provider, {"id", "kind", "staticActions", "dynamicTemplates"}, plugin_id, field) + _identifier(provider["id"], plugin_id, f"{field}.id") + if provider["id"] in seen_providers: + _fail(plugin_id, f"{field}.id", "duplicates a provider ID") + seen_providers.add(provider["id"]) + if provider["kind"] not in VALID_PROVIDER_KINDS: + _fail(plugin_id, f"{field}.kind", "is not supported") + static_actions = provider["staticActions"] + dynamic_templates = provider["dynamicTemplates"] + if not isinstance(static_actions, list) or not isinstance(dynamic_templates, list): + _fail(plugin_id, field, "action collections must be arrays") + if provider["kind"] == "static" and (not static_actions or dynamic_templates): + _fail(plugin_id, field, "static providers require only staticActions") + if provider["kind"] == "dynamic" and (static_actions or not dynamic_templates): + _fail(plugin_id, field, "dynamic providers require only dynamicTemplates") + if provider["kind"] == "mixed" and (not static_actions or not dynamic_templates): + _fail(plugin_id, field, "mixed providers require both action kinds") + for index, action in enumerate(static_actions): + action_field = f"{field}.staticActions[{index}]" + _require_keys(action, { + "id", "title", "description", "keywords", "systemImage", "parameters", + "permissionIDs", "risk", "surfaces", "automaticEligible", "externalInvocation" + }, plugin_id, action_field) + _identifier(action["id"], plugin_id, f"{action_field}.id") + key = (provider["id"], action["id"]) + if key in seen_keys: + _fail(plugin_id, f"{action_field}.id", "duplicates a static action key") + seen_keys.add(key) + _localized_text(action["title"], plugin_id, f"{action_field}.title") + _localized_text(action["description"], plugin_id, f"{action_field}.description") + _non_empty_string(action["systemImage"], plugin_id, f"{action_field}.systemImage") + if "parameterSummary" in action: + _localized_text(action["parameterSummary"], plugin_id, f"{action_field}.parameterSummary") + _validate_parameters(action["parameters"], plugin_id, f"{action_field}.parameters") + if not action["parameters"] and "parameterSummary" in action: + _fail( + plugin_id, + f"{action_field}.parameterSummary", + "is not allowed for an action without parameters", + ) + _validate_action_policy(action, plugin_id, action_field) + for index, template in enumerate(dynamic_templates): + template_field = f"{field}.dynamicTemplates[{index}]" + _require_keys(template, { + "id", "title", "description", "entrySource", "parameters", "parameterSummary", + "localOnlyIdentity", "permissionIDs", "risk", "surfaces", "automaticEligible", + "externalInvocation", "keywords" + }, plugin_id, template_field) + _identifier(template["id"], plugin_id, f"{template_field}.id") + key = (provider["id"], template["id"]) + if key in seen_keys: + _fail(plugin_id, f"{template_field}.id", "duplicates an action or template key") + seen_keys.add(key) + _localized_text(template["title"], plugin_id, f"{template_field}.title") + _localized_text(template["description"], plugin_id, f"{template_field}.description") + _localized_text(template["parameterSummary"], plugin_id, f"{template_field}.parameterSummary") + duplicate_summary_locales = [ + locale + for locale in SUPPORTED_LOCALE_ORDER + if template["parameterSummary"][locale] + in {template["title"][locale], template["description"][locale]} + ] + if duplicate_summary_locales: + _fail( + plugin_id, + f"{template_field}.parameterSummary", + "must describe the template parameters instead of repeating the title or " + "description; repeated locales: " + ", ".join(duplicate_summary_locales), + ) + _non_empty_string(template["entrySource"], plugin_id, f"{template_field}.entrySource") + if not isinstance(template["localOnlyIdentity"], bool): + _fail(plugin_id, f"{template_field}.localOnlyIdentity", "must be a boolean") + risk_varies_by_entry = template.get("riskVariesByEntry", False) + if not isinstance(risk_varies_by_entry, bool): + _fail(plugin_id, f"{template_field}.riskVariesByEntry", "must be a boolean") + automatic_eligibility_varies_by_entry = template.get( + "automaticEligibilityVariesByEntry", + False, + ) + if not isinstance(automatic_eligibility_varies_by_entry, bool): + _fail( + plugin_id, + f"{template_field}.automaticEligibilityVariesByEntry", + "must be a boolean", + ) + _validate_parameters(template["parameters"], plugin_id, f"{template_field}.parameters") + _validate_action_policy( + template, + plugin_id, + template_field, + risk_varies_by_entry=risk_varies_by_entry, + automatic_eligibility_varies_by_entry=( + automatic_eligibility_varies_by_entry + ), + ) + + +def _validate_manifest( + manifest: dict, + manifest_path: Path, + known_plugin_ids: set[str], + *, + allow_sparse_legacy: bool = False, + projected_assets: bool = False, + validate_plugin_references: bool = True, +) -> tuple[dict, list[AssetProjection]]: + manifest = json.loads(json.dumps(manifest)) + plugin_id = validate_runtime_envelope( + manifest, + manifest_path, + allow_sparse_legacy=allow_sparse_legacy, + ) + for section in ("presentation", "discovery", "requirements", "privacy", "actions", "setup", "relationships"): + if section in manifest and not isinstance(manifest[section], dict): + _fail(plugin_id, section, "must be an object") + assets: list[AssetProjection] = [] + presentation = manifest.get("presentation") + if presentation is not None: + _require_keys(presentation, { + "longDescription", "examples", "screenshots", "publisher", "license" + }, plugin_id, "presentation") + _localized_text(presentation["longDescription"], plugin_id, "presentation.longDescription") + for key in ("publisher", "license"): + _non_empty_string(presentation[key], plugin_id, f"presentation.{key}") + if not isinstance(presentation["examples"], list) or not isinstance(presentation["screenshots"], list): + _fail(plugin_id, "presentation", "examples and screenshots must be arrays") + if not all(isinstance(example, dict) for example in presentation["examples"]): + _fail(plugin_id, "presentation.examples", "must contain objects") + example_ids = [example.get("id", "") for example in presentation["examples"]] + _unique_strings(example_ids, plugin_id, "presentation.examples.id") + for index, example in enumerate(presentation["examples"]): + _identifier(example.get("id"), plugin_id, f"presentation.examples[{index}].id") + _localized_text(example.get("text"), plugin_id, f"presentation.examples[{index}].text") + seen_asset_ids: set[str] = set() + for index, asset in enumerate(presentation["screenshots"]): + asset_field = f"presentation.screenshots[{index}]" + if projected_assets: + projected_asset = _validate_projected_asset(asset, plugin_id, asset_field) + else: + source_asset = _validate_asset(asset, manifest_path.parent, plugin_id, asset_field) + assets.append(source_asset) + projected_asset = source_asset.catalog + if projected_asset["id"] in seen_asset_ids: + _fail(plugin_id, f"presentation.screenshots[{index}].id", "duplicates an asset ID") + seen_asset_ids.add(projected_asset["id"]) + for key in ("documentationURL", "supportURL"): + if key in presentation: + _https_url(presentation[key], plugin_id, f"presentation.{key}") + + discovery = manifest.get("discovery") + if discovery is not None: + _require_keys(discovery, { + "keywords", "localizedSynonyms", "useCases", "goalCategories", + "relatedPluginIDs", "alternativePluginIDs" + }, plugin_id, "discovery") + _unique_strings(discovery["keywords"], plugin_id, "discovery.keywords") + if not isinstance(discovery["localizedSynonyms"], dict): + _fail(plugin_id, "discovery.localizedSynonyms", "must be an object") + missing_locales = sorted(SUPPORTED_LOCALE_SET - set(discovery["localizedSynonyms"])) + if missing_locales: + _fail(plugin_id, "discovery.localizedSynonyms", "missing: " + ", ".join(missing_locales)) + for locale, synonyms in discovery["localizedSynonyms"].items(): + if locale not in SUPPORTED_LOCALE_SET: + _fail(plugin_id, "discovery.localizedSynonyms", f"unsupported locale {locale}") + _unique_strings(synonyms, plugin_id, f"discovery.localizedSynonyms.{locale}") + use_case_ids: list[str] = [] + if not isinstance(discovery["useCases"], list): + _fail(plugin_id, "discovery.useCases", "must be an array") + for index, use_case in enumerate(discovery["useCases"]): + _require_keys(use_case, {"id", "title"}, plugin_id, f"discovery.useCases[{index}]") + _identifier(use_case["id"], plugin_id, f"discovery.useCases[{index}].id") + use_case_ids.append(use_case["id"]) + _localized_text(use_case["title"], plugin_id, f"discovery.useCases[{index}].title") + _unique_strings(use_case_ids, plugin_id, "discovery.useCases.id") + for key in ("goalCategories", "relatedPluginIDs", "alternativePluginIDs"): + _unique_strings(discovery[key], plugin_id, f"discovery.{key}") + + requirements = manifest.get("requirements") + if requirements is not None: + _require_keys(requirements, { + "architectures", "hardware", "applications", "executables", "permissionIDs", + "setupComplexity", "requiresRelaunch" + }, plugin_id, "requirements") + for key in ("architectures", "hardware", "executables", "permissionIDs"): + _unique_strings(requirements[key], plugin_id, f"requirements.{key}") + invalid_architectures = sorted(set(requirements["architectures"]) - VALID_ARCHITECTURES) + if invalid_architectures: + _fail(plugin_id, "requirements.architectures", "unknown: " + ", ".join(invalid_architectures)) + invalid_permissions = sorted(set(requirements["permissionIDs"]) - VALID_PERMISSION_IDS) + if invalid_permissions: + _fail(plugin_id, "requirements.permissionIDs", "unknown: " + ", ".join(invalid_permissions)) + if requirements["setupComplexity"] not in VALID_SETUP_COMPLEXITIES: + _fail(plugin_id, "requirements.setupComplexity", "is not supported") + if not isinstance(requirements["requiresRelaunch"], bool): + _fail(plugin_id, "requirements.requiresRelaunch", "must be a boolean") + if "minimumMacOSVersion" in requirements: + _version( + requirements["minimumMacOSVersion"], + plugin_id, + "requirements.minimumMacOSVersion", + ) + applications = requirements["applications"] + if not isinstance(applications, list): + _fail(plugin_id, "requirements.applications", "must be an array") + application_ids = [] + for index, application in enumerate(applications): + field = f"requirements.applications[{index}]" + _require_keys(application, {"bundleID", "name"}, plugin_id, field) + for key in ("bundleID", "name"): + _non_empty_string(application[key], plugin_id, f"{field}.{key}") + application_ids.append(application["bundleID"]) + if len(application_ids) != len(set(application_ids)): + _fail(plugin_id, "requirements.applications", "contains duplicate bundle IDs") + + privacy = manifest.get("privacy") + if privacy is not None: + _require_keys(privacy, { + "dataObserved", "dataPersisted", "retention", "networkUse", "networkDomains", + "telemetry", "processesSensitiveUserContent", "diagnosticExportsContainUserData" + }, plugin_id, "privacy") + for key in ("dataObserved", "dataPersisted", "networkDomains"): + _unique_strings(privacy[key], plugin_id, f"privacy.{key}") + if privacy["networkUse"] not in VALID_NETWORK_USE: + _fail(plugin_id, "privacy.networkUse", "is not supported") + if privacy["telemetry"] not in VALID_TELEMETRY: + _fail(plugin_id, "privacy.telemetry", "is not supported") + for domain in privacy["networkDomains"]: + if not isinstance(domain, str) or not DOMAIN_PATTERN.fullmatch(domain): + _fail(plugin_id, "privacy.networkDomains", f"invalid domain: {domain}") + if "allowsUserConfiguredDomains" in privacy and not isinstance( + privacy["allowsUserConfiguredDomains"], bool + ): + _fail(plugin_id, "privacy.allowsUserConfiguredDomains", "must be a boolean") + for key in ("processesSensitiveUserContent", "diagnosticExportsContainUserData"): + if not isinstance(privacy[key], bool): + _fail(plugin_id, f"privacy.{key}", "must be a boolean") + retention = privacy["retention"] + if not isinstance(retention, dict) or retention.get("policy") not in VALID_RETENTION: + _fail(plugin_id, "privacy.retention.policy", "is not supported") + if retention.get("description") is not None: + _localized_text(retention["description"], plugin_id, "privacy.retention.description") + + if manifest.get("actions") is not None: + _validate_actions(manifest["actions"], plugin_id) + declared_permissions = set(manifest.get("permissions", [])) + requirement_permissions = set( + manifest.get("requirements", {}).get("permissionIDs", []) + ) + for provider_index, provider in enumerate(manifest["actions"]["providers"]): + for collection_name in ("staticActions", "dynamicTemplates"): + for action_index, action in enumerate(provider[collection_name]): + field = ( + f"actions.providers[{provider_index}].{collection_name}" + f"[{action_index}].permissionIDs" + ) + action_permissions = set(action["permissionIDs"]) + missing_top_level = sorted(action_permissions - declared_permissions) + if missing_top_level: + _fail( + plugin_id, + field, + "must also appear in top-level permissions: " + + ", ".join(missing_top_level), + ) + missing_requirements = sorted(action_permissions - requirement_permissions) + if missing_requirements: + _fail( + plugin_id, + field, + "must also appear in requirements.permissionIDs: " + + ", ".join(missing_requirements), + ) + + setup = manifest.get("setup") + if setup is not None: + _require_keys(setup, {"steps", "optionalSurfaces"}, plugin_id, "setup") + if not isinstance(setup["steps"], list): + _fail(plugin_id, "setup.steps", "must be an array") + if ( + requirements is not None + and requirements["setupComplexity"] in {"guided", "advanced"} + and not setup["steps"] + ): + _fail(plugin_id, "setup.steps", "guided and advanced setup requires at least one step") + for index, step in enumerate(setup["steps"]): + _require_keys(step, {"id", "title", "description"}, plugin_id, f"setup.steps[{index}]") + _identifier(step["id"], plugin_id, f"setup.steps[{index}].id") + _localized_text(step["title"], plugin_id, f"setup.steps[{index}].title") + _localized_text(step["description"], plugin_id, f"setup.steps[{index}].description") + localized_metadata = manifest.get("localizedMetadata", {}) + if all( + step["title"].get(locale) == localized_metadata.get(locale, {}).get("displayName") + and step["description"].get(locale) == localized_metadata.get(locale, {}).get("summary") + for locale in SUPPORTED_LOCALE_ORDER + ): + _fail( + plugin_id, + f"setup.steps[{index}]", + "must describe concrete setup requirements instead of repeating product metadata", + ) + test_action = setup.get("suggestedTestAction") + if test_action is not None: + _require_keys(test_action, {"providerID", "actionID"}, plugin_id, "setup.suggestedTestAction") + _identifier(test_action["providerID"], plugin_id, "setup.suggestedTestAction.providerID") + _identifier(test_action["actionID"], plugin_id, "setup.suggestedTestAction.actionID") + key = (test_action.get("providerID"), test_action.get("actionID")) + static_keys = { + (provider["id"], action["id"]) + for provider in manifest.get("actions", {}).get("providers", []) + for action in provider.get("staticActions", []) + } + if key not in static_keys: + _fail(plugin_id, "setup.suggestedTestAction", "must reference a declared static action") + _unique_strings(setup["optionalSurfaces"], plugin_id, "setup.optionalSurfaces") + invalid_surfaces = sorted(set(setup["optionalSurfaces"]) - VALID_SURFACES) + if invalid_surfaces: + _fail(plugin_id, "setup.optionalSurfaces", "unknown: " + ", ".join(invalid_surfaces)) + if setup.get("missingDependencyHelp") is not None: + _localized_text(setup["missingDependencyHelp"], plugin_id, "setup.missingDependencyHelp") + + relationships = manifest.get("relationships") + if relationships is not None: + _require_keys(relationships, { + "relatedPluginIDs", "includedPackIDs", "suggestedRecipeIDs", "supersedesPluginIDs" + }, plugin_id, "relationships") + for key in ("relatedPluginIDs", "includedPackIDs", "suggestedRecipeIDs", "supersedesPluginIDs"): + _unique_strings(relationships[key], plugin_id, f"relationships.{key}") + referenced = set(relationships["relatedPluginIDs"]) | set(relationships["supersedesPluginIDs"]) + if validate_plugin_references: + missing = sorted(referenced - known_plugin_ids) + if missing: + _fail(plugin_id, "relationships", "references unknown plugins: " + ", ".join(missing)) + + if discovery is not None: + referenced = set(discovery["relatedPluginIDs"]) | set(discovery["alternativePluginIDs"]) + if validate_plugin_references: + missing = sorted(referenced - known_plugin_ids) + if missing: + _fail(plugin_id, "discovery", "references unknown plugins: " + ", ".join(missing)) + + projected = json.loads(json.dumps(manifest)) + if presentation is not None: + if projected_assets: + projected["presentation"]["screenshots"] = [ + _validate_projected_asset( + asset, + plugin_id, + f"presentation.screenshots[{index}]", + ) + for index, asset in enumerate(presentation["screenshots"]) + ] + else: + projected["presentation"]["screenshots"] = [asset.catalog for asset in assets] + projected.pop("build", None) + projected.pop("package", None) + return projected, assets + + +def validate_and_project_manifest( + manifest: dict, + manifest_path: Path, + known_plugin_ids: set[str], + *, + allow_sparse_legacy: bool = False, +) -> tuple[dict, list[AssetProjection]]: + return _validate_manifest( + expand_localized_references(manifest, manifest_path), + manifest_path, + known_plugin_ids, + allow_sparse_legacy=allow_sparse_legacy, + ) + + +def validate_projected_manifest( + manifest: dict, + manifest_path: Path, + known_plugin_ids: set[str], + *, + allow_sparse_legacy: bool = False, + validate_plugin_references: bool = True, +) -> dict: + if "productStrings" in manifest: + _fail( + manifest.get("id", manifest_path.parent.name), + "productStrings", + "must be removed from a projected package manifest", + ) + projected, _ = _validate_manifest( + manifest, + manifest_path, + known_plugin_ids, + allow_sparse_legacy=allow_sparse_legacy, + projected_assets=True, + validate_plugin_references=validate_plugin_references, + ) + return projected + + +def load_known_plugin_ids(plugins_root: Path) -> set[str]: + if (plugins_root / "plugin.json").is_file(): + return { + json.loads((plugins_root / "plugin.json").read_text(encoding="utf-8"))["id"] + } + result = set() + for path in plugins_root.glob("*/plugin.json"): + plugin_id = json.loads(path.read_text(encoding="utf-8"))["id"] + if plugin_id in result: + raise ManifestValidationError( + f"{plugin_id}: id: duplicates another plugin manifest under {plugins_root}" + ) + result.add(plugin_id) + return result diff --git a/scripts/plugins/preflight-app-plugin-catalog.swift b/scripts/plugins/preflight-app-plugin-catalog.swift index 675884be..8ff0e8b9 100755 --- a/scripts/plugins/preflight-app-plugin-catalog.swift +++ b/scripts/plugins/preflight-app-plugin-catalog.swift @@ -10,6 +10,7 @@ private struct Options { var publicKeyBase64: String? var deployedCatalogPath: String? var requiredPluginKitVersion: Int? + var requiredSchemaVersion: Int? } private enum PreflightError: LocalizedError { @@ -24,17 +25,10 @@ private enum PreflightError: LocalizedError { private struct CatalogRequirement { let minimumAppVersion: String - let pluginKitVersion: Int let url: URL let expectedCatalogPath: String } -private let host12Requirement = CatalogRequirement( - minimumAppVersion: "1.2.0", - pluginKitVersion: 5, - url: URL(string: "https://mactools.ggbond.app/plugins/v5/catalog.json")!, - expectedCatalogPath: "docs/plugins/v5/catalog.json" -) private let productionCatalogID = "com.ggbond.mactools.plugins" private func fail(_ message: String) throws -> Never { @@ -61,6 +55,11 @@ private func parseOptions() throws -> Options { try fail("Invalid PluginKit version: \(value)") } options.requiredPluginKitVersion = version + case "--required-schema-version": + guard let version = Int(value), version > 0 else { + try fail("Invalid catalog schema version: \(value)") + } + options.requiredSchemaVersion = version default: try fail("Unknown option: \(option)") } } @@ -85,6 +84,48 @@ private func isVersion(_ value: String, atLeast minimum: String) throws -> Bool try versionComponents(value).lexicographicallyPrecedes(versionComponents(minimum)) == false } +private func sourcePluginKitVersion() throws -> Int { + let pluginRoot = URL(fileURLWithPath: "Plugins", isDirectory: true) + let directories = try FileManager.default.contentsOfDirectory( + at: pluginRoot, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + var versions = Set() + for directory in directories { + let manifestURL = directory.appendingPathComponent("plugin.json") + guard FileManager.default.fileExists(atPath: manifestURL.path) else { continue } + let data = try Data(contentsOf: manifestURL) + let object = try JSONSerialization.jsonObject(with: data) + guard let manifest = object as? [String: Any], + let version = manifest["pluginKitVersion"] as? Int, + version > 0 else { + try fail("Cannot determine PluginKit version from \(manifestURL.path).") + } + versions.insert(version) + } + guard versions.count == 1, let version = versions.first else { + try fail("Plugin manifests must declare one shared PluginKit version.") + } + return version +} + +private func catalogRequirement(pluginKitVersion: Int) -> CatalogRequirement { + let relativePath: String + if pluginKitVersion == 2 { + relativePath = "catalog.json" + } else if pluginKitVersion == 5 { + relativePath = "v5/schema3/catalog.json" + } else { + relativePath = "v\(pluginKitVersion)/catalog.json" + } + return CatalogRequirement( + minimumAppVersion: "1.2.1", + url: URL(string: "https://mactools.ggbond.app/plugins/\(relativePath)")!, + expectedCatalogPath: "docs/plugins/\(relativePath)" + ) +} + private func releasePublicKey() throws -> String { let path = "Configs/Release.xcconfig" let contents: String @@ -180,11 +221,13 @@ private func validateCatalog( targetAppVersion: String, expectedCatalogID: String, expectedPluginKitVersion: Int, + expectedSchemaVersion: Int, publicKeyBase64: String ) throws -> [String: Any] { var catalog = try catalogObject(from: data, label: label) - guard catalog["schemaVersion"] as? Int == 2 else { - try fail("\(label) must use catalog schema 2.") + guard let schemaVersion = catalog["schemaVersion"] as? Int, + schemaVersion == expectedSchemaVersion else { + try fail("\(label) must use catalog schema \(expectedSchemaVersion).") } guard catalog["pluginKitVersion"] as? Int == expectedPluginKitVersion else { try fail("\(label) does not target PluginKit \(expectedPluginKitVersion).") @@ -249,16 +292,20 @@ private func validateCatalog( do { let options = try parseOptions() let appVersion = options.appVersion! - guard try isVersion(appVersion, atLeast: host12Requirement.minimumAppVersion) else { - print("Plugin catalog preflight is not required for MacTools \(appVersion).") - exit(EXIT_SUCCESS) + let requiredPluginKitVersion = try options.requiredPluginKitVersion + ?? sourcePluginKitVersion() + let requirement = catalogRequirement(pluginKitVersion: requiredPluginKitVersion) + guard try isVersion(appVersion, atLeast: requirement.minimumAppVersion) else { + try fail( + "This source uses catalog schema 3 and cannot be released as MacTools \(appVersion). " + + "Raise the app version to \(requirement.minimumAppVersion) or later." + ) } - let expectedPath = options.expectedCatalogPath ?? host12Requirement.expectedCatalogPath - let catalogURL = options.catalogURL ?? host12Requirement.url + let expectedPath = options.expectedCatalogPath ?? requirement.expectedCatalogPath + let catalogURL = options.catalogURL ?? requirement.url let publicKey = try options.publicKeyBase64 ?? releasePublicKey() - let requiredPluginKitVersion = options.requiredPluginKitVersion - ?? host12Requirement.pluginKitVersion + let requiredSchemaVersion = options.requiredSchemaVersion ?? 3 let expectedData = try readCatalog(at: expectedPath) let deployedData = try options.deployedCatalogPath.map(readCatalog(at:)) ?? fetch(catalogURL) let expected = try validateCatalog( @@ -267,6 +314,7 @@ do { targetAppVersion: appVersion, expectedCatalogID: productionCatalogID, expectedPluginKitVersion: requiredPluginKitVersion, + expectedSchemaVersion: requiredSchemaVersion, publicKeyBase64: publicKey ) let deployed = try validateCatalog( @@ -275,6 +323,7 @@ do { targetAppVersion: appVersion, expectedCatalogID: productionCatalogID, expectedPluginKitVersion: requiredPluginKitVersion, + expectedSchemaVersion: requiredSchemaVersion, publicKeyBase64: publicKey ) guard try normalizedJSON(expected) == normalizedJSON(deployed) else { diff --git a/scripts/plugins/sync-debug-plugins.sh b/scripts/plugins/sync-debug-plugins.sh index 191998b9..732abebc 100755 --- a/scripts/plugins/sync-debug-plugins.sh +++ b/scripts/plugins/sync-debug-plugins.sh @@ -487,6 +487,7 @@ while IFS=$'\t' read -r plugin_root manifest plugin_id bundle_relative_path bund --source "$manifest" \ --destination "$package_path/plugin.json" \ --configuration Debug \ + --allow-sparse-legacy \ --app-version-config "$APP_VERSION_CONFIG" ditto "$bundle_path" "$package_path/$bundle_relative_path" printf '%s\n' "$fingerprint" > "$state_path" @@ -561,6 +562,8 @@ if [[ "$synced_count" -gt 0 || "$removed_count" -gt 0 || ! -f "$CATALOG_PATH" ]] "$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" \ --mode debug \ --output "$CATALOG_PATH" \ + --plugins-root "$SOURCE_DIR" \ + --allow-sparse-legacy \ "${catalog_args[@]}" fi diff --git a/scripts/release.py b/scripts/release.py index 13d18e0a..91807e35 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -416,15 +416,27 @@ def read_plugins() -> dict[str, PluginInfo]: def plugin_catalog_path(plugin_kit_version: int) -> Path: if plugin_kit_version == 2: return LEGACY_PLUGIN_CATALOG + if plugin_kit_version == 5: + return ROOT_DIR / "docs/plugins/v5/schema3/catalog.json" return ROOT_DIR / "docs" / "plugins" / f"v{plugin_kit_version}" / "catalog.json" +def same_abi_catalog_migration_baseline_path(plugin_kit_version: int) -> Path | None: + if plugin_kit_version == 5: + return ROOT_DIR / "docs/plugins/v5/catalog.json" + return None + + def previous_plugin_catalog_path() -> Path: plugin_kit_version = current_plugin_kit_version(read_plugins()) preferred_path = plugin_catalog_path(plugin_kit_version) if preferred_path.exists() or preferred_path == LEGACY_PLUGIN_CATALOG: return preferred_path + same_abi_baseline = same_abi_catalog_migration_baseline_path(plugin_kit_version) + if same_abi_baseline is not None and same_abi_baseline.exists(): + return same_abi_baseline + previous_versioned_catalogs = [] for candidate in (ROOT_DIR / "docs" / "plugins").glob("v*/catalog.json"): match = re.fullmatch(r"v(\d+)", candidate.parent.name) @@ -461,6 +473,13 @@ def current_plugin_kit_version(plugins: dict[str, PluginInfo]) -> int: return versions[0] +def ensure_plugin_kit_releasable(plugin_kit_version: int) -> None: + if plugin_kit_version < 5: + fail( + "PluginKit 5 之前的 catalog 已冻结,不能使用当前 schema-3 流程发布。" + ) + + def plugin_release_tag(entry: dict) -> str | None: package = entry.get("package") or {} candidates = [package.get("url") or "", entry.get("releaseNotesURL") or ""] @@ -1091,10 +1110,29 @@ def release_app(args: argparse.Namespace) -> None: def release_plugin(args: argparse.Namespace) -> None: mode, raw_selection = choose_plugin_mode(args.plugin_mode, args.plugin) plugins = read_plugins() - previous_catalog = read_previous_catalog() current_plugin_kit = current_plugin_kit_version(plugins) + ensure_plugin_kit_releasable(current_plugin_kit) + previous_catalog = read_previous_catalog() previous_plugin_kit = previous_catalog.get("pluginKitVersion") - if mode == "auto" and previous_plugin_kit is not None and previous_plugin_kit != current_plugin_kit: + is_compatibility_migration = ( + not plugin_catalog_path(current_plugin_kit).exists() + and same_abi_catalog_migration_baseline_path(current_plugin_kit) + == previous_plugin_catalog_path() + ) + if is_compatibility_migration: + if mode == "selected": + fail( + "检测到同一 PluginKit ABI 的 catalog schema 迁移。" + "plugin-mode selected 不能只发布部分插件;" + "请使用 plugin-mode all 全量重建并提升所有插件版本。" + ) + if mode == "auto": + mode = "all" + info( + "检测到同一 PluginKit ABI 的 catalog schema 迁移," + "插件发布模式自动切换为 all。" + ) + elif mode == "auto" and previous_plugin_kit is not None and previous_plugin_kit != current_plugin_kit: mode = "all" info( f"检测到 PluginKit ABI 升级({previous_plugin_kit} -> {current_plugin_kit})," diff --git a/scripts/tests/test_action_provider_coverage.py b/scripts/tests/test_action_provider_coverage.py index af400a6a..3db760ed 100644 --- a/scripts/tests/test_action_provider_coverage.py +++ b/scripts/tests/test_action_provider_coverage.py @@ -1,3 +1,4 @@ +import json import pathlib import re import unittest @@ -7,6 +8,9 @@ COVERAGE_DOCUMENT = REPO_ROOT / "docs" / "plugins" / "action-provider-coverage.md" PLUGINS_ROOT = REPO_ROOT / "Plugins" E2E_SCRIPT = REPO_ROOT / "scripts" / "e2e" / "mactools-e2e.sh" +RUNTIME_METADATA_TEST = ( + REPO_ROOT / "Tests/Core/Plugins/Dynamic/PluginRuntimeActionSnapshotTests.swift" +) class ActionProviderCoverageTests(unittest.TestCase): @@ -53,6 +57,24 @@ def test_every_documented_provider_is_in_the_e2e_registry_checkpoint(self): self.assertEqual(missing, []) + def test_every_documented_provider_is_in_the_manifest_runtime_consistency_test(self): + document = COVERAGE_DOCUMENT.read_text(encoding="utf-8") + providers = self.directory_names( + document, + "## Migrated providers", + "Parameterized actions publish", + ) + harness = RUNTIME_METADATA_TEST.read_text(encoding="utf-8") + missing = [] + for directory in sorted(providers): + manifest = json.loads( + (PLUGINS_ROOT / directory / "plugin.json").read_text(encoding="utf-8") + ) + if f'pluginID: "{manifest["id"]}"' not in harness: + missing.append(directory) + + self.assertEqual(missing, []) + @staticmethod def directory_names(document: str, start: str, end: str): section = document.split(start, 1)[1].split(end, 1)[0] diff --git a/scripts/tests/test_app_plugin_catalog_preflight.py b/scripts/tests/test_app_plugin_catalog_preflight.py index f6daf5e7..a0852b35 100644 --- a/scripts/tests/test_app_plugin_catalog_preflight.py +++ b/scripts/tests/test_app_plugin_catalog_preflight.py @@ -38,6 +38,8 @@ def run_preflight(self, *arguments: str) -> subprocess.CompletedProcess[str]: str(self.executable), "--required-plugin-kit-version", "4", + "--required-schema-version", + "2", *arguments, ], cwd=ROOT_DIR, @@ -46,16 +48,17 @@ def run_preflight(self, *arguments: str) -> subprocess.CompletedProcess[str]: capture_output=True, ) - def test_versions_before_1_2_do_not_require_the_new_catalog(self) -> None: - result = self.run_preflight("--app-version", "1.1.6") + def test_schema3_source_cannot_reuse_the_released_1_2_version(self) -> None: + result = self.run_preflight("--app-version", "1.2.0") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("not required", result.stdout) + self.assertNotEqual(result.returncode, 0) + self.assertIn("cannot be released as MacTools 1.2.0", result.stderr) + self.assertIn("Raise the app version to 1.2.1", result.stderr) def test_matching_committed_and_deployed_signed_catalog_passes(self) -> None: result = self.run_preflight( "--app-version", - "1.2.0", + "1.2.1", "--expected-catalog", str(SIGNED_CATALOG), "--deployed-catalog", @@ -67,16 +70,51 @@ def test_matching_committed_and_deployed_signed_catalog_passes(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("Verified signed PluginKit 4 catalog", result.stdout) - def test_mac_tools_1_2_defaults_to_plugin_kit5_catalog(self) -> None: + def test_schema3_hosts_derive_the_current_plugin_kit_compatibility_line(self) -> None: source = SCRIPT_PATH.read_text(encoding="utf-8") - self.assertIn('pluginKitVersion: 5', source) - self.assertIn('plugins/v5/catalog.json', source) + self.assertIn('sourcePluginKitVersion()', source) + self.assertIn('v5/schema3/catalog.json', source) + self.assertIn('requiredSchemaVersion ?? 3', source) + + def test_future_plugin_kit_defaults_to_its_own_catalog(self) -> None: + result = subprocess.run( + [ + str(self.executable), + "--required-plugin-kit-version", "6", + "--app-version", "1.3.0", + ], + cwd=ROOT_DIR, + check=False, + text=True, + capture_output=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("docs/plugins/v6/catalog.json", result.stderr) + + def test_default_preflight_rejects_schema2_on_the_schema3_line(self) -> None: + result = subprocess.run( + [ + str(self.executable), + "--required-plugin-kit-version", "4", + "--app-version", "1.2.1", + "--expected-catalog", str(SIGNED_CATALOG), + "--deployed-catalog", str(SIGNED_CATALOG), + ], + cwd=ROOT_DIR, + check=False, + text=True, + capture_output=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("must use catalog schema 3", result.stderr) def test_missing_catalog_fails_with_release_order_guidance(self) -> None: missing = Path(self.temporary_directory.name) / "missing.json" result = self.run_preflight( "--app-version", - "1.2.0", + "1.2.1", "--expected-catalog", str(missing), "--deployed-catalog", @@ -94,7 +132,7 @@ def test_invalid_signature_fails_closed(self) -> None: tampered.write_text(json.dumps(catalog), encoding="utf-8") result = self.run_preflight( "--app-version", - "1.2.0", + "1.2.1", "--expected-catalog", str(tampered), "--deployed-catalog", @@ -111,7 +149,7 @@ def test_wrong_catalog_id_fails_closed(self) -> None: invalid.write_text(json.dumps(catalog), encoding="utf-8") result = self.run_preflight( "--app-version", - "1.2.0", + "1.2.1", "--expected-catalog", str(invalid), "--deployed-catalog", @@ -128,7 +166,7 @@ def test_malformed_minimum_host_version_fails_closed(self) -> None: invalid.write_text(json.dumps(catalog), encoding="utf-8") result = self.run_preflight( "--app-version", - "1.2.0", + "1.2.1", "--expected-catalog", str(invalid), "--deployed-catalog", @@ -145,7 +183,7 @@ def test_catalog_requiring_newer_host_fails_closed(self) -> None: invalid.write_text(json.dumps(catalog), encoding="utf-8") result = self.run_preflight( "--app-version", - "1.2.0", + "1.2.1", "--expected-catalog", str(invalid), "--deployed-catalog", @@ -154,7 +192,7 @@ def test_catalog_requiring_newer_host_fails_closed(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("requires MacTools 1.3.0", result.stderr) - self.assertIn("target app 1.2.0", result.stderr) + self.assertIn("target app 1.2.1", result.stderr) if __name__ == "__main__": diff --git a/scripts/tests/test_copy_plugin_manifest.py b/scripts/tests/test_copy_plugin_manifest.py index 1229b94b..8d42f2e4 100644 --- a/scripts/tests/test_copy_plugin_manifest.py +++ b/scripts/tests/test_copy_plugin_manifest.py @@ -1,4 +1,5 @@ import json +import os import pathlib import subprocess import sys @@ -10,6 +11,29 @@ SCRIPT = REPO_ROOT / "scripts/plugins/copy-plugin-manifest.py" SYNC_SCRIPT = REPO_ROOT / "scripts/plugins/sync-debug-plugins.sh" APP_VERSION_CONFIG = REPO_ROOT / "Configs/AppVersion.xcconfig" +SUPPORTED_LOCALES = ( + "ar", "de", "en", "es", "fr", "ja", "ko", "pt", "ru", "zh-Hans", "zh-Hant" +) + + +def runtime_envelope(**overrides: object) -> dict[str, object]: + manifest: dict[str, object] = { + "id": "example", + "displayName": "Example", + "version": "1.0.0", + "minHostVersion": "2.0", + "pluginKitVersion": 5, + "bundleRelativePath": "Example.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "settings": "none", + }, + "permissions": [], + "category": "other", + } + manifest.update(overrides) + return manifest class CopyPluginManifestTests(unittest.TestCase): @@ -20,7 +44,7 @@ def test_debug_copy_uses_local_host_version(self) -> None: destination = root / "copied.json" config = root / "AppVersion.xcconfig" source.write_text( - json.dumps({"id": "example", "minHostVersion": "99.0"}), + json.dumps(runtime_envelope(minHostVersion="99.0")), encoding="utf-8", ) config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") @@ -41,13 +65,45 @@ def test_debug_copy_uses_local_host_version(self) -> None: "1.2.3", ) - def test_release_copy_preserves_manifest_bytes(self) -> None: + def test_release_copy_projects_out_build_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + source = root / "plugin.json" + destination = root / "copied.json" + config = root / "AppVersion.xcconfig" + original = json.dumps(runtime_envelope( + build={"project": "../../MacTools.xcodeproj", "scheme": "Example"}, + package={"signPaths": ["Example.bundle/Contents/Resources/helper"]}, + presentation={"publisher": "Example"}, + )).encode() + b"\n" + source.write_bytes(original) + config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") + + subprocess.run( + [ + sys.executable, str(SCRIPT), "copy", + "--source", str(source), + "--destination", str(destination), + "--configuration", "Release", + "--app-version-config", str(config), + ], + check=True, + ) + + projected = json.loads(destination.read_text(encoding="utf-8")) + self.assertNotIn("build", projected) + self.assertEqual(projected["package"], { + "signPaths": ["Example.bundle/Contents/Resources/helper"], + }) + self.assertEqual(projected["presentation"], {"publisher": "Example"}) + + def test_release_copy_preserves_valid_manifest_bytes_without_build_metadata(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: root = pathlib.Path(temporary_directory) source = root / "plugin.json" destination = root / "copied.json" config = root / "AppVersion.xcconfig" - original = b'{ "id": "example", "minHostVersion": "2.0" }\n' + original = (json.dumps(runtime_envelope(), separators=(",", ":")) + "\n").encode() source.write_bytes(original) config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") @@ -64,6 +120,170 @@ def test_release_copy_preserves_manifest_bytes(self) -> None: self.assertEqual(destination.read_bytes(), original) + def test_release_copy_expands_source_localization_references(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + source = root / "plugin.json" + destination = root / "copied.json" + config = root / "AppVersion.xcconfig" + localized_metadata = { + locale: {"displayName": "Example", "summary": "Example summary"} + for locale in SUPPORTED_LOCALES + } + source.write_text(json.dumps(runtime_envelope( + displayName="示例", + summary="示例摘要", + localizedMetadata=localized_metadata, + productStrings={"summary": "@summary"}, + presentation={"longDescription": "@productStrings.summary"}, + )), encoding="utf-8") + config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") + + subprocess.run( + [ + sys.executable, str(SCRIPT), "copy", + "--source", str(source), + "--destination", str(destination), + "--configuration", "Release", + "--app-version-config", str(config), + ], + check=True, + ) + + projected = json.loads(destination.read_text(encoding="utf-8")) + description = projected["presentation"]["longDescription"] + self.assertEqual(description["en"], "Example summary") + self.assertEqual(description["ar"], "Example summary") + self.assertNotIn("productStrings", projected) + + def test_release_copy_is_stable_across_python_hash_seeds(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + source = root / "plugin.json" + config = root / "AppVersion.xcconfig" + localized_metadata = { + locale: {"displayName": "Example", "summary": "Example summary"} + for locale in SUPPORTED_LOCALES + } + source.write_text(json.dumps(runtime_envelope( + displayName="示例", + summary="示例摘要", + localizedMetadata=localized_metadata, + productStrings={"summary": "@summary"}, + presentation={"longDescription": "@productStrings.summary"}, + )), encoding="utf-8") + config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") + outputs = [] + for seed in ("1", "2"): + destination = root / f"copied-{seed}.json" + subprocess.run( + [ + sys.executable, str(SCRIPT), "copy", + "--source", str(source), + "--destination", str(destination), + "--configuration", "Release", + "--app-version-config", str(config), + ], + check=True, + env={**os.environ, "PYTHONHASHSEED": seed}, + ) + outputs.append(destination.read_bytes()) + + self.assertEqual(outputs[0], outputs[1]) + + def test_copy_rejects_invalid_runtime_envelope(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + config = root / "AppVersion.xcconfig" + config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") + mutations = { + "reserved-id": {"id": "marketplace"}, + "empty-display-name": {"displayName": ""}, + "string-plugin-kit": {"pluginKitVersion": "5"}, + "traversal-path": {"bundleRelativePath": "../Bad.bundle"}, + "array-capabilities": {"capabilities": []}, + "numeric-summary": {"summary": 4}, + "array-localized-metadata": {"localizedMetadata": []}, + "numeric-release-channel": {"releaseChannel": 4}, + "invalid-release-notes-url": {"releaseNotesURL": "not-a-url"}, + "release-notes-host-whitespace": { + "releaseNotesURL": "https://bad host/path" + }, + "release-notes-invalid-port": { + "releaseNotesURL": "https://example.com:abc/x" + }, + } + for name, overrides in mutations.items(): + source = root / f"{name}.json" + destination = root / f"{name}-copied.json" + source.write_text(json.dumps(runtime_envelope(**overrides)), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "copy", + "--source", + str(source), + "--destination", + str(destination), + "--configuration", + "Release", + "--app-version-config", + str(config), + ], + capture_output=True, + text=True, + ) + with self.subTest(mutation=name): + self.assertNotEqual(result.returncode, 0) + self.assertFalse(destination.exists()) + + def test_debug_copy_accepts_runtime_decodable_v3_and_v4_envelopes(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + config = root / "AppVersion.xcconfig" + config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8") + fixtures = ( + (3, {"primaryPanel": True, "configuration": True}), + (4, {"componentPanel": True, "settings": "form"}), + ) + for plugin_kit_version, capabilities in fixtures: + source = root / f"v{plugin_kit_version}.json" + destination = root / f"v{plugin_kit_version}-copied.json" + source.write_text( + json.dumps({ + "id": f"legacy-v{plugin_kit_version}", + "displayName": "Legacy", + "version": "1.0.0", + "minHostVersion": "1.0.0", + "pluginKitVersion": plugin_kit_version, + "bundleRelativePath": "Legacy.bundle", + "capabilities": capabilities, + "permissions": [], + }), + encoding="utf-8", + ) + + subprocess.run( + [ + sys.executable, + str(SCRIPT), + "copy", + "--source", str(source), + "--destination", str(destination), + "--configuration", "Debug", + "--allow-sparse-legacy", + "--app-version-config", str(config), + ], + check=True, + ) + + with self.subTest(pluginKitVersion=plugin_kit_version): + copied = json.loads(destination.read_text(encoding="utf-8")) + self.assertEqual(copied["capabilities"], capabilities) + self.assertEqual(copied["permissions"], []) + self.assertNotIn("category", copied) + def test_debug_sync_normalizes_and_caches_packaged_manifest(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: root = pathlib.Path(temporary_directory) @@ -84,6 +304,12 @@ def test_debug_sync_normalizes_and_caches_packaged_manifest(self) -> None: "minHostVersion": "99.0.0", "pluginKitVersion": 3, "bundleRelativePath": "Example.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "configuration": False, + }, + "permissions": [], } ), encoding="utf-8", @@ -159,6 +385,12 @@ def test_debug_sync_repairs_stale_installed_package(self) -> None: "minHostVersion": "99.0.0", "pluginKitVersion": 3, "bundleRelativePath": "Example.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "configuration": False, + }, + "permissions": [], } ), encoding="utf-8", @@ -229,6 +461,12 @@ def test_full_debug_sync_quarantines_package_missing_from_checkout(self) -> None "minHostVersion": "99.0.0", "pluginKitVersion": 3, "bundleRelativePath": "Example.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "configuration": False, + }, + "permissions": [], } ), encoding="utf-8", @@ -276,6 +514,12 @@ def test_full_debug_sync_removes_stale_output_and_regenerates_catalog(self) -> N "minHostVersion": "1.0.0", "pluginKitVersion": 3, "bundleRelativePath": f"{name}.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "configuration": False, + }, + "permissions": [], } ), encoding="utf-8", @@ -339,6 +583,12 @@ def test_partial_debug_sync_preserves_unrelated_installed_package(self) -> None: "minHostVersion": "99.0.0", "pluginKitVersion": 3, "bundleRelativePath": "Example.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "configuration": False, + }, + "permissions": [], } ), encoding="utf-8", diff --git a/scripts/tests/test_generate_plugin_catalog.py b/scripts/tests/test_generate_plugin_catalog.py new file mode 100644 index 00000000..e03abbf5 --- /dev/null +++ b/scripts/tests/test_generate_plugin_catalog.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import pathlib +import stat +import subprocess +import sys +import tempfile +import unittest +import unicodedata +import zipfile +from unittest import mock + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +PLUGINS_ROOT = REPO_ROOT / "Plugins" +SCRIPTS_ROOT = REPO_ROOT / "scripts" / "plugins" +GENERATOR = SCRIPTS_ROOT / "generate-plugin-catalog.py" +COPY_MANIFEST = SCRIPTS_ROOT / "copy-plugin-manifest.py" +sys.path.insert(0, str(SCRIPTS_ROOT)) + +SPEC = importlib.util.spec_from_file_location("generate_plugin_catalog", GENERATOR) +assert SPEC is not None and SPEC.loader is not None +generate_plugin_catalog = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(generate_plugin_catalog) + + +def projected_manifest(**overrides: object) -> dict[str, object]: + manifest: dict[str, object] = { + "id": "com.example.mactools.demo", + "displayName": "Demo", + "summary": "Demo plugin", + "version": "1.0.0", + "minHostVersion": "1.2.1", + "pluginKitVersion": 5, + "bundleRelativePath": "Demo.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "settings": "none", + }, + "permissions": [], + "category": "other", + } + manifest.update(overrides) + return manifest + + +class GeneratePluginCatalogTests(unittest.TestCase): + def make_package( + self, + root: pathlib.Path, + manifest: dict[str, object] | None = None, + name: str = "Demo.mactoolsplugin", + ) -> pathlib.Path: + package = root / name + package.mkdir() + package.joinpath("plugin.json").write_text( + json.dumps(manifest or projected_manifest()), + encoding="utf-8", + ) + return package + + def run_generator( + self, + root: pathlib.Path, + package: pathlib.Path, + *arguments: str, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(GENERATOR), + "--mode", "debug", + "--output", str(root / "catalog.json"), + "--package", str(package), + "--plugins-root", str(root / "Sources"), + *arguments, + ], + check=False, + capture_output=True, + text=True, + ) + + def test_existing_projected_package_is_authoritative_without_source(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + package = self.make_package( + root, + projected_manifest(releaseChannel="beta"), + ) + + result = self.run_generator(root, package) + + self.assertEqual(result.returncode, 0, result.stderr) + entry = json.loads((root / "catalog.json").read_text(encoding="utf-8"))["plugins"][0] + self.assertEqual(entry["releaseChannel"], "beta") + + def test_source_and_package_metadata_must_match(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + package = root / "Appearance.mactoolsplugin" + package.mkdir() + source = PLUGINS_ROOT / "Appearance" / "plugin.json" + subprocess.run( + [ + sys.executable, + str(COPY_MANIFEST), + "copy", + "--source", str(source), + "--destination", str(package / "plugin.json"), + "--configuration", "Release", + "--app-version-config", str(REPO_ROOT / "Configs/AppVersion.xcconfig"), + ], + check=True, + ) + packaged = json.loads((package / "plugin.json").read_text(encoding="utf-8")) + packaged["releaseChannel"] = "mismatched" + (package / "plugin.json").write_text(json.dumps(packaged), encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + str(GENERATOR), + "--mode", "debug", + "--output", str(root / "catalog.json"), + "--package", str(package), + "--plugins-root", str(PLUGINS_ROOT), + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match its source manifest: releaseChannel", result.stderr) + + def test_duplicate_package_plugin_ids_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + first = self.make_package(root, name="First.mactoolsplugin") + second = self.make_package(root, name="Second.mactoolsplugin") + + result = subprocess.run( + [ + sys.executable, + str(GENERATOR), + "--mode", "debug", + "--output", str(root / "catalog.json"), + "--package", str(first), + "--package", str(second), + "--plugins-root", str(root / "Sources"), + ], + check=False, + capture_output=True, + text=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Duplicate package plugin ID", result.stderr) + + def test_invalid_catalog_arguments_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + package = self.make_package(root) + cases = { + "blank catalog ID": ("--catalog-id", " "), + "invalid generated timestamp": ("--generated-at", "not-a-date"), + "insecure release notes URL": ("--release-notes-url", "http://example.com/notes"), + "insecure base URL": ("--base-url", "http://example.com/plugins"), + } + for name, arguments in cases.items(): + with self.subTest(name=name): + result = self.run_generator(root, package, *arguments) + self.assertNotEqual(result.returncode, 0) + + def test_directory_metrics_skip_symlinked_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + root.joinpath("payload").write_bytes(b"included") + root.joinpath("linked").symlink_to(root / "payload") + + digest, size = generate_plugin_catalog.directory_metrics(root) + + expected = hashlib.sha256(b"payload\0included\0").hexdigest() + self.assertEqual((digest, size), (expected, len(b"included"))) + + @unittest.skipUnless(hasattr(stat, "UF_HIDDEN"), "requires macOS hidden file flags") + def test_directory_metrics_skip_finder_hidden_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + hidden = root / "payload" + hidden.write_bytes(b"hidden") + os.chflags(hidden, stat.UF_HIDDEN) + + digest, size = generate_plugin_catalog.directory_metrics(root) + + self.assertEqual(digest, hashlib.sha256().hexdigest()) + self.assertEqual(size, 0) + + def test_zip_preflight_rejects_traversal_and_unsupported_members(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + for name, member_name, attributes in ( + ("traversal", "../escape", None), + ("fifo", "Demo.mactoolsplugin/fifo", (stat.S_IFIFO | 0o644) << 16), + ): + with self.subTest(name=name): + archive_path = root / f"{name}.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr( + "Demo.mactoolsplugin/plugin.json", + json.dumps(projected_manifest()), + ) + if attributes is None: + archive.writestr(member_name, b"bad") + else: + info = zipfile.ZipInfo(member_name) + info.create_system = 3 + info.external_attr = attributes + archive.writestr(info, b"bad") + with self.assertRaises(SystemExit): + generate_plugin_catalog.packaged_manifest(archive_path) + + def test_zip_preflight_bounds_expansion_and_symlink_targets(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + archive_path = root / "package.zip" + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "Demo.mactoolsplugin/plugin.json", + json.dumps(projected_manifest()), + ) + archive.writestr("Demo.mactoolsplugin/payload", b"x" * 1024) + with mock.patch.object(generate_plugin_catalog, "MAX_ARCHIVE_EXPANDED_BYTES", 512): + with self.assertRaisesRegex(SystemExit, "expands beyond"): + generate_plugin_catalog.packaged_manifest(archive_path) + + for target, accepted in (("Demo.bundle/file", True), ("../../escape", False)): + symlink_archive = root / ("safe.zip" if accepted else "escape.zip") + with zipfile.ZipFile(symlink_archive, "w") as archive: + archive.writestr( + "Demo.mactoolsplugin/plugin.json", + json.dumps(projected_manifest()), + ) + info = zipfile.ZipInfo("Demo.mactoolsplugin/link") + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(info, target) + if accepted: + manifest, _ = generate_plugin_catalog.packaged_manifest(symlink_archive) + self.assertEqual(manifest["id"], "com.example.mactools.demo") + else: + with self.assertRaisesRegex(SystemExit, "escaping symlink"): + generate_plugin_catalog.packaged_manifest(symlink_archive) + + def test_zip_preflight_rejects_crc_corruption(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + archive_path = pathlib.Path(temporary_directory) / "corrupt.zip" + payload_name = "Demo.mactoolsplugin/Demo.bundle/payload" + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_STORED) as archive: + archive.writestr( + "Demo.mactoolsplugin/plugin.json", + json.dumps(projected_manifest()), + ) + archive.writestr(payload_name, b"original") + with zipfile.ZipFile(archive_path) as archive: + info = archive.getinfo(payload_name) + payload_offset = ( + info.header_offset + + 30 + + len(info.filename.encode("utf-8")) + + len(info.extra) + ) + data = bytearray(archive_path.read_bytes()) + data[payload_offset] ^= 1 + archive_path.write_bytes(data) + + with self.assertRaisesRegex(SystemExit, "unreadable data"): + generate_plugin_catalog.packaged_manifest(archive_path) + + def test_zip_preflight_normalizes_unicode_duplicate_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + archive_path = pathlib.Path(temporary_directory) / "unicode.zip" + nfc_name = "café" + nfd_name = unicodedata.normalize("NFD", nfc_name) + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr( + "Demo.mactoolsplugin/plugin.json", + json.dumps(projected_manifest()), + ) + archive.writestr(f"Demo.mactoolsplugin/{nfc_name}", b"first") + archive.writestr(f"Demo.mactoolsplugin/{nfd_name}", b"second") + + with self.assertRaisesRegex(SystemExit, "duplicate archive path"): + generate_plugin_catalog.packaged_manifest(archive_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_merge_plugin_catalog.py b/scripts/tests/test_merge_plugin_catalog.py index 24d6b128..def87629 100644 --- a/scripts/tests/test_merge_plugin_catalog.py +++ b/scripts/tests/test_merge_plugin_catalog.py @@ -114,6 +114,52 @@ def test_host_specific_catalog_can_raise_aggregate_host_floor(self) -> None: merged = json.loads(output.read_text(encoding="utf-8")) self.assertEqual(merged["minimumHostVersion"], "1.2.0") + def test_schema3_merge_cannot_inherit_a_released_host_floor(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + previous = root / "previous.json" + updates = root / "updates.json" + plan = root / "plan.json" + output = root / "merged.json" + previous.write_text(json.dumps({ + "schemaVersion": 2, + "catalogID": "test.catalog", + "minimumHostVersion": "1.2.0", + "pluginKitVersion": 5, + "plugins": [], + }), encoding="utf-8") + updates.write_text(json.dumps({ + "schemaVersion": 3, + "catalogID": "test.catalog", + "minimumHostVersion": "1.2.1", + "pluginKitVersion": 5, + "plugins": [{ + "id": "modern", + "version": "1.0.0", + "pluginKitVersion": 5, + "minimumHostVersion": "1.2.0", + }], + }), encoding="utf-8") + plan.write_text(json.dumps({ + "selectedPluginIDs": ["modern"], + "removedPluginIDs": [], + "fullRelease": False, + }), encoding="utf-8") + + subprocess.run([ + sys.executable, + str(SCRIPT), + "--previous", str(previous), + "--updates", str(updates), + "--plan", str(plan), + "--output", str(output), + "--plugin-kit-version", "5", + ], check=True, capture_output=True, text=True) + + merged = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(merged["schemaVersion"], 3) + self.assertEqual(merged["minimumHostVersion"], "1.2.1") + if __name__ == "__main__": unittest.main() diff --git a/scripts/tests/test_plugin_minimum_host_compatibility.py b/scripts/tests/test_plugin_minimum_host_compatibility.py index 634d337e..20f263d4 100644 --- a/scripts/tests/test_plugin_minimum_host_compatibility.py +++ b/scripts/tests/test_plugin_minimum_host_compatibility.py @@ -14,6 +14,7 @@ MAKEFILE = REPO_ROOT / "Makefile" ACTION_MODELS = REPO_ROOT / "Sources/MacToolsPluginKit/ActionModels.swift" COMPONENT_THEME_MODELS = REPO_ROOT / "Sources/MacToolsPluginKit/PluginComponentTheme.swift" +PLUGIN_MODELS = REPO_ROOT / "Sources/MacToolsPluginKit/PluginModels.swift" APP_VERSION_CONFIG = REPO_ROOT / "Configs/AppVersion.xcconfig" NEW_API_MINIMUM_HOSTS = { # Canonical action registry, execution, discovery, and surface bridges. @@ -74,6 +75,8 @@ "PluginComponentTheme": "1.2.0", "PluginComponentCardBackground": "1.2.0", "PluginActionSafetyStateChangeProviding": "1.2.0", + # Finder-extension permission presentation introduced after host 1.2.0. + ".finderExtension": "1.2.1", } @@ -125,18 +128,34 @@ def test_legacy_v4_catalog_remains_compatible_with_shipped_1_1_6_verifier(self) ] self.assertEqual(incompatible, []) - def test_plugin_kit5_release_targets_versioned_host_compatible_catalog(self) -> None: + def test_plugin_kit5_schema3_release_targets_a_new_compatibility_catalog(self) -> None: workflow = PLUGIN_RELEASE_WORKFLOW.read_text(encoding="utf-8") makefile = MAKEFILE.read_text(encoding="utf-8") self.assertIn( - 'PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v5/catalog.json"', + 'PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v5/schema3/catalog.json"', workflow, ) - self.assertIn('PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.0"', workflow) + self.assertIn('PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.1"', workflow) self.assertIn( - "PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= $(if $(filter 5,$(PLUGIN_KIT_VERSION)),1.2.0,1.1.6)", + "PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= 1.2.1", makefile, ) + released_catalog = json.loads( + (REPO_ROOT / "docs/plugins/v5/catalog.json").read_text(encoding="utf-8") + ) + self.assertEqual(released_catalog["schemaVersion"], 2) + + def test_schema3_release_floor_applies_to_future_plugin_kit_versions(self) -> None: + workflow = PLUGIN_RELEASE_WORKFLOW.read_text(encoding="utf-8") + makefile = MAKEFILE.read_text(encoding="utf-8") + self.assertIn( + 'PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v${PLUGIN_KIT_VERSION}/catalog.json"', + workflow, + ) + self.assertGreaterEqual(workflow.count('PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.1"'), 2) + self.assertIn("PluginKit versions below 5 use immutable legacy catalogs", workflow) + self.assertIn("if (( PLUGIN_KIT_VERSION < 5 )); then", workflow) + self.assertIn('case " 1 2 3 4 " in', makefile) def test_every_current_plugin_targets_plugin_kit5_and_a_released_host_line(self) -> None: incompatible = [] @@ -173,6 +192,27 @@ def test_new_plugin_kit_api_consumers_require_compatible_host(self) -> None: self.assertEqual(violations, [], "\n".join(violations)) + def test_plugin_permission_kind_preserves_released_case_order(self) -> None: + source = PLUGIN_MODELS.read_text(encoding="utf-8") + match = re.search( + r"public enum PluginPermissionKind\s*\{(?P.*?)\n\}", + source, + flags=re.DOTALL, + ) + self.assertIsNotNone(match) + cases = re.findall(r"^\s*case\s+(\w+)", match.group("body"), flags=re.MULTILINE) + self.assertEqual( + cases[:6], + [ + "accessibility", + "inputMonitoring", + "calendarFullAccess", + "automation", + "screenRecording", + "finderExtension", + ], + ) + def test_action_model_inventory_covers_every_public_type_used_by_plugins(self) -> None: action_model_symbols = public_top_level_type_names( ACTION_MODELS.read_text(encoding="utf-8") diff --git a/scripts/tests/test_plugin_release_planning.py b/scripts/tests/test_plugin_release_planning.py index 38c67859..875f9b9f 100644 --- a/scripts/tests/test_plugin_release_planning.py +++ b/scripts/tests/test_plugin_release_planning.py @@ -29,12 +29,82 @@ def test_plugin_kit4_release_uses_versioned_catalog_path(self) -> None: release.ROOT_DIR / "docs/plugins/v4/catalog.json", ) - def test_plugin_kit5_release_uses_versioned_catalog_path(self) -> None: + def test_plugin_kit5_release_uses_schema3_compatibility_path(self) -> None: self.assertEqual( release.plugin_catalog_path(5), - release.ROOT_DIR / "docs/plugins/v5/catalog.json", + release.ROOT_DIR / "docs/plugins/v5/schema3/catalog.json", ) + def test_first_plugin_kit5_schema3_release_uses_schema2_baseline(self) -> None: + with ( + mock.patch.object(release, "read_plugins", return_value={}), + mock.patch.object(release, "current_plugin_kit_version", return_value=5), + mock.patch.object(Path, "exists", autospec=True) as exists, + ): + exists.side_effect = lambda path: path == release.ROOT_DIR / "docs/plugins/v5/catalog.json" + + self.assertEqual( + release.previous_plugin_catalog_path(), + release.ROOT_DIR / "docs/plugins/v5/catalog.json", + ) + + def test_schema3_workflow_uses_same_abi_baseline_and_forces_full_release(self) -> None: + workflow = (release.ROOT_DIR / ".github/workflows/plugin-release.yml").read_text( + encoding="utf-8" + ) + + self.assertIn("origin/main:docs/plugins/v5/catalog.json", workflow) + self.assertIn('echo "PLUGIN_RELEASE_MODE=all"', workflow) + self.assertIn('echo "PLUGIN_RELEASE_REQUIRE_VERSION_BUMP=true"', workflow) + self.assertIn("plan_args+=(--require-version-bump)", workflow) + + def test_selected_mode_cannot_skip_same_abi_migration_rebuild(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + schema3_catalog = root / "docs/plugins/v5/schema3/catalog.json" + schema2_catalog = root / "docs/plugins/v5/catalog.json" + args = mock.Mock(plugin_mode="selected", plugin=["demo"], skip_check=True) + + with ( + mock.patch.object(release, "read_plugins", return_value={}), + mock.patch.object(release, "current_plugin_kit_version", return_value=5), + mock.patch.object(release, "plugin_catalog_path", return_value=schema3_catalog), + mock.patch.object( + release, + "same_abi_catalog_migration_baseline_path", + return_value=schema2_catalog, + ), + mock.patch.object( + release, + "previous_plugin_catalog_path", + return_value=schema2_catalog, + ), + mock.patch.object( + release, + "read_previous_catalog", + return_value={"pluginKitVersion": 5}, + ), + mock.patch.object(release, "write_plugin_versions") as write_versions, + mock.patch.object(release, "commit_if_needed") as commit, + mock.patch.object(release, "push_branch_and_tag") as push, + ): + with self.assertRaisesRegex( + release.ReleaseError, + "plugin-mode selected.*plugin-mode all", + ): + release.release_plugin(args) + + write_versions.assert_not_called() + commit.assert_not_called() + push.assert_not_called() + + def test_legacy_plugin_kit_release_is_rejected_before_tagging(self) -> None: + with self.assertRaisesRegex(release.ReleaseError, "catalog 已冻结"): + release.ensure_plugin_kit_releasable(4) + + release.ensure_plugin_kit_releasable(5) + release.ensure_plugin_kit_releasable(6) + def test_predeclared_app_version_is_the_default_release_target(self) -> None: with mock.patch.object(release, "choose_level") as choose_level: target, level, uses_declared_version = release.resolve_app_release_target( @@ -164,6 +234,71 @@ def test_app_release_preflight_invokes_catalog_verifier_for_target_version(self) class WorkflowReleasePlanningTests(unittest.TestCase): + def test_compatibility_migration_rejects_equal_published_versions(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + plugin_directory = root / "Plugins" / "Demo" + plugin_directory.mkdir(parents=True) + manifest_path = plugin_directory / "plugin.json" + manifest = { + "id": "demo", + "displayName": "Demo", + "version": "1.0.0", + "pluginKitVersion": 5, + } + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + previous_catalog = root / "catalog.json" + previous_catalog.write_text( + json.dumps( + { + "pluginKitVersion": 5, + "plugins": [ + { + "id": "demo", + "version": "1.0.0", + "pluginKitVersion": 5, + } + ], + } + ), + encoding="utf-8", + ) + output = root / "plan.json" + command = [ + sys.executable, + str(PLAN_SCRIPT_PATH), + "--mode", "all", + "--require-version-bump", + "--source-dir", "Plugins", + "--previous-catalog", str(previous_catalog), + "--output", str(output), + ] + + rejected = subprocess.run( + command, + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertNotEqual(rejected.returncode, 0) + self.assertIn("compatibility migration requires a version bump", rejected.stderr) + + manifest["version"] = "1.0.1" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + accepted = subprocess.run( + command, + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + self.assertEqual(accepted.returncode, 0, accepted.stderr) + plan = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual(plan["selectedPluginIDs"], ["demo"]) + def test_default_plan_rejects_unbumped_plugin_after_plugin_kit_change(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: root = Path(temporary_directory) diff --git a/scripts/tests/test_plugin_source_manifest.py b/scripts/tests/test_plugin_source_manifest.py new file mode 100644 index 00000000..67146b1c --- /dev/null +++ b/scripts/tests/test_plugin_source_manifest.py @@ -0,0 +1,977 @@ +from __future__ import annotations + +import base64 +import copy +import json +import pathlib +import re +import subprocess +import sys +import tempfile +import unittest + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +PLUGINS_ROOT = REPO_ROOT / "Plugins" +SCRIPTS_ROOT = REPO_ROOT / "scripts" / "plugins" +sys.path.insert(0, str(SCRIPTS_ROOT)) + +from plugin_source_manifest import ( # noqa: E402 + ManifestValidationError, + SUPPORTED_LOCALES, + load_known_plugin_ids, + validate_and_project_manifest, +) + + +class PluginSourceManifestTests(unittest.TestCase): + def test_every_repository_manifest_passes_semantic_validation(self) -> None: + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + for path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): + with self.subTest(plugin=path.parent.name): + validate_and_project_manifest( + json.loads(path.read_text(encoding="utf-8")), + path, + known_ids, + ) + + def test_all_repository_manifests_publish_complete_product_metadata(self) -> None: + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + required_sections = { + "presentation", "discovery", "requirements", "privacy", "setup", "relationships" + } + for path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): + manifest = json.loads(path.read_text(encoding="utf-8")) + with self.subTest(plugin=manifest["id"]): + self.assertTrue(required_sections.issubset(manifest)) + self.assertEqual(set(manifest["localizedMetadata"]), SUPPORTED_LOCALES) + projected, _ = validate_and_project_manifest(manifest, path, known_ids) + self.assertEqual( + set(projected["presentation"]["longDescription"]), + SUPPORTED_LOCALES, + ) + + def test_source_localization_references_expand_before_projection(self) -> None: + path = PLUGINS_ROOT / "ActivityBar" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + + self.assertEqual( + manifest["presentation"]["longDescription"], + "@productStrings.summary", + ) + self.assertEqual(manifest["productStrings"]["summary"], "@summary") + projected, _ = validate_and_project_manifest( + manifest, + path, + load_known_plugin_ids(PLUGINS_ROOT), + ) + + self.assertEqual( + projected["presentation"]["longDescription"]["en"], + manifest["localizedMetadata"]["en"]["summary"], + ) + self.assertNotIn("productStrings", projected) + + def test_standard_setup_references_expand_declared_requirements(self) -> None: + path = PLUGINS_ROOT / "Calendar" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + projected, _ = validate_and_project_manifest( + manifest, + path, + load_known_plugin_ids(PLUGINS_ROOT), + ) + + step = projected["setup"]["steps"][0] + self.assertEqual(step["title"]["en"], "Set Up Calendar") + self.assertIn("Full Calendar Access", step["description"]["en"]) + self.assertIn("Automation permission", step["description"]["en"]) + + def test_repository_product_text_uses_only_declared_product_string_references(self) -> None: + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + for path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): + manifest = json.loads(path.read_text(encoding="utf-8")) + with self.subTest(plugin=manifest["id"]): + self.assertTrue(manifest["productStrings"]) + projected, _ = validate_and_project_manifest(manifest, path, known_ids) + self.assertNotIn("productStrings", projected) + + def test_inline_missing_and_unused_product_strings_are_rejected(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + + inline = copy.deepcopy(manifest) + inline["presentation"]["longDescription"] = inline["productStrings"]["long-description"] + with self.assertRaisesRegex(ManifestValidationError, "inline localized text is not allowed"): + validate_and_project_manifest(inline, path, known_ids) + + missing = copy.deepcopy(manifest) + missing["presentation"]["longDescription"] = "@productStrings.missing" + with self.assertRaisesRegex(ManifestValidationError, "references missing productStrings entry"): + validate_and_project_manifest(missing, path, known_ids) + + unused = copy.deepcopy(manifest) + unused["productStrings"]["unused"] = "@summary" + with self.assertRaisesRegex(ManifestValidationError, "contains unused entries: unused"): + validate_and_project_manifest(unused, path, known_ids) + + def test_reviewed_runtime_requirements_and_disclosures_stay_accurate(self) -> None: + expected_permissions = { + "ActivityBar": ["inputMonitoring"], + "AppVolume": ["system-audio-recording"], + "Appearance": ["automation"], + "AppleShortcuts": ["automation"], + "AutoHideDock": ["automation"], + "AutoHideMenuBar": ["automation"], + "EmptyTrash": ["automation"], + "RightClick": [], + "DeviceBattery": ["inputMonitoring"], + "ZshConfig": ["automation"], + } + for directory, permissions in expected_permissions.items(): + manifest = json.loads( + (PLUGINS_ROOT / directory / "plugin.json").read_text(encoding="utf-8") + ) + with self.subTest(plugin=manifest["id"]): + self.assertEqual(manifest["permissions"], permissions) + self.assertEqual(manifest["requirements"]["permissionIDs"], permissions) + + battery = json.loads( + (PLUGINS_ROOT / "BatteryChargeLimit" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertEqual(set(battery["requirements"]["architectures"]), {"arm64", "x86_64"}) + self.assertNotIn("Apple Silicon Mac", battery["requirements"]["hardware"]) + + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + privileged_helpers = { + "BatteryChargeLimit": ( + "cc.ggbond.mactools.battery-charge-limit.smc-helper", + "built-in battery", + ), + "FanControl": ( + "cc.ggbond.mactools.fan-control.smc-helper", + "system fans", + ), + } + for directory, (helper_name, hardware_copy) in privileged_helpers.items(): + path = PLUGINS_ROOT / directory / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + projected, _ = validate_and_project_manifest(manifest, path, known_ids) + steps = {step["id"]: step for step in projected["setup"]["steps"]} + with self.subTest(plugin=manifest["id"], disclosure="privileged-helper"): + self.assertEqual( + set(steps), + {"install-privileged-helper", "verify-compatible-hardware"}, + ) + helper_description = steps["install-privileged-helper"]["description"]["en"] + self.assertIn("administrator authorization", helper_description) + self.assertIn("root-owned, mode-4755 helper", helper_description) + self.assertIn( + f"/Library/PrivilegedHelperTools/{helper_name}", + helper_description, + ) + self.assertIn( + hardware_copy, + steps["verify-compatible-hardware"]["description"]["en"], + ) + + zsh_path = PLUGINS_ROOT / "ZshConfig" / "plugin.json" + zsh_manifest = json.loads(zsh_path.read_text(encoding="utf-8")) + zsh_projected, _ = validate_and_project_manifest(zsh_manifest, zsh_path, known_ids) + zsh_retention = zsh_projected["privacy"]["retention"]["description"]["en"] + self.assertIn("one .bak backup per edited shell file", zsh_retention) + self.assertIn("next save of that file replaces its backup", zsh_retention) + self.assertIn("until you remove them", zsh_retention) + + cloudflare = json.loads( + (PLUGINS_ROOT / "CloudflareR2" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertEqual(cloudflare["requirements"]["setupComplexity"], "advanced") + self.assertTrue(cloudflare["setup"]["steps"]) + + right_click = json.loads( + (PLUGINS_ROOT / "RightClick" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertEqual(right_click["permissions"], []) + self.assertEqual(right_click["requirements"]["permissionIDs"], []) + self.assertEqual(right_click["requirements"]["setupComplexity"], "guided") + self.assertTrue(right_click["setup"]["steps"]) + + translator = json.loads( + (PLUGINS_ROOT / "Translator" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertEqual(translator["privacy"]["networkDomains"], ["api.openai.com"]) + translator_actions = { + action["id"]: action["permissionIDs"] + for action in translator["actions"]["providers"][0]["staticActions"] + } + self.assertEqual( + translator_actions, + { + "select-translation": ["accessibility", "automation"], + "screenshot-translation": ["screen-recording"], + }, + ) + + auto_input = json.loads( + (PLUGINS_ROOT / "AutoInput" / "plugin.json").read_text(encoding="utf-8") + ) + auto_input_actions = auto_input["actions"]["providers"][0] + self.assertTrue( + all( + not action["permissionIDs"] + for action in ( + auto_input_actions["staticActions"] + + auto_input_actions["dynamicTemplates"] + ) + ) + ) + + activity = json.loads( + (PLUGINS_ROOT / "ActivityBar" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertTrue(activity["privacy"]["processesSensitiveUserContent"]) + self.assertTrue( + {"ai-prompt-content", "project-working-directories"}.issubset( + activity["privacy"]["dataObserved"] + ) + ) + self.assertTrue( + {"usage-statistics", "coding-session-statistics"}.issubset( + activity["privacy"]["dataPersisted"] + ) + ) + + shortcuts = json.loads( + (PLUGINS_ROOT / "AppleShortcuts" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertTrue(shortcuts["privacy"]["processesSensitiveUserContent"]) + shortcut_template = shortcuts["actions"]["providers"][0]["dynamicTemplates"][0] + self.assertTrue(shortcut_template["riskVariesByEntry"]) + self.assertNotIn("automaticEligibilityVariesByEntry", shortcut_template) + self.assertEqual(shortcut_template["risk"], "confirmationRequired") + self.assertEqual(shortcut_template["externalInvocation"], "confirmAlways") + self.assertIn("run-link", shortcut_template["surfaces"]) + + scripts = json.loads( + (PLUGINS_ROOT / "SavedScripts" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertTrue( + {"script-content", "script-working-directories"}.issubset( + scripts["privacy"]["dataPersisted"] + ) + ) + self.assertIn("script-working-directories", scripts["privacy"]["dataObserved"]) + script_template = scripts["actions"]["providers"][0]["dynamicTemplates"][0] + self.assertTrue(script_template["riskVariesByEntry"]) + self.assertTrue(script_template["automaticEligibilityVariesByEntry"]) + self.assertEqual(script_template["risk"], "confirmationRequired") + self.assertEqual(script_template["externalInvocation"], "configurable") + self.assertTrue( + {"run-link", "automatic-rule"}.issubset(script_template["surfaces"]) + ) + + layouts = json.loads( + (PLUGINS_ROOT / "WindowLayouts" / "plugin.json").read_text(encoding="utf-8") + ) + self.assertTrue(layouts["privacy"]["processesSensitiveUserContent"]) + custom = layouts["actions"]["providers"][0]["dynamicTemplates"][0] + self.assertEqual(custom["externalInvocation"], "configurable") + self.assertIn("run-link", custom["surfaces"]) + + def test_automation_permission_runtime_copy_is_fully_localized(self) -> None: + expected_keys = { + "permission.automation.title", + "permission.automation.description", + "permission.automation.footnote", + "permission.automation.status", + } + expected_locales = { + "Appearance": SUPPORTED_LOCALES, + "AppleShortcuts": SUPPORTED_LOCALES, + "AutoHideDock": SUPPORTED_LOCALES, + "AutoHideMenuBar": SUPPORTED_LOCALES, + "ZshConfig": SUPPORTED_LOCALES, + } + + for directory, locales in expected_locales.items(): + source = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted((PLUGINS_ROOT / directory / "Sources").glob("*.swift")) + ) + source_keys = set( + re.findall(r'localization\.string\(\s*"(permission\.automation\.[^"]+)"', source) + ) + catalog = json.loads( + (PLUGINS_ROOT / directory / "Resources" / "Localizable.xcstrings").read_text( + encoding="utf-8" + ) + )["strings"] + + with self.subTest(plugin=directory): + self.assertEqual(source_keys, expected_keys) + for key in expected_keys: + self.assertIn(key, catalog) + self.assertEqual(set(catalog[key]["localizations"]), locales) + + def test_action_permissions_must_be_declared_by_plugin_and_requirements(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + + missing_top_level = copy.deepcopy(manifest) + missing_top_level["permissions"] = [] + with self.assertRaisesRegex(ManifestValidationError, "top-level permissions"): + validate_and_project_manifest(missing_top_level, path, known_ids) + + missing_requirements = copy.deepcopy(manifest) + missing_requirements["requirements"]["permissionIDs"] = [] + with self.assertRaisesRegex(ManifestValidationError, "requirements.permissionIDs"): + validate_and_project_manifest(missing_requirements, path, known_ids) + + def test_optional_urls_reject_explicit_null(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + for key in ("documentationURL", "supportURL"): + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["presentation"][key] = None + with self.subTest(field=key), self.assertRaisesRegex( + ManifestValidationError, + "must be an HTTPS URL", + ): + validate_and_project_manifest(manifest, path, known_ids) + + def test_presentation_example_ids_match_json_schema(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["presentation"]["examples"][0]["id"] = "bad/example" + + with self.assertRaisesRegex(ManifestValidationError, "stable identifier"): + validate_and_project_manifest( + manifest, + path, + load_known_plugin_ids(PLUGINS_ROOT), + ) + + def test_versions_match_json_schema_shape(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + schema = json.loads( + (REPO_ROOT / "docs" / "plugins" / "plugin-manifest.schema.json").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(schema["$defs"]["version"]["pattern"], r"^[0-9]+(?:\.[0-9]+){0,2}$") + + for field, value in ( + ("version", "1.2.3.4"), + ("minHostVersion", "v1.2"), + ): + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest[field] = value + with self.subTest(field=field), self.assertRaisesRegex( + ManifestValidationError, + "numeric version components", + ): + validate_and_project_manifest(manifest, path, known_ids) + + for value in (None, "14.0.0.1", "macOS 14"): + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["requirements"]["minimumMacOSVersion"] = value + with self.subTest(minimum=value), self.assertRaisesRegex( + ManifestValidationError, + "numeric version components", + ): + validate_and_project_manifest(manifest, path, known_ids) + + def test_runtime_envelope_schema_matches_source_validator(self) -> None: + schema = json.loads( + (REPO_ROOT / "docs" / "plugins" / "plugin-manifest.schema.json").read_text( + encoding="utf-8" + ) + ) + definitions = schema["$defs"] + self.assertEqual(schema["properties"]["id"]["$ref"], "#/$defs/pluginIdentifier") + self.assertEqual( + definitions["pluginIdentifier"]["pattern"], + r"^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$", + ) + self.assertEqual(definitions["pluginIdentifier"]["not"], {"const": "marketplace"}) + self.assertEqual( + schema["properties"]["bundleRelativePath"]["$ref"], + "#/$defs/bundleRelativePath", + ) + self.assertEqual( + schema["properties"]["localizedMetadata"]["$ref"], + "#/$defs/localizedMetadata", + ) + self.assertEqual(schema["properties"]["releaseChannel"]["type"], "string") + self.assertEqual(schema["properties"]["releaseNotesURL"]["pattern"], "^https://") + capabilities = definitions["capabilities"] + self.assertEqual( + set(capabilities["required"]), + {"primaryPanel", "componentPanel", "settings"}, + ) + self.assertFalse(capabilities["additionalProperties"]) + self.assertEqual( + set(capabilities["properties"]["settings"]["enum"]), + {"none", "form", "workspace"}, + ) + + def test_sparse_legacy_runtime_envelopes_remain_valid(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + path = pathlib.Path(temporary_directory) / "plugin.json" + fixtures = ( + (3, {"primaryPanel": True, "configuration": True}), + (4, {"componentPanel": True, "settings": "form"}), + ) + for plugin_kit_version, capabilities in fixtures: + manifest = { + "id": "legacy-demo", + "displayName": "Legacy", + "version": "1.0.0", + "minHostVersion": "1.0.0", + "pluginKitVersion": plugin_kit_version, + "bundleRelativePath": "Legacy.bundle", + "capabilities": capabilities, + "permissions": [], + } + path.write_text(json.dumps(manifest), encoding="utf-8") + + with self.subTest(pluginKitVersion=plugin_kit_version): + projected, assets = validate_and_project_manifest( + manifest, + path, + {"legacy-demo"}, + allow_sparse_legacy=True, + ) + + self.assertNotIn("presentation", projected) + self.assertEqual(assets, []) + + def test_runtime_envelope_mutations_are_rejected(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + mutations = { + "reserved id": lambda value: value.__setitem__("id", "marketplace"), + "empty display name": lambda value: value.__setitem__("displayName", ""), + "string PluginKit version": lambda value: value.__setitem__("pluginKitVersion", "5"), + "traversal bundle path": lambda value: value.__setitem__( + "bundleRelativePath", "../Bad.bundle" + ), + "array capabilities": lambda value: value.__setitem__("capabilities", []), + "incomplete capabilities": lambda value: value.__setitem__( + "capabilities", {"primaryPanel": True} + ), + "numeric summary": lambda value: value.__setitem__("summary", 4), + "empty summary": lambda value: value.__setitem__("summary", ""), + "array localized metadata": lambda value: value.__setitem__( + "localizedMetadata", [] + ), + "invalid localized metadata entry": lambda value: value.__setitem__( + "localizedMetadata", {"en": 4} + ), + "numeric release channel": lambda value: value.__setitem__("releaseChannel", 4), + "empty release channel": lambda value: value.__setitem__("releaseChannel", ""), + "numeric release notes URL": lambda value: value.__setitem__("releaseNotesURL", 4), + "invalid release notes URL": lambda value: value.__setitem__( + "releaseNotesURL", "not-a-url" + ), + "release notes URL with host whitespace": lambda value: value.__setitem__( + "releaseNotesURL", "https://bad host/path" + ), + "release notes URL with invalid port": lambda value: value.__setitem__( + "releaseNotesURL", "https://example.com:abc/x" + ), + } + for name, mutate in mutations.items(): + invalid = copy.deepcopy(manifest) + mutate(invalid) + with self.subTest(mutation=name), self.assertRaises(ManifestValidationError): + validate_and_project_manifest(invalid, path, known_ids) + + def test_catalog_rejects_invalid_packaged_runtime_envelope(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + package = root / "appearance.mactoolsplugin" + package.mkdir() + packaged_manifest = json.loads( + (PLUGINS_ROOT / "Appearance" / "plugin.json").read_text(encoding="utf-8") + ) + packaged_manifest["capabilities"] = [] + package.joinpath("plugin.json").write_text( + json.dumps(packaged_manifest), + encoding="utf-8", + ) + package.joinpath("Appearance.bundle").mkdir() + + result = subprocess.run( + [ + sys.executable, + str(SCRIPTS_ROOT / "generate-plugin-catalog.py"), + "--mode", "debug", + "--output", str(root / "catalog.json"), + "--package", str(package), + "--plugins-root", str(PLUGINS_ROOT), + ], + capture_output=True, + text=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("capabilities", result.stderr) + + def test_duplicate_plugin_ids_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + for name in ("First", "Second"): + directory = root / name + directory.mkdir() + directory.joinpath("plugin.json").write_text( + json.dumps({"id": "duplicate"}), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ManifestValidationError, "duplicates another plugin"): + load_known_plugin_ids(root) + + def test_rejects_missing_localization_invalid_domain_and_duplicate_action(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + + missing_locale = copy.deepcopy(manifest) + missing_locale["productStrings"]["long-description"].pop("fr") + with self.assertRaisesRegex(ManifestValidationError, "missing locale fallback"): + validate_and_project_manifest(missing_locale, path, known_ids) + + invalid_domain = copy.deepcopy(manifest) + invalid_domain["privacy"]["networkUse"] = "required" + invalid_domain["privacy"]["networkDomains"] = ["https://example.com/path"] + with self.assertRaisesRegex(ManifestValidationError, "invalid domain"): + validate_and_project_manifest(invalid_domain, path, known_ids) + + duplicate_action = copy.deepcopy(manifest) + duplicate_action["actions"]["providers"][0]["staticActions"].append( + copy.deepcopy(duplicate_action["actions"]["providers"][0]["staticActions"][0]) + ) + with self.assertRaisesRegex(ManifestValidationError, "duplicates a static action key"): + validate_and_project_manifest(duplicate_action, path, known_ids) + + def test_rejects_duplicate_dynamic_template_ids(self) -> None: + path = PLUGINS_ROOT / "AppVolume" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + provider = manifest["actions"]["providers"][0] + provider["dynamicTemplates"].append(copy.deepcopy(provider["dynamicTemplates"][0])) + + with self.assertRaisesRegex(ManifestValidationError, "duplicates an action or template key"): + validate_and_project_manifest(manifest, path, load_known_plugin_ids(PLUGINS_ROOT)) + + def test_parameterless_static_action_rejects_parameter_summary(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + action = manifest["actions"]["providers"][0]["staticActions"][0] + action["parameterSummary"] = action["description"] + + with self.assertRaisesRegex(ManifestValidationError, "without parameters"): + validate_and_project_manifest(manifest, path, load_known_plugin_ids(PLUGINS_ROOT)) + + def test_dynamic_template_rejects_repeated_parameter_summary(self) -> None: + path = PLUGINS_ROOT / "AppHotkey" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + template = manifest["actions"]["providers"][0]["dynamicTemplates"][0] + template["parameterSummary"] = template["description"] + del manifest["productStrings"]["action.launch.parameter-summary"] + + with self.assertRaisesRegex(ManifestValidationError, "must describe the template parameters"): + validate_and_project_manifest(manifest, path, load_known_plugin_ids(PLUGINS_ROOT)) + + def test_guided_setup_rejects_product_metadata_placeholder(self) -> None: + path = PLUGINS_ROOT / "Calendar" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + step = manifest["setup"]["steps"][0] + step["title"] = "@productStrings.display-name" + step["description"] = "@productStrings.summary" + del manifest["productStrings"]["setup.requirements.title"] + del manifest["productStrings"]["setup.requirements.description"] + + with self.assertRaisesRegex(ManifestValidationError, "concrete setup requirements"): + validate_and_project_manifest(manifest, path, load_known_plugin_ids(PLUGINS_ROOT)) + + def test_multi_action_providers_publish_distinct_localized_copy(self) -> None: + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + for path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): + manifest = json.loads(path.read_text(encoding="utf-8")) + projected, _ = validate_and_project_manifest(manifest, path, known_ids) + for provider in projected.get("actions", {}).get("providers", []): + entries = provider["staticActions"] + provider["dynamicTemplates"] + if len(entries) < 2: + continue + for field in ("title", "description", "parameterSummary"): + field_entries = [entry for entry in entries if field in entry] + if len(field_entries) < 2: + continue + for locale in SUPPORTED_LOCALES: + values = [entry[field][locale] for entry in field_entries] + with self.subTest( + plugin=manifest["id"], + provider=provider["id"], + field=field, + locale=locale, + ): + self.assertEqual(len(values), len(set(values))) + + def test_rejects_action_surfaces_that_runtime_policy_cannot_expose(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + + confirmation = copy.deepcopy(manifest) + confirmation["actions"]["providers"][0]["staticActions"][0]["risk"] = "confirmationRequired" + with self.assertRaisesRegex(ManifestValidationError, "automatic-rule requires"): + validate_and_project_manifest(confirmation, path, known_ids) + + local_only = copy.deepcopy(manifest) + action = local_only["actions"]["providers"][0]["staticActions"][0] + action["parameters"] = [{ + "id": "device", "kind": "string", "isRequired": True, "portability": "localOnly", + }] + with self.assertRaisesRegex(ManifestValidationError, "app-intent requires"): + validate_and_project_manifest(local_only, path, known_ids) + + missing_run_link = copy.deepcopy(manifest) + action = missing_run_link["actions"]["providers"][0]["staticActions"][0] + action["surfaces"].remove("run-link") + with self.assertRaisesRegex(ManifestValidationError, "run-link must match"): + validate_and_project_manifest(missing_run_link, path, known_ids) + + shortcuts_path = PLUGINS_ROOT / "AppleShortcuts" / "plugin.json" + for surface in ("automatic-rule", "app-intent"): + shortcuts = json.loads(shortcuts_path.read_text(encoding="utf-8")) + shortcuts["actions"]["providers"][0]["dynamicTemplates"][0]["surfaces"].append( + surface + ) + with self.subTest(surface=surface), self.assertRaisesRegex( + ManifestValidationError, + f"{surface} requires", + ): + validate_and_project_manifest(shortcuts, shortcuts_path, known_ids) + + saved_scripts_path = PLUGINS_ROOT / "SavedScripts" / "plugin.json" + saved_scripts = json.loads(saved_scripts_path.read_text(encoding="utf-8")) + saved_scripts["actions"]["providers"][0]["dynamicTemplates"][0]["surfaces"].append( + "app-intent" + ) + with self.assertRaisesRegex(ManifestValidationError, "app-intent requires"): + validate_and_project_manifest(saved_scripts, saved_scripts_path, known_ids) + + def test_rejects_values_that_do_not_match_the_catalog_codable_shape(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + mutations = [ + ("publisher", lambda value: value["presentation"].__setitem__("publisher", 7)), + ("requiresRelaunch", lambda value: value["requirements"].__setitem__("requiresRelaunch", "false")), + ("applications", lambda value: value["requirements"].__setitem__( + "applications", [{"bundleID": 7, "name": "Example"}] + )), + ("processesSensitiveUserContent", lambda value: value["privacy"].__setitem__( + "processesSensitiveUserContent", "false" + )), + ] + + for field, mutate in mutations: + invalid = copy.deepcopy(manifest) + mutate(invalid) + with self.subTest(field=field): + with self.assertRaisesRegex(ManifestValidationError, field): + validate_and_project_manifest(invalid, path, known_ids) + + def test_static_dynamic_and_mixed_provider_shapes_validate(self) -> None: + appearance_path = PLUGINS_ROOT / "Appearance" / "plugin.json" + app_volume_path = PLUGINS_ROOT / "AppVolume" / "plugin.json" + appearance = json.loads(appearance_path.read_text(encoding="utf-8")) + app_volume = json.loads(app_volume_path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + + validate_and_project_manifest(appearance, appearance_path, known_ids) + validate_and_project_manifest(app_volume, app_volume_path, known_ids) + + mixed = copy.deepcopy(appearance) + provider = mixed["actions"]["providers"][0] + provider["kind"] = "mixed" + template = copy.deepcopy( + app_volume["actions"]["providers"][0]["dynamicTemplates"][0] + ) + template["id"] = "set-device-value" + provider["dynamicTemplates"] = [template] + mixed["permissions"].append("system-audio-recording") + mixed["requirements"]["permissionIDs"].append("system-audio-recording") + for field in ("title", "description", "parameterSummary"): + key = template[field].removeprefix("@productStrings.") + mixed["productStrings"][key] = app_volume["productStrings"][key] + + validate_and_project_manifest(mixed, appearance_path, known_ids) + + def test_rejects_invalid_action_permission_relationship_asset_and_test_action(self) -> None: + path = PLUGINS_ROOT / "Appearance" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + + cases = [] + invalid_key = copy.deepcopy(manifest) + invalid_key["actions"]["providers"][0]["staticActions"][0]["id"] = "bad/action" + cases.append((invalid_key, "stable identifier")) + + invalid_permission = copy.deepcopy(manifest) + invalid_permission["actions"]["providers"][0]["staticActions"][0]["permissionIDs"] = ["root-access"] + cases.append((invalid_permission, "unknown: root-access")) + + invalid_relationship = copy.deepcopy(manifest) + invalid_relationship["relationships"]["relatedPluginIDs"] = ["missing-plugin"] + cases.append((invalid_relationship, "references unknown plugins")) + + invalid_asset = copy.deepcopy(manifest) + invalid_asset["presentation"]["screenshots"] = [{ + "id": "missing", + "path": "MarketplaceAssets/missing.png", + "alt": invalid_asset["presentation"]["longDescription"], + }] + cases.append((invalid_asset, "asset does not exist")) + + invalid_test_action = copy.deepcopy(manifest) + invalid_test_action["setup"]["suggestedTestAction"]["actionID"] = "missing" + cases.append((invalid_test_action, "must reference a declared static action")) + + for value, message in cases: + with self.subTest(message=message): + with self.assertRaisesRegex(ManifestValidationError, message): + validate_and_project_manifest(value, path, known_ids) + + def test_unknown_optional_field_is_preserved_and_dynamic_templates_must_be_complete(self) -> None: + path = PLUGINS_ROOT / "AppVolume" / "plugin.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + known_ids = load_known_plugin_ids(PLUGINS_ROOT) + manifest["futureProductField"] = {"enabled": True} + + projected, _ = validate_and_project_manifest(manifest, path, known_ids) + + self.assertEqual(projected["futureProductField"], {"enabled": True}) + + incomplete = copy.deepcopy(manifest) + template = incomplete["actions"]["providers"][0]["dynamicTemplates"][0] + key = template["parameterSummary"].removeprefix("@productStrings.") + del template["parameterSummary"] + del incomplete["productStrings"][key] + with self.assertRaisesRegex(ManifestValidationError, "missing parameterSummary"): + validate_and_project_manifest(incomplete, path, known_ids) + + def test_asset_projection_hashes_and_validates_png(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + asset = root / "MarketplaceAssets" / "preview.png" + asset.parent.mkdir() + asset.write_bytes(base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlS8AAAAASUVORK5CYII=" + )) + localized = {locale: "Preview" for locale in SUPPORTED_LOCALES} + manifest = { + "id": "asset-demo", + "displayName": "Asset Demo", + "version": "1.0.0", + "minHostVersion": "1.2.0", + "pluginKitVersion": 5, + "bundleRelativePath": "AssetDemo.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "settings": "none", + }, + "permissions": [], + "category": "other", + "productStrings": {"preview": localized}, + "presentation": { + "longDescription": "@productStrings.preview", + "examples": [], + "screenshots": [{ + "id": "main", + "path": "MarketplaceAssets/preview.png", + "alt": "@productStrings.preview", + }], + "publisher": "Example", + "license": "Apache-2.0", + }, + } + path = root / "plugin.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + projected, assets = validate_and_project_manifest(manifest, path, {"asset-demo"}) + + screenshot = projected["presentation"]["screenshots"][0] + self.assertEqual(screenshot["mediaType"], "image/png") + self.assertEqual(screenshot["width"], 1) + self.assertEqual(screenshot["height"], 1) + self.assertEqual(len(screenshot["sha256"]), 64) + self.assertEqual(len(assets), 1) + + def test_assets_cannot_escape_root_or_bypass_dimension_parsing(self) -> None: + localized = {locale: "Preview" for locale in SUPPORTED_LOCALES} + + def manifest_for(path: str) -> dict: + return { + "id": "asset-demo", + "displayName": "Asset Demo", + "version": "1.0.0", + "minHostVersion": "1.2.0", + "pluginKitVersion": 5, + "bundleRelativePath": "AssetDemo.bundle", + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "settings": "none", + }, + "permissions": [], + "category": "other", + "productStrings": {"preview": localized}, + "presentation": { + "longDescription": "@productStrings.preview", + "examples": [], + "screenshots": [{ + "id": "main", + "path": path, + "alt": "@productStrings.preview", + }], + "publisher": "Example", + "license": "Apache-2.0", + }, + } + + with tempfile.TemporaryDirectory() as temporary_directory: + parent = pathlib.Path(temporary_directory) + root = parent / "Plugin" + assets = root / "MarketplaceAssets" + assets.mkdir(parents=True) + outside = parent / "outside.png" + outside.write_bytes(base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlS8AAAAASUVORK5CYII=" + )) + (assets / "linked.png").symlink_to(outside) + path = root / "plugin.json" + manifest = manifest_for("MarketplaceAssets/linked.png") + + with self.assertRaisesRegex(ManifestValidationError, "resolve inside"): + validate_and_project_manifest(manifest, path, {"asset-demo"}) + + corrupt = assets / "corrupt.png" + corrupt.write_bytes(b"\x89PNG\r\n\x1a\n") + manifest = manifest_for("MarketplaceAssets/corrupt.png") + with self.assertRaisesRegex(ManifestValidationError, "dimensions could not be parsed"): + validate_and_project_manifest(manifest, path, {"asset-demo"}) + + oversized_webp = assets / "oversized.webp" + width_minus_one = 8_000 - 1 + payload = b"\0\0\0\0" + width_minus_one.to_bytes(3, "little") + (0).to_bytes(3, "little") + body = b"WEBP" + b"VP8X" + len(payload).to_bytes(4, "little") + payload + oversized_webp.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body) + manifest = manifest_for("MarketplaceAssets/oversized.webp") + with self.assertRaisesRegex(ManifestValidationError, "dimensions must not exceed"): + validate_and_project_manifest(manifest, path, {"asset-demo"}) + + def test_catalog_and_website_generation_are_deterministic(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = pathlib.Path(temporary_directory) + package = root / "appearance.mactoolsplugin" + package.mkdir() + source = PLUGINS_ROOT / "Appearance" / "plugin.json" + subprocess.run( + [ + sys.executable, + str(SCRIPTS_ROOT / "copy-plugin-manifest.py"), + "copy", + "--source", str(source), + "--destination", str(package / "plugin.json"), + "--configuration", "Release", + "--app-version-config", str(REPO_ROOT / "Configs/AppVersion.xcconfig"), + ], + check=True, + ) + package.joinpath("Appearance.bundle").mkdir() + package.joinpath("Appearance.bundle", "payload").write_text("fixture", encoding="utf-8") + first_catalog = root / "first.json" + second_catalog = root / "second.json" + first_website = root / "first-website" / "plugins.json" + second_website = root / "second-website" / "plugins.json" + base_command = [ + sys.executable, + str(SCRIPTS_ROOT / "generate-plugin-catalog.py"), + "--mode", "debug", + "--package", str(package), + "--plugins-root", str(PLUGINS_ROOT), + "--generated-at", "2026-08-23T00:00:00Z", + ] + subprocess.run( + base_command + ["--output", str(first_catalog), "--website-output", str(first_website)], + check=True, + ) + subprocess.run( + base_command + ["--output", str(second_catalog), "--website-output", str(second_website)], + check=True, + ) + + self.assertEqual(first_catalog.read_bytes(), second_catalog.read_bytes()) + self.assertEqual(first_website.read_bytes(), second_website.read_bytes()) + self.assertNotIn("@summary", first_catalog.read_text(encoding="utf-8")) + self.assertNotIn("@displayName", first_catalog.read_text(encoding="utf-8")) + self.assertNotIn("@productStrings", first_catalog.read_text(encoding="utf-8")) + catalog = json.loads(first_catalog.read_text(encoding="utf-8")) + self.assertEqual(catalog["schemaVersion"], 3) + self.assertEqual(catalog["minimumHostVersion"], "0.1.0") + entry = catalog["plugins"][0] + self.assertIn("actions", entry) + self.assertNotIn("build", entry) + self.assertNotIn("productStrings", entry) + website = json.loads(first_website.read_text(encoding="utf-8")) + self.assertNotIn("package", website["plugins"][0]) + + release_catalog = root / "release.json" + release_command = list(base_command) + release_command[release_command.index("debug")] = "release" + subprocess.run( + release_command + [ + "--base-url", "https://example.com/plugins", + "--output", str(release_catalog), + ], + check=True, + ) + self.assertEqual( + json.loads(release_catalog.read_text(encoding="utf-8"))["minimumHostVersion"], + "1.2.1", + ) + incompatible_release = subprocess.run( + release_command + [ + "--base-url", "https://example.com/plugins", + "--minimum-host-version", "1.2.0", + "--output", str(root / "incompatible-release.json"), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertNotEqual(incompatible_release.returncode, 0) + self.assertIn("require MacTools 1.2.1", incompatible_release.stderr) + + def test_dynamic_catalog_template_never_contains_machine_local_entries(self) -> None: + manifest = json.loads( + (PLUGINS_ROOT / "AppVolume" / "plugin.json").read_text(encoding="utf-8") + ) + provider = manifest["actions"]["providers"][0] + + self.assertEqual(provider["kind"], "dynamic") + self.assertEqual(provider["staticActions"], []) + self.assertEqual(provider["dynamicTemplates"][0]["entrySource"], "active-audio-applications") + self.assertNotIn("entries", provider) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_sync_debug_plugins.py b/scripts/tests/test_sync_debug_plugins.py index d0eca48e..c5f20a97 100644 --- a/scripts/tests/test_sync_debug_plugins.py +++ b/scripts/tests/test_sync_debug_plugins.py @@ -22,6 +22,12 @@ def write_manifest(source: pathlib.Path, plugin_id: str, bundle_relative_path: s "minHostVersion": "99.0.0", "pluginKitVersion": 3, "bundleRelativePath": bundle_relative_path, + "capabilities": { + "primaryPanel": False, + "componentPanel": False, + "configuration": False, + }, + "permissions": [], } ), encoding="utf-8",