commit 1d1286fadbb0d34ddc6e740c078ad4df7b8c21b9 Author: wehub-resource-sync Date: Mon Jul 13 13:18:38 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..bf81c60 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,170 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PROJECT_NAME: MacTools + SCHEME: MacTools + DERIVED_DATA: build/DerivedData + CI_BUNDLE_IDENTIFIER_PREFIX: dev.mactools.ci + +jobs: + build: + name: Build and test + runs-on: macos-26 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Show Xcode version + run: xcodebuild -version + + - name: Install XcodeGen + run: | + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + + - name: Write CI local config + run: | + cat > LocalConfig.xcconfig < "$ARTIFACT_ROOT/run-debug.sh" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + ROOT="$(cd "$(dirname "$0")" && pwd)" + LOCAL_CATALOG_PATH="$ROOT/LocalPlugins/catalog.local.json" + PACKAGES_DIR="$ROOT/LocalPlugins/Packages" + INSTALL_DIR="$HOME/Library/Application Support/MacTools Dev/Plugins/Installed" + + /usr/bin/ruby -rjson -ruri -e ' + root = ARGV.fetch(0) + catalog_path = File.join(root, "LocalPlugins", "catalog.dev.json") + output_path = File.join(root, "LocalPlugins", "catalog.local.json") + catalog = JSON.parse(File.read(catalog_path)) + (catalog["plugins"] || []).each do |plugin| + package = plugin["package"] || {} + package_path = File.expand_path(File.join(root, "LocalPlugins", "Packages", "#{plugin.fetch("id")}.mactoolsplugin")) + package["url"] = URI::DEFAULT_PARSER.escape("file://#{package_path}") + end + File.write(output_path, JSON.pretty_generate(catalog)) + ' "$ROOT" + + mkdir -p "$INSTALL_DIR" + find "$PACKAGES_DIR" -maxdepth 1 -name "*.mactoolsplugin" -type d -print0 | while IFS= read -r -d '' package; do + destination="$INSTALL_DIR/$(basename "$package")" + staging="$INSTALL_DIR/.$(basename "$package").syncing.$$" + rm -rf "$staging" + ditto "$package" "$staging" + rm -rf "$destination" + mv "$staging" "$destination" + done + + LOCAL_CATALOG_URL="$(/usr/bin/ruby -ruri -e 'puts URI::DEFAULT_PARSER.escape("file://#{File.expand_path(ARGV.fetch(0))}")' "$LOCAL_CATALOG_PATH")" + export MACTOOLS_PLUGIN_CATALOG_URL="$LOCAL_CATALOG_URL" + open "$ROOT/MacTools Dev.app" + EOF + chmod +x "$ARTIFACT_ROOT/run-debug.sh" + + cat > "$ARTIFACT_ROOT/README.txt" <<'EOF' + This is an unsigned Debug build for local testing. + + Run ./run-debug.sh to launch MacTools Dev with the bundled local plugin catalog. + If macOS blocks the app because it was downloaded from the internet, remove quarantine: + + xattr -dr com.apple.quarantine "MacTools Dev.app" + + Then run ./run-debug.sh again. + EOF + + (cd build/debug-artifact && ditto -c -k --sequesterRsrc --keepParent MacTools-Debug MacTools-Debug.zip) + + - name: Upload Debug app artifact + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@v4 + with: + name: MacTools-Debug + path: build/debug-artifact/MacTools-Debug.zip + if-no-files-found: error + + - name: Build unsigned Release app + if: github.event_name != 'pull_request' + run: | + xcodebuild \ + -project "${PROJECT_NAME}.xcodeproj" \ + -scheme "${SCHEME}" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -derivedDataPath "${DERIVED_DATA}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY= \ + build \ + -quiet diff --git a/.github/workflows/homebrew-cask-update.yml b/.github/workflows/homebrew-cask-update.yml new file mode 100644 index 0000000..1df8ad8 --- /dev/null +++ b/.github/workflows/homebrew-cask-update.yml @@ -0,0 +1,107 @@ +name: Homebrew Cask Update + +on: + workflow_dispatch: + inputs: + version: + description: "Version to bump, for example 1.0.14. Defaults to the latest stable release." + required: false + type: string + +permissions: + contents: read + +jobs: + bump-official-cask: + name: Open Homebrew cask bump PR + if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: macos-26 + timeout-minutes: 15 + + steps: + - name: Resolve release metadata + id: release + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [[ -n "$INPUT_VERSION" ]]; then + TAG="v${INPUT_VERSION#v}" + release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")" + else + release="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest")" + fi + + if [[ -z "$release" || "$release" == "null" ]]; then + echo "No stable release found." >&2 + exit 1 + fi + + TAG="$(printf '%s' "$release" | jq -r '.tag_name')" + VERSION="${TAG#v}" + DOWNLOAD_URL="$(printf '%s' "$release" | jq -r \ + '.assets[] | select(.name == "MacTools.dmg") | .browser_download_url')" + SHA256_URL="$(printf '%s' "$release" | jq -r \ + '.assets[] | select(.name == "MacTools.sha256") | .browser_download_url')" + + if [[ -z "$DOWNLOAD_URL" || "$DOWNLOAD_URL" == "null" ]]; then + echo "MacTools.dmg asset not found in release ${TAG}." >&2 + exit 1 + fi + + if [[ -z "$SHA256_URL" || "$SHA256_URL" == "null" ]]; then + echo "MacTools.sha256 asset not found in release ${TAG}." >&2 + exit 1 + fi + + SHA256="$(curl -fsSL "$SHA256_URL" | awk '{ print $1; exit }')" + if [[ ! "$SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Invalid SHA256 read from ${SHA256_URL}: ${SHA256}" >&2 + exit 1 + fi + + { + echo "tag=$TAG" + echo "version=$VERSION" + echo "sha256=$SHA256" + } >> "$GITHUB_OUTPUT" + + - name: Check official cask version + id: cask + env: + HOMEBREW_NO_INSTALL_FROM_API: 1 + run: | + brew update + current="$(brew info --cask --json=v2 mactools | jq -r '.casks[0].version')" + latest="${{ steps.release.outputs.version }}" + + { + echo "current=$current" + echo "latest=$latest" + if [[ "$current" == "$latest" ]]; then + echo "up_to_date=true" + else + echo "up_to_date=false" + fi + } >> "$GITHUB_OUTPUT" + + - name: Open Homebrew/homebrew-cask bump PR + if: steps.cask.outputs.up_to_date == 'false' + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} + HOMEBREW_NO_INSTALL_FROM_API: 1 + run: | + if [[ -z "$HOMEBREW_GITHUB_API_TOKEN" ]]; then + echo "Missing HOMEBREW_GITHUB_API_TOKEN secret. Create a GitHub token with public_repo access." >&2 + exit 1 + fi + + brew bump-cask-pr mactools \ + --version "${{ steps.release.outputs.version }}" \ + --sha256 "${{ steps.release.outputs.sha256 }}" \ + --no-browse + + - name: Already up to date + if: steps.cask.outputs.up_to_date == 'true' + run: | + echo "Official Homebrew cask is already at version ${{ steps.cask.outputs.current }}." diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..4ac6429 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,68 @@ +name: Deploy Pages + +on: + workflow_run: + workflows: + - Release + - Plugin Release + types: + - completed + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + name: Deploy site to GitHub Pages + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout main + uses: actions/checkout@v5 + with: + ref: main + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + cache-dependency-path: site/package-lock.json + + - name: Install site dependencies + working-directory: site + run: npm ci + + - name: Build site + working-directory: site + run: npm run build + + - name: Prepare Pages artifact + run: | + rm -rf build/pages + mkdir -p build/pages + cp -R docs/. build/pages/ + cp -R site/dist/. build/pages/ + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: build/pages + + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/plugin-release.yml b/.github/workflows/plugin-release.yml new file mode 100644 index 0000000..eb8ce62 --- /dev/null +++ b/.github/workflows/plugin-release.yml @@ -0,0 +1,418 @@ +name: Plugin Release + +on: + push: + tags: + - "plugins-*" + workflow_dispatch: + inputs: + tag: + description: "Plugin batch tag, for example plugins-2026.05.17 or plugins-1.0.0" + required: true + type: string + mode: + description: "Plugin release mode" + required: false + default: auto + type: choice + options: + - auto + - selected + - all + plugins: + description: "Comma-separated plugin IDs or directory names when mode=selected" + required: false + type: string + prerelease: + description: "Mark the GitHub Release as prerelease" + required: false + default: false + type: boolean + +permissions: + contents: write + +concurrency: + group: plugin-release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +env: + PLUGIN_SOURCE_DIR: Plugins + PLUGIN_RELEASE_ROOT: build/plugin-release + PLUGIN_BUILD_DIR: build/plugin-release/Build + PLUGIN_ASSETS_DIR: build/plugin-release/Assets + PLUGIN_PREVIOUS_CATALOG_PATH: build/plugin-release/previous-catalog.json + PLUGIN_RELEASE_PLAN_PATH: build/plugin-release/plan.json + PLUGIN_CATALOG_DELTA_PATH: build/plugin-release/catalog.delta.json + PLUGIN_CATALOG_PATH: build/plugin-release/catalog.json + +jobs: + plugin-release: + name: Build, sign, and release plugins + runs-on: macos-26 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Set plugin release metadata + run: | + TAG="${{ inputs.tag || github.ref_name }}" + if [[ ! "$TAG" =~ ^plugins-[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then + echo "Plugin release tag must look like plugins-2026.05.17 or plugins-1.0.0" >&2 + exit 1 + fi + + MODE="${{ inputs.mode || 'auto' }}" + PLUGIN_SELECTION="${{ inputs.plugins || '' }}" + if [[ "$MODE" != "selected" && -n "$PLUGIN_SELECTION" ]]; then + echo "The plugins input is only valid when mode=selected." >&2 + exit 1 + fi + + PRERELEASE="${{ inputs.prerelease || 'false' }}" + RELEASE_SUFFIX="${TAG#plugins-}" + PLUGIN_KIT_VERSION="$(python3 - <<'PY' + import json + from pathlib import Path + + versions = { + json.loads(path.read_text(encoding="utf-8"))["pluginKitVersion"] + for path in sorted(Path("Plugins").glob("*/plugin.json")) + } + if len(versions) != 1: + raise SystemExit("All plugin manifests must use one pluginKitVersion.") + print(next(iter(versions))) + PY + )" + if [[ "$PLUGIN_KIT_VERSION" == "2" ]]; then + PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/catalog.json" + else + PLUGIN_CATALOG_RELATIVE_PATH="docs/plugins/v${PLUGIN_KIT_VERSION}/catalog.json" + fi + { + echo "TAG=$TAG" + echo "PLUGIN_RELEASE_MODE=$MODE" + 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}" + echo "PLUGIN_ASSET_BASE_URL=https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}" + echo "PLUGIN_PRERELEASE=$PRERELEASE" + echo "PLUGIN_KIT_VERSION=$PLUGIN_KIT_VERSION" + echo "PLUGIN_CATALOG_RELATIVE_PATH=$PLUGIN_CATALOG_RELATIVE_PATH" + echo "SIGNED_PLUGIN_CATALOG_PATH=$PLUGIN_CATALOG_RELATIVE_PATH" + } >> "$GITHUB_ENV" + + - name: Prepare previous plugin catalog + run: | + mkdir -p "$PLUGIN_RELEASE_ROOT" + 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 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")" && \ + [[ -n "$PREVIOUS_VERSIONED_CATALOG" ]] && \ + git show "origin/main:${PREVIOUS_VERSIONED_CATALOG}" > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then + echo "Using ${PREVIOUS_VERSIONED_CATALOG} from origin/main as the ABI migration baseline." + elif [[ "$PLUGIN_CATALOG_RELATIVE_PATH" != "docs/plugins/catalog.json" ]] && \ + git show origin/main:docs/plugins/catalog.json > "$PLUGIN_PREVIOUS_CATALOG_PATH" 2>/dev/null; then + echo "Using the legacy v2 catalog from origin/main as the v${PLUGIN_KIT_VERSION} release baseline." + elif [[ -f "$SIGNED_PLUGIN_CATALOG_PATH" ]]; then + cp "$SIGNED_PLUGIN_CATALOG_PATH" "$PLUGIN_PREVIOUS_CATALOG_PATH" + echo "Using ${PLUGIN_CATALOG_RELATIVE_PATH} from the checked-out ref as the previous production catalog." + else + rm -f "$PLUGIN_PREVIOUS_CATALOG_PATH" + echo "No previous plugin catalog found; auto mode will publish all plugins." + fi + + - name: Plan incremental plugin release + run: | + plan_args=( + --mode "$PLUGIN_RELEASE_MODE" + --source-dir "$PLUGIN_SOURCE_DIR" + --previous-catalog "$PLUGIN_PREVIOUS_CATALOG_PATH" + --output "$PLUGIN_RELEASE_PLAN_PATH" + ) + if [[ -n "$PLUGIN_RELEASE_SELECTION" ]]; then + plan_args+=(--plugins "$PLUGIN_RELEASE_SELECTION") + fi + + scripts/plugins/plan-plugin-release.py "${plan_args[@]}" + + python3 <<'PY' + import json + import os + from pathlib import Path + + plan = json.loads(Path(os.environ["PLUGIN_RELEASE_PLAN_PATH"]).read_text(encoding="utf-8")) + selected = plan.get("selectedPluginIDs", []) + removed = plan.get("removedPluginIDs", []) + + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env: + env.write(f"PLUGIN_RELEASE_REQUIRED={'true' if plan.get('releaseRequired') else 'false'}\n") + env.write(f"PLUGIN_CHANGED_COUNT={len(selected)}\n") + env.write(f"PLUGIN_REMOVED_COUNT={len(removed)}\n") + env.write(f"PLUGIN_FULL_RELEASE={'true' if plan.get('fullRelease') else 'false'}\n") + planned_version = plan.get("pluginKitVersion") + if str(planned_version) != os.environ["PLUGIN_KIT_VERSION"]: + raise SystemExit( + f"Release plan PluginKit {planned_version} does not match manifest PluginKit " + f"{os.environ['PLUGIN_KIT_VERSION']}." + ) + + lines = [ + "## Plugin release plan", + "", + f"- Mode: `{plan.get('mode')}`", + f"- Release required: `{str(plan.get('releaseRequired')).lower()}`", + f"- Full release: `{str(plan.get('fullRelease')).lower()}`", + f"- Updated packages: `{len(selected)}`", + f"- Removed plugins: `{len(removed)}`", + ] + if selected: + lines.extend(["", "### Packages to build"]) + for plugin in plan.get("plugins", []): + reason = "; ".join(plan.get("reasons", {}).get(plugin["id"], [])) + suffix = f" - {reason}" if reason else "" + lines.append(f"- `{plugin['id']}` {plugin['version']}{suffix}") + if removed: + lines.extend(["", "### Removed from catalog"]) + lines.extend(f"- `{plugin_id}`" for plugin_id in removed) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write("\n".join(lines) + "\n") + PY + + - name: No plugin release changes + if: env.PLUGIN_RELEASE_REQUIRED != 'true' + run: echo "No plugin package or catalog changes were detected for $TAG." + + - name: Validate required secrets + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + env: + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }} + BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} + DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} + DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} + PLUGIN_CATALOG_PRIVATE_KEY_BASE64: ${{ secrets.PLUGIN_CATALOG_PRIVATE_KEY_BASE64 }} + run: | + missing=0 + + if [[ -z "$PLUGIN_CATALOG_PRIVATE_KEY_BASE64" ]]; then + echo "Missing required secret: PLUGIN_CATALOG_PRIVATE_KEY_BASE64" >&2 + missing=1 + fi + + if [[ "$PLUGIN_CHANGED_COUNT" != "0" ]]; then + for name in \ + APPLE_DEVELOPMENT_TEAM \ + BUNDLE_IDENTIFIER_PREFIX \ + DEVELOPER_ID_CERT_P12 \ + DEVELOPER_ID_CERT_PASSWORD; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: $name" >&2 + missing=1 + fi + done + fi + + exit "$missing" + + - name: Show Xcode version + if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0' + run: xcodebuild -version + + - name: Install XcodeGen + if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0' + run: | + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + + - name: Install catalog signing dependency + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + run: | + python3 -m venv "$RUNNER_TEMP/plugin-signing-venv" + "$RUNNER_TEMP/plugin-signing-venv/bin/python" -m pip install --upgrade pip + "$RUNNER_TEMP/plugin-signing-venv/bin/python" -m pip install cryptography + echo "$RUNNER_TEMP/plugin-signing-venv/bin" >> "$GITHUB_PATH" + + - name: Write release local config + if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0' + env: + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }} + BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} + run: | + umask 077 + cat > LocalConfig.xcconfig < "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" \ + -k "$KEYCHAIN_PATH" \ + -P "$DEVELOPER_ID_CERT_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + SIGNING_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | awk -F'"' '/Developer ID Application/ { print $2; exit }')" + if [[ -z "$SIGNING_IDENTITY" ]]; then + echo "Developer ID Application identity was not found in imported certificate." >&2 + exit 1 + fi + + rm -f "$CERT_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV" + + - name: Generate Xcode project + if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0' + run: make generate + + - name: Build plugin release assets + if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0' + run: | + selected_plugins=() + while IFS= read -r plugin_id; do + [[ -n "$plugin_id" ]] && selected_plugins+=("$plugin_id") + done < <(python3 - <<'PY' + import json + import os + from pathlib import Path + + plan = json.loads(Path(os.environ["PLUGIN_RELEASE_PLAN_PATH"]).read_text(encoding="utf-8")) + for plugin_id in plan.get("selectedPluginIDs", []): + print(plugin_id) + PY + ) + + build_args=( + --source-dir "$PLUGIN_SOURCE_DIR" + --build-dir "$PLUGIN_BUILD_DIR" + --dist-dir "$PLUGIN_RELEASE_ROOT" + --assets-dir "$PLUGIN_ASSETS_DIR" + --base-url "$PLUGIN_ASSET_BASE_URL" + --catalog-output "$PLUGIN_CATALOG_DELTA_PATH" + --sign-identity "$SIGNING_IDENTITY" + --destination "generic/platform=macOS" + --xcodebuild "$PWD/scripts/xcodebuild-filtered.sh" + --release-notes-url "$PLUGIN_RELEASE_NOTES_URL" + ) + for plugin_id in "${selected_plugins[@]}"; do + build_args+=(--plugin "$plugin_id") + done + + CODE_SIGN_KEYCHAIN="$KEYCHAIN_PATH" scripts/plugins/build-plugin-release-assets.sh "${build_args[@]}" + + - name: Merge and sign plugin catalog + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + env: + PLUGIN_CATALOG_PRIVATE_KEY_BASE64: ${{ secrets.PLUGIN_CATALOG_PRIVATE_KEY_BASE64 }} + run: | + scripts/plugins/merge-plugin-catalog.py \ + --previous "$PLUGIN_PREVIOUS_CATALOG_PATH" \ + --updates "$PLUGIN_CATALOG_DELTA_PATH" \ + --plan "$PLUGIN_RELEASE_PLAN_PATH" \ + --plugin-kit-version "$PLUGIN_KIT_VERSION" \ + --output "$PLUGIN_CATALOG_PATH" + + scripts/plugins/sign-plugin-catalog.sh \ + --input "$PLUGIN_CATALOG_PATH" \ + --output "$SIGNED_PLUGIN_CATALOG_PATH" \ + --private-key-base64 "$PLUGIN_CATALOG_PRIVATE_KEY_BASE64" + + - name: Generate plugin release notes + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + run: | + RELEASE_NOTES="$RUNNER_TEMP/plugin-release-notes.md" + python3 scripts/changelog.py extract \ + --tag "$TAG" \ + --output "$RELEASE_NOTES" + echo "PLUGIN_RELEASE_NOTES_PATH=$RELEASE_NOTES" >> "$GITHUB_ENV" + + - name: Create or update plugin GitHub Release + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + shopt -s nullglob + assets=("$PLUGIN_ASSETS_DIR"/*.mactoolsplugin.zip) + + publish_args=( + --repo "$GITHUB_REPOSITORY" + --tag "$TAG" + --title "$PLUGIN_RELEASE_TITLE" + --notes-file "$PLUGIN_RELEASE_NOTES_PATH" + --allow-empty + ) + if [[ "$PLUGIN_PRERELEASE" == "true" ]]; then + publish_args+=(--prerelease) + fi + for asset in "${assets[@]}"; do + publish_args+=(--asset "$asset") + done + + scripts/plugins/publish-plugin-release.sh "${publish_args[@]}" + + - name: Upload plugin release artifact + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + uses: actions/upload-artifact@v4 + with: + name: MacTools-${{ env.TAG }} + path: | + ${{ env.PLUGIN_ASSETS_DIR }}/*.mactoolsplugin.zip + ${{ env.PLUGIN_RELEASE_PLAN_PATH }} + ${{ env.PLUGIN_CATALOG_PATH }} + ${{ env.SIGNED_PLUGIN_CATALOG_PATH }} + if-no-files-found: error + retention-days: 30 + + - name: Commit plugin catalog to main + if: env.PLUGIN_RELEASE_REQUIRED == 'true' + run: | + cp "$SIGNED_PLUGIN_CATALOG_PATH" "$RUNNER_TEMP/plugin-catalog.json" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin main + git checkout -B main origin/main + mkdir -p "$(dirname "$PLUGIN_CATALOG_RELATIVE_PATH")" + cp "$RUNNER_TEMP/plugin-catalog.json" "$PLUGIN_CATALOG_RELATIVE_PATH" + git add "$PLUGIN_CATALOG_RELATIVE_PATH" + if git diff --cached --quiet; then + echo "No plugin catalog changes to commit." + else + git commit -m "ci: update plugin catalog for ${TAG}" + git push origin main + fi + + - name: Cleanup keychain + if: always() + run: | + if [[ -n "${KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true + fi diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..57fdb28 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,208 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + type: + description: "Release type" + required: true + default: app + type: choice + options: + - app + - plugin + version: + description: "Target version without prefix, for example 1.0.7" + required: true + type: string + release: + description: "Trigger release workflow after tag is pushed" + required: true + default: true + type: boolean + plugin_mode: + description: "Plugin release mode" + required: false + default: auto + type: choice + options: + - auto + - selected + - all + plugins: + description: "Comma-separated plugin IDs or directory names when plugin_mode=selected" + required: false + type: string + +permissions: + contents: write + actions: write + +concurrency: + group: prepare-release-${{ inputs.type }} + cancel-in-progress: false + +jobs: + prepare: + name: Prepare ${{ inputs.type }} release + runs-on: macos-26 + timeout-minutes: 45 + + env: + CI_BUNDLE_IDENTIFIER_PREFIX: dev.mactools.ci + + steps: + - name: Checkout main + uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - name: Ensure main branch + run: git checkout main + + - name: Validate inputs + env: + INPUT_TYPE: ${{ inputs.type }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }} + INPUT_PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + + if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version must look like 1.2.3 and must not include v or plugins-." >&2 + exit 1 + fi + + if [[ "$INPUT_TYPE" == "app" ]]; then + if [[ "$INPUT_PLUGIN_MODE" != "auto" || -n "$INPUT_PLUGINS" ]]; then + echo "plugin_mode/plugins are only valid for plugin releases." >&2 + exit 1 + fi + elif [[ "$INPUT_TYPE" == "plugin" ]]; then + if [[ "$INPUT_PLUGIN_MODE" != "selected" && -n "$INPUT_PLUGINS" ]]; then + echo "plugins input is only valid when plugin_mode=selected." >&2 + exit 1 + fi + if [[ "$INPUT_PLUGIN_MODE" == "selected" && -z "$INPUT_PLUGINS" ]]; then + echo "plugins input is required when plugin_mode=selected." >&2 + exit 1 + fi + else + echo "Unknown type: $INPUT_TYPE" >&2 + exit 1 + fi + + - name: Show Xcode version + run: xcodebuild -version + + - name: Install XcodeGen + run: | + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + + - name: Write CI local config + run: | + cat > LocalConfig.xcconfig <> "$GITHUB_ENV" + + - name: Prepare plugin release + if: inputs.type == 'plugin' + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }} + INPUT_PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + + release_args=( + --type plugin + --version "$INPUT_VERSION" + --plugin-mode "$INPUT_PLUGIN_MODE" + --yes + ) + if [[ -n "$INPUT_PLUGINS" ]]; then + release_args+=(--plugin "$INPUT_PLUGINS") + fi + + scripts/release.py "${release_args[@]}" + + { + echo "RELEASE_TAG=plugins-${INPUT_VERSION}" + echo "RELEASE_WORKFLOW=plugin-release.yml" + } >> "$GITHUB_ENV" + + - name: Write summary + env: + INPUT_TYPE: ${{ inputs.type }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_RELEASE: ${{ inputs.release }} + INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }} + INPUT_PLUGINS: ${{ inputs.plugins }} + run: | + { + echo "## Prepare release" + echo "" + echo "- Type: \`${INPUT_TYPE}\`" + echo "- Version: \`${INPUT_VERSION}\`" + echo "- Tag: \`${RELEASE_TAG}\`" + echo "- Trigger release workflow: \`${INPUT_RELEASE}\`" + if [[ "$INPUT_TYPE" == "plugin" ]]; then + echo "- Plugin mode: \`${INPUT_PLUGIN_MODE}\`" + if [[ -n "$INPUT_PLUGINS" ]]; then + echo "- Plugins: \`${INPUT_PLUGINS}\`" + fi + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Trigger release workflow + if: inputs.release + env: + GH_TOKEN: ${{ github.token }} + INPUT_TYPE: ${{ inputs.type }} + INPUT_PLUGIN_MODE: ${{ inputs.plugin_mode }} + INPUT_PLUGINS: ${{ inputs.plugins }} + run: | + set -euo pipefail + + if [[ "$INPUT_TYPE" == "app" ]]; then + gh workflow run "$RELEASE_WORKFLOW" --ref main -f tag="$RELEASE_TAG" + else + workflow_args=( + --ref main + -f tag="$RELEASE_TAG" + -f mode="$INPUT_PLUGIN_MODE" + -f prerelease=false + ) + if [[ -n "$INPUT_PLUGINS" ]]; then + workflow_args+=(-f plugins="$INPUT_PLUGINS") + fi + gh workflow run "$RELEASE_WORKFLOW" "${workflow_args[@]}" + fi + + echo "Triggered ${RELEASE_WORKFLOW} for ${RELEASE_TAG}." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a027a15 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,586 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + - "v*.*.*-*" + workflow_dispatch: + inputs: + tag: + description: "Tag to release, for example v0.9.2 or v0.9.2-beta.1" + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +env: + PROJECT_NAME: MacTools + SCHEME: MacTools + DERIVED_DATA: build/DerivedData + ARTIFACT_DIR: build/release + +jobs: + release: + name: Build, sign, notarize, and release + runs-on: macos-26 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Set release metadata + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Release tag must look like v1.2.3 or v1.2.3-beta.1" >&2 + exit 1 + fi + + VERSION="$(awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)" + BUILD_NUMBER="$(awk '$1 == "CURRENT_PROJECT_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)" + TAG_VERSION="${TAG#v}" + + if [[ -z "$VERSION" || -z "$BUILD_NUMBER" ]]; then + echo "MARKETING_VERSION and CURRENT_PROJECT_VERSION must be set in Configs/AppVersion.xcconfig" >&2 + exit 1 + fi + if [[ "$TAG_VERSION" != "$VERSION" ]]; then + echo "Release tag $TAG does not match Configs/AppVersion.xcconfig MARKETING_VERSION $VERSION" >&2 + exit 1 + fi + if [[ ! "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then + echo "Configs/AppVersion.xcconfig CURRENT_PROJECT_VERSION must be numeric" >&2 + exit 1 + fi + + PRERELEASE="false" + if [[ "$VERSION" == *"-"* || "$VERSION" == *"beta"* || "$VERSION" == *"alpha"* || "$VERSION" == *"rc"* ]]; then + PRERELEASE="true" + fi + + { + echo "TAG=$TAG" + echo "VERSION=$VERSION" + echo "BUILD_NUMBER=$BUILD_NUMBER" + echo "PRERELEASE=$PRERELEASE" + echo "DMG_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.dmg" + echo "SHA256_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.sha256" + } >> "$GITHUB_ENV" + + - name: Validate required secrets + env: + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }} + BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} + DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} + DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + missing=0 + for name in \ + APPLE_DEVELOPMENT_TEAM \ + BUNDLE_IDENTIFIER_PREFIX \ + DEVELOPER_ID_CERT_P12 \ + DEVELOPER_ID_CERT_PASSWORD \ + ASC_API_KEY_P8_BASE64 \ + ASC_API_KEY_ID \ + ASC_API_ISSUER_ID \ + SPARKLE_PRIVATE_KEY; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: $name" >&2 + missing=1 + fi + done + exit "$missing" + + - name: Show Xcode version + run: xcodebuild -version + + - name: Install XcodeGen + run: | + if ! command -v xcodegen >/dev/null 2>&1; then + brew install xcodegen + fi + + - name: Write release local config + env: + APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }} + BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} + run: | + umask 077 + cat > LocalConfig.xcconfig < "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" \ + -k "$KEYCHAIN_PATH" \ + -P "$DEVELOPER_ID_CERT_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + SIGNING_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | awk -F'"' '/Developer ID Application/ { print $2; exit }')" + if [[ -z "$SIGNING_IDENTITY" ]]; then + echo "Developer ID Application identity was not found in imported certificate." >&2 + exit 1 + fi + + rm -f "$CERT_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV" + + - name: Generate Xcode project + run: make generate + + - name: Build unsigned Release app + run: | + ./scripts/xcodebuild-filtered.sh \ + -project "${PROJECT_NAME}.xcodeproj" \ + -scheme "${SCHEME}" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -derivedDataPath "${DERIVED_DATA}" \ + MARKETING_VERSION="${VERSION}" \ + CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY= \ + build \ + -quiet + + - name: Sign app bundle + run: | + APP_PATH="${DERIVED_DATA}/Build/Products/Release/${PROJECT_NAME}.app" + ENTITLEMENTS="Configs/MacTools.entitlements" + FINDER_SYNC_ENTITLEMENTS="Sources/Extensions/RightClickFinderSync/RightClickFinderSync.entitlements" + SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework" + SPARKLE_CURRENT="$SPARKLE_FRAMEWORK/Versions/Current" + + sign_path() { + /usr/bin/codesign \ + --force \ + --sign "$SIGNING_IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + --options runtime \ + --timestamp \ + "$1" + } + + sign_path_with_entitlements() { + /usr/bin/codesign \ + --force \ + --sign "$SIGNING_IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + --options runtime \ + --timestamp \ + --entitlements "$2" \ + "$1" + } + + sign_path_preserving_entitlements() { + /usr/bin/codesign \ + --force \ + --sign "$SIGNING_IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + --options runtime \ + --timestamp \ + --preserve-metadata=entitlements \ + "$1" + } + + if [[ -d "$SPARKLE_FRAMEWORK" ]]; then + if [[ -d "$SPARKLE_CURRENT/XPCServices/Installer.xpc" ]]; then + sign_path "$SPARKLE_CURRENT/XPCServices/Installer.xpc" + fi + if [[ -d "$SPARKLE_CURRENT/XPCServices/Downloader.xpc" ]]; then + sign_path_preserving_entitlements "$SPARKLE_CURRENT/XPCServices/Downloader.xpc" + fi + if [[ -f "$SPARKLE_CURRENT/Autoupdate" ]]; then + sign_path "$SPARKLE_CURRENT/Autoupdate" + fi + if [[ -d "$SPARKLE_CURRENT/Updater.app" ]]; then + sign_path "$SPARKLE_CURRENT/Updater.app" + fi + fi + + while IFS= read -r binary; do + [[ -n "$binary" ]] || continue + sign_path "$binary" + done < <(find "$APP_PATH/Contents" -type f \( -name "*.dylib" -o -name "*.so" \) -print) + + while IFS= read -r bundle; do + [[ -n "$bundle" ]] || continue + if [[ "$bundle" == *"/Sparkle.framework" || "$bundle" == *"/Sparkle.framework/"* ]]; then + continue + fi + if [[ "$(basename "$bundle")" == "RightClickFinderSync.appex" ]]; then + sign_path_with_entitlements "$bundle" "$FINDER_SYNC_ENTITLEMENTS" + else + sign_path "$bundle" + fi + done < <(find "$APP_PATH/Contents" -depth -type d \( -name "*.framework" -o -name "*.app" -o -name "*.xpc" -o -name "*.appex" \) -print) + + if [[ -d "$SPARKLE_FRAMEWORK" ]]; then + sign_path "$SPARKLE_FRAMEWORK" + fi + + /usr/bin/codesign \ + --force \ + --sign "$SIGNING_IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + --options runtime \ + --timestamp \ + --entitlements "$ENTITLEMENTS" \ + "$APP_PATH" + + FINDER_SYNC_APPEX="$APP_PATH/Contents/PlugIns/RightClickFinderSync.appex" + FINDER_SYNC_EXTENSION_POINT="$(/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPointIdentifier" "$FINDER_SYNC_APPEX/Contents/Info.plist")" + if [[ "$FINDER_SYNC_EXTENSION_POINT" != "com.apple.FinderSync" ]]; then + echo "Finder Sync extension point is invalid: $FINDER_SYNC_EXTENSION_POINT" >&2 + exit 1 + fi + FINDER_SYNC_SIGNED_ENTITLEMENTS="$(/usr/bin/codesign -d --entitlements :- "$FINDER_SYNC_APPEX" 2>/dev/null || true)" + if [[ "$FINDER_SYNC_SIGNED_ENTITLEMENTS" != *"com.apple.security.app-sandbox"* ]]; then + echo "Finder Sync extension is missing app sandbox entitlement" >&2 + exit 1 + fi + if [[ "$FINDER_SYNC_SIGNED_ENTITLEMENTS" != *"com.apple.security.temporary-exception.files.home-relative-path.read-only"* ]]; then + echo "Finder Sync extension is missing right-click config read entitlement" >&2 + exit 1 + fi + + /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" + + - name: Create and sign DMG + env: + BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} + run: | + APP_PATH="${DERIVED_DATA}/Build/Products/Release/${PROJECT_NAME}.app" + STAGE_DIR="$(mktemp -d "${RUNNER_TEMP}/mactools-dmg.XXXXXX")" + mkdir -p "$(dirname "$DMG_PATH")" + + ditto "$APP_PATH" "$STAGE_DIR/${PROJECT_NAME}.app" + ln -s /Applications "$STAGE_DIR/Applications" + + hdiutil create \ + -volname "$PROJECT_NAME" \ + -srcfolder "$STAGE_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" >/dev/null + + /bin/rm -rf "$STAGE_DIR" + + /usr/bin/codesign \ + --force \ + --sign "$SIGNING_IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + --timestamp \ + --identifier "${BUNDLE_IDENTIFIER_PREFIX}.mactools.disk-image" \ + "$DMG_PATH" + + /usr/bin/codesign --verify --verbose=2 "$DMG_PATH" + + - name: Notarize and staple DMG + env: + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + run: | + API_KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_API_KEY_ID}.p8" + echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH" + + xcrun notarytool submit "$DMG_PATH" \ + --key "$API_KEY_PATH" \ + --key-id "$ASC_API_KEY_ID" \ + --issuer "$ASC_API_ISSUER_ID" \ + --wait \ + --timeout 30m + + xcrun stapler staple "$DMG_PATH" + spctl -a -t open --context context:primary-signature -v "$DMG_PATH" + rm -f "$API_KEY_PATH" + + - name: Compute SHA256 + run: | + shasum -a 256 "$DMG_PATH" | tee "$SHA256_PATH" + + - name: Extract release notes from CHANGELOG + run: | + RELEASE_NOTES="$RUNNER_TEMP/release-notes.md" + HIGHLIGHTS_PATH=".github/release-highlights/${TAG}.md" + GENERATED_NOTES="$RUNNER_TEMP/release-notes.generated.md" + + python3 scripts/changelog.py extract \ + --tag "$TAG" \ + --output "$GENERATED_NOTES" + + if [[ -f "$HIGHLIGHTS_PATH" ]]; then + cat "$HIGHLIGHTS_PATH" > "$RELEASE_NOTES" + printf '\n\n' >> "$RELEASE_NOTES" + cat "$GENERATED_NOTES" >> "$RELEASE_NOTES" + else + cat "$GENERATED_NOTES" > "$RELEASE_NOTES" + fi + + SPARKLE_RELEASE_NOTES="$RUNNER_TEMP/sparkle-release-notes.md" + python3 scripts/changelog.py compose-sparkle \ + --tag "$TAG" \ + --app-notes "$RELEASE_NOTES" \ + --output "$SPARKLE_RELEASE_NOTES" + + { + echo "RELEASE_NOTES_PATH=$RELEASE_NOTES" + echo "SPARKLE_RELEASE_NOTES_PATH=$SPARKLE_RELEASE_NOTES" + } >> "$GITHUB_ENV" + + - name: Generate Sparkle appcast + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + SIGN_UPDATE="${DERIVED_DATA}/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" + if [[ ! -x "$SIGN_UPDATE" ]]; then + SIGN_UPDATE="$(find "${DERIVED_DATA}/SourcePackages" -path "*/Sparkle/bin/sign_update" -type f -perm -111 | head -n 1)" + fi + if [[ -z "$SIGN_UPDATE" || ! -x "$SIGN_UPDATE" ]]; then + echo "Sparkle sign_update tool was not found." >&2 + exit 1 + fi + + SPARKLE_KEY_PATH="$RUNNER_TEMP/sparkle_ed25519_key" + printf '%s' "$SPARKLE_PRIVATE_KEY" > "$SPARKLE_KEY_PATH" + SIGN_OUTPUT="$("$SIGN_UPDATE" "$DMG_PATH" --ed-key-file "$SPARKLE_KEY_PATH")" + rm -f "$SPARKLE_KEY_PATH" + + ED_SIGNATURE="$(printf '%s' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')" + if [[ -z "$ED_SIGNATURE" ]]; then + echo "Failed to extract Sparkle EdDSA signature." >&2 + exit 1 + fi + + FILE_SIZE="$(stat -f '%z' "$DMG_PATH")" + DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PROJECT_NAME}.dmg" + RELEASE_NOTES_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" + FULL_RELEASE_NOTES_URL="https://github.com/${GITHUB_REPOSITORY}/releases" + PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" + MINIMUM_SYSTEM_VERSION="14.0" + export ED_SIGNATURE FILE_SIZE DOWNLOAD_URL RELEASE_NOTES_URL FULL_RELEASE_NOTES_URL PUB_DATE MINIMUM_SYSTEM_VERSION + + python3 <<'PY_APPCAST' + import os + import xml.sax.saxutils as xml + from pathlib import Path + + values = { + key: os.environ[key] + for key in [ + "PROJECT_NAME", + "VERSION", + "BUILD_NUMBER", + "DOWNLOAD_URL", + "RELEASE_NOTES_URL", + "FULL_RELEASE_NOTES_URL", + "PUB_DATE", + "FILE_SIZE", + "ED_SIGNATURE", + "MINIMUM_SYSTEM_VERSION", + ] + } + release_notes = Path(os.environ["SPARKLE_RELEASE_NOTES_PATH"]).read_text(encoding="utf-8") + cdata_release_notes = release_notes.replace("]]>", "]]]]>") + + content = f''' + + + {xml.escape(values["PROJECT_NAME"])} Releases + Latest release metadata for {xml.escape(values["PROJECT_NAME"])}. + en + + Version {xml.escape(values["VERSION"])} + {xml.escape(values["RELEASE_NOTES_URL"])} + {xml.escape(values["BUILD_NUMBER"])} + {xml.escape(values["VERSION"])} + + {xml.escape(values["FULL_RELEASE_NOTES_URL"])} + {xml.escape(values["PUB_DATE"])} + + {xml.escape(values["MINIMUM_SYSTEM_VERSION"])} + + + + ''' + Path("docs/appcast.xml").write_text(content, encoding="utf-8") + PY_APPCAST + + python3 -c 'import xml.etree.ElementTree as ET; ET.parse("docs/appcast.xml")' + + - name: Upload notarized DMG artifact + uses: actions/upload-artifact@v4 + with: + name: MacTools-${{ env.TAG }} + path: | + ${{ env.DMG_PATH }} + ${{ env.SHA256_PATH }} + if-no-files-found: error + retention-days: 30 + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + RELEASE_ARGS=(--title "${PROJECT_NAME} ${VERSION}" --notes-file "$RELEASE_NOTES_PATH") + if [[ "$PRERELEASE" == "true" ]]; then + RELEASE_ARGS+=(--prerelease) + fi + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" --clobber + gh release edit "$TAG" "${RELEASE_ARGS[@]}" + else + gh release create "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" "${RELEASE_ARGS[@]}" + fi + + - name: Open Homebrew tap update PR + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} + run: | + TAP_REPOSITORY="ggbond268/homebrew-mactools" + + if [[ "$PRERELEASE" == "true" ]]; then + echo "Skipping Homebrew tap update for prerelease ${TAG}." + exit 0 + fi + + if [[ -z "$HOMEBREW_GITHUB_API_TOKEN" ]]; then + echo "Skipping Homebrew tap update because HOMEBREW_GITHUB_API_TOKEN is not configured." + exit 0 + fi + + export GH_TOKEN="$HOMEBREW_GITHUB_API_TOKEN" + + SHA256="$(awk '{ print $1; exit }' "$SHA256_PATH")" + if [[ -z "$SHA256" ]]; then + echo "Unable to read SHA256 from ${SHA256_PATH}." >&2 + exit 1 + fi + DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PROJECT_NAME}.dmg" + export SHA256 DOWNLOAD_URL + TAP_DIR="$(mktemp -d)" + BRANCH="update-mactools-${VERSION}" + + gh repo clone "$TAP_REPOSITORY" "$TAP_DIR" + cd "$TAP_DIR" + gh auth setup-git + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + + mkdir -p Casks + ruby -e 'File.write("Casks/mactools.rb", <<~CASK) + cask "mactools" do + version "#{ENV.fetch("VERSION")}" + sha256 "#{ENV.fetch("SHA256")}" + + url "#{ENV.fetch("DOWNLOAD_URL")}" + name "MacTools" + desc "Menu bar toolbox" + homepage "https://github.com/#{ENV.fetch("GITHUB_REPOSITORY")}" + + livecheck do + url :url + strategy :github_latest + end + + auto_updates true + depends_on macos: :sonoma + + app "MacTools.app" + end + CASK' + + brew style Casks/mactools.rb + + if git diff --quiet -- Casks/mactools.rb; then + echo "Homebrew tap cask is already up to date." + exit 0 + fi + + git add Casks/mactools.rb + git commit -m "Update mactools to ${VERSION}" + git push --force-with-lease origin "$BRANCH" + + pr_number="$(gh pr list --repo "$TAP_REPOSITORY" --head "$BRANCH" --json number --jq '.[0].number // empty')" + if [[ -n "$pr_number" ]]; then + echo "Homebrew tap update PR already exists: #${pr_number}" + else + pr_url="$(gh pr create \ + --repo "$TAP_REPOSITORY" \ + --base main \ + --head "$BRANCH" \ + --title "Update mactools to ${VERSION}" \ + --body "Updates the MacTools cask for ${TAG}.")" + pr_number="${pr_url##*/}" + echo "Created Homebrew tap update PR: #${pr_number}" + fi + + gh pr merge "$pr_number" \ + --repo "$TAP_REPOSITORY" \ + --squash \ + --delete-branch \ + --subject "Update mactools to ${VERSION}" + + - name: Commit appcast to main + run: | + cp docs/appcast.xml "$RUNNER_TEMP/appcast.xml" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin main + git checkout -B main origin/main + cp "$RUNNER_TEMP/appcast.xml" docs/appcast.xml + git add docs/appcast.xml + if git diff --cached --quiet; then + echo "No appcast changes to commit." + else + git commit -m "ci: update appcast for ${TAG}" + git push origin main + fi + + - name: Cleanup keychain + if: always() + run: | + if [[ -n "${KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb17c06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +MacTools.xcodeproj +MacTools.xcworkspace +LocalConfig.xcconfig +Configs/GeneratedPlugins.yml +scripts/release.local.env +build/ +.build/ +site/node_modules/ +site/dist/ +site/.astro/ +site/public/assets +__pycache__/ +.DS_Store +*.log +CLAUDE.local.md + +xcuserdata/ +*.xcuserdatad +*.xcuserstate +*.pbxuser + +# Machine-local Claude Code session lock (records a local PID/session id) +.claude/scheduled_tasks.lock diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fcbfac3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +# Agent Instructions for MacTools + +## Instruction Scope +- This file is the canonical agent guide for this repository and applies to the entire repo. +- If a closer `AGENTS.md` appears in a subdirectory in the future, the closer file takes precedence. +- `CLAUDE.md` and `GEMINI.md` are compatibility entry points only; shared rules should be maintained here first. + +## Project Overview +- MacTools is a native macOS menu-bar utility collection for frequent, lightweight, non-disruptive system tasks. +- The stack is Swift 6 with SwiftUI + AppKit, targeting macOS 14.0 or later. +- Features are organized as plugins. Current plugin source lives under `Plugins//` and is integrated into the host through `MacToolsPluginKit`, dynamic plugin packages, and catalogs. +- User-facing copy is currently primarily Chinese. New copy should stay concise, clear, and close to native macOS phrasing. + +## Key Directories +- `Sources/App/`: app entry point, menu-bar status item, panels, settings pages, and window routing. +- `Sources/Core/Plugins/`: plugin host, dynamic plugin loading, package installation, catalog validation, and display preferences. +- `Sources/Core/Shortcuts/`: global shortcut models, storage, and management. +- `Sources/Core/Permissions/`: system permission checks. +- `Sources/Core/Diagnostics/`: shared logging entry points. +- `Sources/Core/Updates/`: Sparkle update checks and About-page update state. +- `Sources/MacToolsPluginKit/`: plugin protocols, declarative UI models, shortcut models, and runtime context. +- `Plugins//`: plugin manifest, source, bundle entry point, resources, and adjacent tests. +- `Tests/`: XCTest coverage for shared App/Core logic; plugin tests should prefer the corresponding plugin directory. +- `Configs/`: Xcode build settings and `Info.plist`. +- `docs/plugins/`: plugin package, catalog, local debugging, and release-process documentation. +- `docs/superpowers/`: larger product/interaction specs and implementation plans. +- `scripts/`: release, signing, notarization, and GitHub Release helper scripts. + +## Build And Run +- Run `make setup` first to initialize `LocalConfig.xcconfig`, then fill in `DEVELOPMENT_TEAM` and `BUNDLE_IDENTIFIER_PREFIX`. +- `project.yml` is the root XcodeGen source. Plugin targets and schemes are generated locally into `Configs/GeneratedPlugins.yml` by `scripts/plugins/generate-plugin-project-config.rb`, which scans `Plugins/*/plugin.json`; that generated file is not committed. +- Generate the project with `make generate`. Do not run bare `xcodegen generate`, because it can miss the latest generated plugin configuration. +- Validate compilation with `make build`. +- Run locally with `make run`; it syncs the latest Debug plugin packages and generates the local development catalog. +- Sync only already-built Debug plugin packages and the local development catalog with `make sync-debug-plugins`. +- Build the local plugin packages and generate the Debug catalog with `make build-plugin`. +- Build one plugin with `make build-plugin PLUGIN=`. +- Run the full test suite with `xcodebuild -project MacTools.xcodeproj -scheme MacTools -configuration Debug -derivedDataPath build/DerivedData test -quiet`. +- Run one test class by appending `-only-testing:MacToolsTests/` to the full test command. +- Use `./scripts/release-local.sh` only when a release is needed; confirm user intent before signing, notarizing, publishing, or tagging. + +## Architecture Conventions +- Add new plugins under `Plugins//` with at least `plugin.json`, `Sources/`, and `Bundle/`. +- Plugins implement `MacToolsPlugin`; menu-bar primary panels implement `PluginPrimaryPanel`, and component panels implement `PluginComponentPanel`. +- `plugin.json.id` must be stable, readable, and exactly match the runtime `PluginMetadata.id`; each `.mactoolsplugin` package must return exactly one plugin instance. +- `PluginHost` owns plugin ordering, visibility, shortcuts, permission cards, and derived display state. Individual plugins should not manipulate host UI directly. +- Plugin UI should be expressed through declarative models such as `PluginPanelState`, `PluginPanelDetail`, and `PluginPanelControl`. Except for `PluginComponentPanel.makeView`, avoid bypassing the existing panel framework with custom menu-bar UI. +- Plugin state and UI-related code should normally run on `@MainActor`. Long-running scans, filesystem work, or system calls should not block the main thread for extended periods. `primaryPanelState` and `componentPanelState` should read existing snapshots whenever possible, not synchronously scan hardware, filesystems, or networks from getters. +- `PluginHost` is responsible only for deriving common display state such as panel items, component items, and settings items. It caches component views and coalesces short-window state rebuilds; business-data snapshots, cache invalidation, and refresh timing remain the plugin or component's responsibility. +- After plugin state changes, call `onStateChange?()` so the host can rebuild derived state. If state can change due to external system events such as display hot-plugging, permission changes, filesystem changes, or calendar authorization changes, wire an explicit observer or refresh entry point with debounce/throttling. Do not depend on users expanding a panel, switching settings pages, or a full `refreshAll()` to get fresh data. Exception: high-frequency event sources such as input statistics or sampling counters may update only their own snapshots instead of calling `onStateChange?()` for every event; they must throttle UI notifications by visibility or a fixed time window and ensure `refresh()` or user panel opening can read the latest snapshot. +- External state changes with cross-plugin value should be abstracted into Core-layer protocols or observers first. For example, display-topology changes should use `DisplayConfigurationObserving` to notify the host, then refresh display-related plugins that implement `DisplayTopologyRefreshing`. +- Control IDs, plugin IDs, and shortcut IDs must be stable, readable, and preferably centralized in private constants within the feature. +- Ordinary new plugins do not need root `project.yml` changes. Keep `plugin.json.build.scheme` pointing to the bundle scheme; the generator creates the core target, bundle target, test dependencies, and plugin scheme. If a plugin needs extra frameworks, include paths, bundle resources, or target overrides, declare only the minimal delta in `Plugins//project.yml`. + +## Plugin Settings UI Guidelines +- Plugin settings pages should use the host settings-page framework by default. `PluginConfiguration` should provide only the current plugin's configuration content; page title, icon, description, permission cards, shortcut cards, and other common regions are derived and rendered by `PluginHost`/`SettingsView`. Do not duplicate a full-page title inside custom plugin configuration. +- Prefer declarative models such as `settingsSections`, `permissionRequirements`, and `shortcutDefinitions` for new settings. Use `PluginConfiguration.makeView` only for complex interactions, lists, drag and drop, charts, or dedicated managers. +- Settings theme constants must use `MacToolsPluginKit.PluginSettingsTheme`. Plugin targets must not depend on `Sources/App/SettingsStyle.swift` or copy private settings-style definitions. When the theme needs extension, add it to `PluginSettingsTheme` first so dependencies remain host App -> PluginKit and plugin -> PluginKit. +- Use `FanControlPresetManagerView` as the visual baseline for custom plugin settings typography, expressed through `PluginSettingsTheme.Typography`: `pageTitle` for page titles and `pageDescription` for page descriptions; section headers should use `Label` + SF Symbol + `sectionTitle` + `.foregroundStyle(.secondary)`; normal row titles should use `rowTitle`; emphasized row titles or table headers should use `emphasizedRowTitle`; descriptions, help text, and subtitles should use `rowDescription`; status badges should use `statusBadge`; fixed-width numeric readings should use `monospacedValue`. These tokens should map to Apple platform semantic fonts such as `.title2`, `.body`, and `.subheadline` whenever possible, instead of scattering raw font sizes across plugins. +- Host settings-page headers use `PluginSettingsTheme.Typography.pageTitle` + `pageDescription`. Custom plugin configuration content starts at sections and should not introduce another page-level title. +- Base custom-configuration layout on Fan Control and prefer `PluginSettingsTheme.Spacing`: `section` for outer section spacing, `sectionHeaderContent` between a section header and content, `rowHorizontal` for card/list row horizontal padding, `rowVertical` for normal row vertical padding, `interactiveRowVertical` for rows containing editors or sliders, `rowTitleDescription` between row title and description, and `rowContentControl` between text and controls. +- Prefer `PluginSettingsTheme.Palette` and `Radius` for card/list containers. Use background color, spacing, and corner radius to separate regions; do not add strokes to ordinary settings cards. Host settings cards use `cardBackground`, custom plugin lists may use macOS native `nativeCardBackground`, `Radius.card` is preferred, and large host cards may use `Radius.hostCard`. +- Keep control layout stable: buttons should use system `.bordered`/`.borderedProminent` styles with `.controlSize(.small)`, switches should use `.toggleStyle(.switch)`, and sliders, pickers, text fields, and similar controls should have explicit minimum/ideal/maximum widths. Numeric text should have fixed width. Long titles and paths should use `lineLimit`, `fixedSize`, or text selection to avoid compression and layout jumps during window resizing. +- Copy should remain Chinese, short, and close to native macOS phrasing. Titles should name the object or setting, subtitles should explain effect or current state, and operation instructions should not become long prose blocks. + +## Swift Code Style +- Follow the existing Swift style: small types, clear names, early returns, and minimal global state. +- Prefer Apple native frameworks. Explain the reason before adding a third-party dependency. Plugin-private system frameworks or include paths should be declared in the plugin's own `Plugins//project.yml`. +- Add OSLog categories through `AppLog`; avoid bare `print` in app code. +- When interacting with AppKit, CoreGraphics, IOKit, EventKit, or other system APIs, preserve failure branches and fallback paths. +- Validate external inputs such as files, paths, permissions, display IDs, and shortcut bindings before use. +- Do not write local sensitive configuration such as signing certificates, notarization credentials, bundle prefixes, or development team IDs into the repository. + +## Feature Safety Boundaries +- Disk cleanup: do not bypass `DiskCleanSafetyPolicy`, allowlists, sensitive-path protection, or pre-execution secondary validation. Expanding cleanup scope requires tests. +- Physical clean mode: preserve an exit path, Accessibility permission guidance, multi-display overlays, and safe exit after sleep or lock. +- Hide notch: do not destroy the user's original wallpaper; account for multi-display, Space switching, and wallpaper-change scenarios. +- Display brightness: keep the Apple-native, DDC/CI, and Gamma/Shade fallback chain; external-display failures must not crash. +- Display resolution: confirm the display is still connected and the target mode still exists before switching; errors should be converted into user-understandable state. +- Calendar: do not assume permission is granted; insufficient permission should show clear guidance instead of failing silently. +- Update release: keep Sparkle appcast, version, signing, and notarization changes small and careful; avoid committing local release artifacts. + +## Testing Requirements +- Behavior changes should prefer adjacent XCTest additions or updates. Test files should use `Tests.swift`. +- Local and agent validation should default to the smallest relevant test method or class, such as `-only-testing:MacToolsTests/` or `-only-testing:MacToolsTests//`. Do not run the full suite for narrow changes unless the scope justifies it. +- Plugin tests should prefer `Plugins//Tests/`; shared Core/App tests should live under the corresponding `Tests/Core/` or `Tests/App/` path. +- Filesystem tests must use temporary directories or fake stores, and must never delete real user directories. +- Plugin interaction tests should cover `PluginPanelAction`, derived `PluginPanelState`, permission state, and error state. +- If tests cannot be run, explicitly state the reason and suggest the local verification command in the final response. + +## Documentation And Resources +- User-visible feature changes should update `README.md`. +- User-visible app or plugin changes should add or update one concise English changelog fragment under `changes/unreleased/*.md`. Use `release: app` for app releases and `release: plugin` for plugin batch releases, plus `type: added`, `changed`, `fixed`, `security`, `removed`, `deprecated`, `maintenance`, or `summary`. If one change needs both release channels, write one app fragment for the host/app impact and one plugin fragment for the plugin-package impact; do not duplicate the same sentence. Keep entries user-facing, avoid duplicate wording, and do not describe implementation details. Pure refactors, tests, and local-only release mechanics do not need a fragment unless users or maintainers need to know about them. +- Plugin directories, manifests, catalogs, or release-flow changes should update `docs/plugins/` and `CONTRIBUTING.md`. +- Large product/interaction changes may add date-prefixed documents under `docs/superpowers/specs/` or `docs/superpowers/plans/`. +- Icons, asset catalogs, `LocalConfig.xcconfig`, and release env files are usually maintained by the user or generation flow; avoid unrelated changes. + +## Agent Workflow +- Before modifying code, use `rg`/`rg --files` to quickly locate existing patterns and prefer adjacent implementations. +- Keep changes focused. Do not opportunistically refactor unrelated modules or overwrite existing user edits. +- After changing `project.yml`, run or recommend running `make generate`. +- Start verification from the smallest related test method or class. Consider full tests or `make build` only for cross-module changes, shared infrastructure changes, pre-release checks, or explicit user requests. +- Do not automatically commit, create branches, tag, publish releases, or clean user files unless the user explicitly asks. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bdc98c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,129 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses +[Semantic Versioning](https://semver.org/). + +Pending release notes live in `changes/unreleased/*.md` and are compiled during +the app and plugin release processes. + +## [v1.1.0] - 2026-07-12 + +### Added + +- The plugin marketplace can sort by install status (not installed first, or installed first with updates prioritized) or by name (A–Z / Z–A). + +### Changed + +- Placed plugin-specific settings between permission and shortcut sections for a more natural configuration flow. +- The in-app update dialog now includes plugin changes released since the previous app version. +- Changing the app language now refreshes all Settings and plugin controls immediately without requiring a restart. +- The language picker now shows each option in the system language alongside its native name. +- New app versions use a PluginKit-versioned plugin catalog and update installed plugins before loading them, while older app versions continue using the legacy catalog. + +### Fixed + +- Custom and gallery menu bar icons now use one standard height and content inset with consistent settings previews. + +## [plugins-1.1.0] - 2026-07-12 + +### Added + +- Device Battery can show trusted iPhone, iPad, iPod touch, Vision Pro, and paired Apple Watch battery and charging status over USB or Wi-Fi. +- Keep Awake can optionally keep the display on for every session through its plugin settings and shows an indicator while that mode is active. + +### Changed + +- Refined the native window switcher overlay and added editable, stable single-key app shortcuts. +- Device Battery now refreshes Apple mobile devices less often while the component panel is hidden. +- All plugin packages are rebuilt for PluginKit 3 and published together in a versioned catalog so they remain compatible with the new app runtime. + +### Fixed + +- Eject Disks no longer treats internal system or non-ejectable volumes as removable disks. +- Translator source text no longer overlaps the copy and speech actions when the text spans multiple lines. + +## [v1.0.31] - 2026-07-09 + +### Changed + +- Improved the menu bar icon settings previews so uploaded and online icons are easier to inspect. + +## [plugins-1.0.32] - 2026-07-09 + +### Added + +- System Status now includes settings for arranging component-panel metrics and optional compact menu bar metrics. +- Added Window Switcher for shortcut-driven window switching with direct cycling, key-based selection, configurable ordering, stable key hints, and hover quit buttons. + +### Fixed + +- Activity Bar now includes Cursor coding activity in AI work summaries and coding tool trends. +- Activity Bar now listens for installed AI hooks independently of Input Monitoring tracking. +- Activity Bar can now uninstall the AI activity hooks it adds for Claude Code, Cursor, and Codex. +- Battery Charge Limit now prefers the system charge ceiling path when available, uses the correct macOS 26 charging key, and verifies SMC writes to reduce USB-C display disconnects when charging stops at the limit. +- System Status now reports total GPU usage from the accelerator's total activity metric instead of pipeline-specific counters, avoiding false 100% history samples. +- Translator service order and enabled-state changes now persist immediately. + +## [v1.0.30] - 2026-07-07 + +### Fixed + +- Reduce unnecessary menu bar refresh work when plugins report state changes, keeping panels steadier during frequent updates. + +## [plugins-1.0.31] - 2026-07-07 + +### Added + +- Add a Homebrew management plugin for browsing packages, installing and upgrading formulae, and running diagnostics from the menu bar and settings. + +### Fixed + +- Hide Menu Bar Icons no longer quits after Accessibility is granted before Screen Recording is approved. +- Hide Menu Bar Icons now keeps the divider where you drag it instead of snapping it back next to the MacTools icon. +- Keep Awake now restores permanent sessions after restarting MacTools. + +## [v1.0.29] - 2026-07-05 + +### Fixed + +- Kept panel position stable and feature panel height accurate as controls expand. + +## [plugins-1.0.30] - 2026-07-05 + +### Changed + +- Stop Battery Charge Limit background monitoring while the charging limit is disabled. +- Pause Menu Bar Hidden background observers when the feature is hidden or not actively in use. +- Reduce System Status background sampling to lower idle energy use while keeping foreground updates responsive. + +### Fixed + +- Prevent repeated low-battery notifications when device battery readings switch sources or briefly disappear. +- Refresh Empty Trash availability when opening the feature panel. +- Fixed Mouse Enhancer settings switches rendering incorrectly on first open. +- Avoid administrator password prompts on quit when Fan Control or Battery Charge Limit did not apply SMC changes. + +## [v1.0.28] - 2026-07-03 + +### Fixed + +- Finder right-click menu items now stay hidden while the Right Click plugin is disabled. + +### Maintenance + +- App release notes now come from concise changelog fragments that are compiled into CHANGELOG.md during release. + +## [plugins-1.0.29] - 2026-07-03 + +### Added + +- Added Mouse Enhancer, a mouse and trackpad controls plugin with separate horizontal and vertical scroll reversing plus trackpad-tap middle-click simulation. + +### Fixed + +- Right Click now keeps Finder extension menu visibility aligned with the plugin's enabled state. + +### Maintenance + +- Plugin batch release notes now come from plugin changelog fragments compiled into CHANGELOG.md during release. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7154e86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md + +## Claude Code Adapter +- Canonical shared rules — build/test commands, plugin conventions, the settings-UI spec, Swift style, feature safety boundaries, test requirements, agent workflow — live in `AGENTS.md` (imported above). Read it; update it rather than duplicating rules here. +- `GEMINI.md` is a parallel thin adapter to the same `AGENTS.md`. Put personal/machine-local Claude notes in `CLAUDE.local.md` (do not commit). + +## Runtime Architecture Overview + +MacTools has a menu-bar host plus plugins architecture. Three areas must be understood together: the host (`Sources/App` + `Sources/Core/Plugins`), the plugin protocol layer (`Sources/MacToolsPluginKit`), and feature plugins (`Plugins/`). + +**Two plugin types and two loading paths** +- *Static plugins*: `Plugins//` lives in the tree and is compiled into the app by XcodeGen. `scripts/plugins/generate-plugin-project-config.rb` scans each `plugin.json`, writes the local uncommitted `Configs/GeneratedPlugins.yml`, and feeds `project.yml` into `MacTools.xcodeproj`. Ordinary new plugins should not require root `project.yml` edits. +- *Dynamic plugins*: `.mactoolsplugin` packages are handled at runtime by the `Sources/Core/Plugins/Dynamic` stack: `PluginPackageStore` installs and records packages and provides each plugin's `runtimeContext` with support/cache/temp directories plus pluginID-scoped storage; `PluginTrustValidator` validates trust and fails closed; `DynamicPluginManager`/`DynamicPluginLoader` load packages, support `pausePlugin`/`resumePlugin`, and mark hot updates for restart. `plugin.json.id` must equal the runtime `PluginMetadata.id`. + +**The host is the hub (`PluginHost`), and plugins never touch menu-bar UI directly.** The host derives renderable items from each plugin's `primaryPanelState`, `componentPanelState`, `settingsSections`, `permissionRequirements`, and `shortcutDefinitions`. It owns ordering, visibility, shortcuts, permission cards, component-view caching, and coalesced/debounced state rebuilds. The four user-visible surfaces are the menu-bar feature panel (`PluginPrimaryPanel`), component dashboard (`PluginComponentPanel`), general settings, and plugin-management page. + +**UI is declarative, not raw SwiftUI.** Use `PluginPanelState`, `PluginPanelDetail`, `PluginPanelControl`, and `PluginConfiguration` to describe UI for host rendering. Write real SwiftUI only in `PluginComponentPanel.makeView` and `PluginConfiguration.makeView`, for cases such as complex lists, drag and drop, or charts. + +**Data flow**: user action -> `handleAction(PluginPanelAction)` -> plugin mutates its own state -> plugin calls `onStateChange?()` -> host rebuilds derived state and re-renders. **External system events** such as display hot-plugging, permission changes, filesystem changes, or calendar authorization must not depend on "refresh when the user expands a panel." They need explicit observers, such as Core-layer `DisplayConfigurationObserving` notifications feeding plugins that implement `DisplayTopologyRefreshing`, with debounce where appropriate. + +**Lifecycle**: `activate(context:)` / `deactivate(reason:)`. `reason.requiresStateCleanup` distinguishes real cleanup paths such as disable, uninstall, or shutdown, where system side effects must be reverted, from `.updating` hot-update paths, where cleanup is intentionally skipped and a process restart completes unload. When touching side-effect code, do not silently swallow cleanup failures. Forced discharge, brightness overrides, wallpaper changes, event taps, and similar effects must have restoration and user-exitable paths. + +## Build Pitfalls +- `MacTools.xcodeproj`, `Configs/GeneratedPlugins.yml`, and `Configs/LocalConfig.xcconfig` are generated and gitignored. **After switching git branches, run `make generate` again.** The `.xcodeproj` references concrete files, so branch changes, added sources, or deleted sources can otherwise cause build failures or missing files. +- The platform is **macOS**, not the iOS simulator. For a quick single-plugin compile, use `-scheme Plugin -destination 'platform=macOS'`. For one test class, append `-only-testing:MacToolsTests/` to the full test command from `AGENTS.md`. +- Plugin tests live under `Plugins//Tests/`, but they run inside the `MacToolsTests` bundle. Typical imports are `@testable import MacTools` plus `@testable import Plugin`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5eafc88 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to MacTools + +[中文] [English] + +Thanks for your interest in MacTools. Please keep each contribution small and clear: explain the problem, provide verifiable changes, and avoid mixing unrelated refactors into the same pull request. + +## Ways to Contribute +- Bug reports should include reproduction steps, expected behavior, actual behavior, macOS version, and relevant logs or screenshots. +- Feature suggestions should describe the use case, target users, and expected interaction. For large plugins or interaction changes, open an issue first to align on scope. +- Changes involving file deletion, system permissions, global shortcuts, display control, signing, or update flows should explain risks, safeguards, and rollback options. + +## Development Environment +- Xcode and `xcodegen` are required. The project supports macOS 14.0 and later. +- First-time setup: run `make setup`, then edit `LocalConfig.xcconfig` and fill in `DEVELOPMENT_TEAM` and `BUNDLE_IDENTIFIER_PREFIX`. +- Common commands: `make generate` generates the Xcode project, `make build` validates compilation, and `make run` runs the app locally. +- Plugin development: `make run` incrementally builds the app and plugins, then syncs the latest Debug plugin packages to the local development marketplace. `make sync-debug-plugins` only syncs already built plugins. `make build-plugin` is reserved for validating dynamic plugin packages or release flows; to build one plugin, run `make build-plugin PLUGIN=calendar`. +- Do not commit local or generated files: `MacTools.xcodeproj`, `MacTools.xcworkspace`, `LocalConfig.xcconfig`, `build/`, or `scripts/release.local.env`. + +## Project Structure +- `Sources/App/`: app entry point, menu bar status item, settings pages, and window routing. +- `Sources/Core/`: shared infrastructure such as the plugin host, dynamic plugin loading, shortcuts, permissions, logging, and updates. +- `Sources/MacToolsPluginKit/`: plugin APIs, declarative UI models, and runtime context. +- `Plugins//`: plugin manifest, source code, bundle entry point, resources, and adjacent tests. +- `Tests/`: XCTest coverage for shared App/Core logic. Plugin tests should live inside the corresponding plugin directory when possible. +- `project.yml`: root XcodeGen project source, only for the App, PluginKit, and shared aggregate entry points. Plugin targets are generated automatically. +- `Plugins//project.yml`: optional per-plugin build overrides, only when a plugin needs extra frameworks, include paths, bundle resources, helper/tool targets, or target overrides. +- `docs/plugins/`: plugin packages, catalogs, local debugging, and release flow documentation. +- `docs/superpowers/`: larger product, interaction, or implementation design documents. + +## Development Guidelines +- Add new plugins under `Plugins//` with at least `plugin.json`, `Sources/`, and `Bundle/`. +- Ordinary plugins only need to define `plugin.json`, source code, and a bundle entry point. `make generate` scans `Plugins/*/plugin.json` and generates local `Configs/GeneratedPlugins.yml`; do not edit generated files manually. +- Features that require macOS app extensions, such as Finder Sync, must add the extension target to the root `project.yml` and embed it in the main app. Use the dynamic plugin only for the MacTools panel/settings surface. +- Command workflows for adding and updating plugins are documented in the Development Steps section of `docs/plugins/local-native-plugins.md`. +- Keep documentation short and task-focused. User-visible behavior changes should update `README.md` or the relevant file under `docs/`; plugin package, catalog, or release flow changes should update `docs/plugins/`. +- Plugins implement `MacToolsPlugin`; menu panel plugins implement `PluginPrimaryPanel`, and component panel plugins implement `PluginComponentPanel`. +- `plugin.json.id` must be stable, readable, and exactly match the runtime `PluginMetadata.id`; each plugin package should return exactly one plugin instance. +- Plugin display state should be expressed through `PluginPanelState`, `PluginPanelDetail`, `PluginPanelControl`, and related models. Do not bypass the existing panel framework. +- Prefer declarative plugin settings through `settingsSections`, `permissionRequirements`, `shortcutDefinitions`, and related models. Use a custom `PluginConfiguration` view for interactive preferences, complex managers, or specialized interactions the declarative models cannot express. +- If ordinary plugin resources rarely change, prefer bundling them into the executable. If extra bundle resources are needed, declare the smallest necessary differences in the plugin's own `project.yml`. +- Custom plugin settings views must reuse `MacToolsPluginKit.PluginSettingsTheme` and `.pluginSettingsCardBackground(...)`. 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. +- 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`. + +## Testing +- Behavioral changes should add or update adjacent XCTest coverage. Test files should be named `Tests.swift`. +- Full test command: `xcodebuild -project MacTools.xcodeproj -scheme MacTools -configuration Debug -derivedDataPath build/DerivedData test -quiet`. +- Single test class: append `-only-testing:MacToolsTests/` to the full test command. +- File system tests should use temporary directories or fake stores. Disk cleanup tests must not delete real user directories. + +## Pull Request Checklist +- Keep the PR focused, and explain the purpose, verification, and user impact. +- Prefer English for commit messages, pull request titles/descriptions, and issues. +- Build or tests have passed. If they could not be run, explain why in the PR. +- 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`. +- 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. + +## Release +- Releases are handled by maintainers. Do not create tags, publish GitHub Releases, or commit release artifacts in ordinary contributions. +- For GitHub-based releases, prefer `Actions` -> `Prepare Release`. Enter `type`, target `version`, and whether to `release`; when `release` is enabled, the workflow continues to the actual release workflow after bumping, committing, and creating the tag. +- For quick releases, prefer `make release`. The command interactively chooses `app` or `plugin`, analyzes the next `patch`/`minor`/`major` version, previews the bump, then only after confirmation runs `git pull --rebase`, lightweight checks, version updates, commit, tag creation, and tag push. +- App releases update `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `Configs/AppVersion.xcconfig`. The app and embedded extensions inherit this shared version config. After pushing a `v*.*.*` tag, the `Release` workflow builds, signs, notarizes, and uploads the DMG. +- Plugin releases push a `plugins-*` batch tag. The default `auto` mode uses the production catalog to find new plugins, already bumped plugins, and package-related plugin changes; it updates `plugin.json.version` when needed, then the `Plugin Release` workflow builds plugins and merges the signed catalog. +- On first launch, a new app version checks installed plugins and automatically updates them from the signed production catalog. It does not automatically install plugins the user has not installed. +- Non-interactive examples: `make release ARGS="--type app --version 1.0.7 --yes"` or `make release ARGS="--type plugin --version 1.0.10 --plugin-mode selected --plugin calendar --yes"`. +- Add `--dry-run` to preview the steps. The working tree must be clean before a real release. +- Before local release builds, copy `scripts/release.local.env.sample` to `scripts/release.local.env` and fill in at least `DEVELOPER_ID_APPLICATION`. +- 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, only plugins with bumped versions are built and uploaded, then merged into that line's catalog. The first release of a new ABI rebuilds every plugin and writes a versioned catalog such as `docs/plugins/v3/catalog.json`; the legacy v2 catalog remains untouched. 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/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md new file mode 100644 index 0000000..6e6ae06 --- /dev/null +++ b/CONTRIBUTING.zh-CN.md @@ -0,0 +1,76 @@ +# Contributing to MacTools + +[中文] [English] + +感谢你关注 MacTools。请让每次贡献保持小而清晰:说明问题、给出可验证改动,并避免混入无关重构。 + +## 贡献方式 +- Bug 报告请包含复现步骤、期望结果、实际结果、macOS 版本和相关日志或截图。 +- 功能建议请说明使用场景、目标用户和预期交互;大型插件或交互变更请先开 issue 对齐范围。 +- 涉及磁盘删除、系统权限、全局快捷键、显示器控制、签名或更新流程的改动,需要说明风险、保护措施和回滚方式。 + +## 开发环境 +- 需要 Xcode 和 `xcodegen`,项目最低支持 macOS 14.0。 +- 首次初始化:运行 `make setup`,再编辑 `LocalConfig.xcconfig` 填写 `DEVELOPMENT_TEAM` 和 `BUNDLE_IDENTIFIER_PREFIX`。 +- 常用命令:`make generate` 生成 Xcode 项目,`make build` 编译校验,`make run` 本地运行。 +- 插件开发:`make run` 会增量编译 App 和插件,并把最新 Debug 插件包同步到本地开发市场;`make sync-debug-plugins` 可只同步已编译插件。`make build-plugin` 保留给单独验证动态包或发布链路使用,指定插件可运行 `make build-plugin PLUGIN=calendar`。 +- 不要提交本地或生成文件:`MacTools.xcodeproj`、`MacTools.xcworkspace`、`LocalConfig.xcconfig`、`build/`、`scripts/release.local.env`。 + +## 项目结构 +- `Sources/App/`:应用入口、菜单栏状态项、设置页和窗口路由。 +- `Sources/Core/`:插件宿主、动态插件加载、快捷键、权限、日志、更新等共享基础能力。 +- `Sources/MacToolsPluginKit/`:插件 API、描述式 UI 模型和运行时上下文。 +- `Plugins//`:插件 manifest、源码、bundle 入口、资源和相邻测试。 +- `Tests/`:App/Core 共享逻辑的 XCTest;插件测试优先放在对应插件目录下。 +- `project.yml`:XcodeGen 根项目源文件,只维护 App、PluginKit 和公共聚合入口;插件 target 由生成器自动生成。 +- `Plugins//project.yml`:可选的插件构建差异配置,仅在插件需要额外 framework、include path、bundle 资源、helper/tool target 或 target 覆盖时添加。 +- `docs/plugins/`:插件包、catalog、本地调试和发布流程说明。 +- `docs/superpowers/`:较大的产品、交互或实施设计文档。 + +## 开发约定 +- 新增插件放在 `Plugins//`,至少包含 `plugin.json`、`Sources/` 和 `Bundle/`。 +- 普通插件只需要在目录内定义 `plugin.json`、源码和 bundle 入口;`make generate` 会扫描 `Plugins/*/plugin.json` 并生成本地 `Configs/GeneratedPlugins.yml`,不要手改生成文件。 +- 需要 macOS app extension 的能力(如 Finder Sync)必须在根 `project.yml` 添加扩展 target 并嵌入主应用;动态插件只负责 MacTools 面板和设置入口。 +- 新增和更新插件的命令流程见 `docs/plugins/local-native-plugins.md` 的 Development Steps。 +- 文档保持简短、聚焦任务。用户可见行为变化应同步更新 `README.md` 或 `docs/` 下的相关文档;插件包、catalog 或发布流程变化应更新 `docs/plugins/`。 +- 插件实现 `MacToolsPlugin`;菜单栏主面板实现 `PluginPrimaryPanel`,组件面板实现 `PluginComponentPanel`。 +- `plugin.json.id` 必须稳定、可读,并与运行时 `PluginMetadata.id` 完全一致;每个插件包只返回一个插件实例。 +- 插件展示状态通过 `PluginPanelState`、`PluginPanelDetail`、`PluginPanelControl` 等模型表达,不绕过现有面板框架。 +- 插件设置优先使用 `settingsSections`、`permissionRequirements`、`shortcutDefinitions` 等描述式模型;只有复杂管理器或专用交互才使用 `PluginConfiguration` 自定义视图。 +- 普通插件资源文件如果依赖变更较少,推荐直接打包到可执行二进制中;需要额外 bundle 资源时,在插件自己的 `project.yml` 中声明最小差异。 +- 自定义插件设置视图必须复用 `MacToolsPluginKit.PluginSettingsTheme` 和 `.pluginSettingsCardBackground(...)`,不要复制插件私有 settings style,也不要让插件依赖 `Sources/App/SettingsStyle.swift`。 +- 插件状态变化后调用 `onStateChange?()`;耗时扫描、文件系统和系统调用不要长时间阻塞主线程。 +- 用户可见文案以中文为主,保持简洁、清楚、接近 macOS 原生表达。 +- 用户可见文案使用 `.xcstrings` 本地化。App/Core 文案放在 `Sources/Resources/Localization`,PluginKit 文案放在 `Sources/MacToolsPluginKit/Resources`,插件文案放在 `Plugins//Resources`。插件 `plugin.json` 保留 `displayName`/`summary` 作为 fallback,并为插件市场和未加载插件展示提供 `localizedMetadata`。 +- 新插件应尽量提供多语言,至少覆盖面板文案、设置文案、权限说明和插件元数据。 +- 优先复用 Apple 原生框架;新增系统 framework、私有 include path、bundle 内辅助可执行文件时,在插件自己的 `project.yml` 中声明最小差异。需要单独签名的 bundle 资源可执行文件应写入 `plugin.json.package.signPaths`。 + +## 测试 +- 行为改动应补充或更新相邻 XCTest,测试文件命名使用 `Tests.swift`。 +- 完整测试:`xcodebuild -project MacTools.xcodeproj -scheme MacTools -configuration Debug -derivedDataPath build/DerivedData test -quiet`。 +- 单个测试类:在完整测试命令后追加 `-only-testing:MacToolsTests/`。 +- 文件系统测试使用临时目录或 fake store;磁盘清理相关测试不得删除真实用户目录。 + +## Pull Request Checklist +- PR 范围聚焦,并说明变更目的、验证方式和用户影响。 +- commit message、Pull Request 标题/描述和 issue 优先使用英文。 +- 构建或测试已通过;如无法运行,请在 PR 中说明原因。 +- 用户可见行为变化已同步更新 `README.md` 或相关设计文档。 +- 高风险功能已覆盖安全校验、错误状态和权限不足场景。 +- 不包含无关格式化、生成物、本地配置、证书或发布凭证。 + +## Release +- 发布由维护者执行;不要在普通贡献中创建 tag、发布 GitHub Release 或提交发布产物。 +- GitHub 页面发包优先使用 `Actions` → `Prepare Release`。输入 `type`、目标 `version` 和是否 `release`;勾选 `release` 时会在 bump、提交和创建 tag 后继续触发实际发包 workflow。 +- 快速发包优先使用 `make release`。命令会交互选择 `app` 或 `plugin`,先分析下一版本的 `patch`/`minor`/`major` 并预览 bump;确认后才 `git pull --rebase`、执行轻量检查、更新并提交版本 bump、创建并推送对应 tag。 +- App 发布会更新 `project.yml` 的 `MARKETING_VERSION` 和 `CURRENT_PROJECT_VERSION`,推送 `v*.*.*` tag 后由 `Release` workflow 构建、签名、公证并上传 DMG。 +- 插件发布会推送 `plugins-*` 批次 tag。默认 `auto` 模式会按生产 catalog 找出新插件、已 bump 插件和包相关变更插件;需要时自动更新对应 `plugin.json.version`,然后由 `Plugin Release` workflow 构建并合并签名 catalog。 +- 新版 App 首次启动时会先检查已安装插件,并从签名后的生产 catalog 自动更新到最新版;不会自动安装用户未安装的新插件。 +- 非交互用法示例:`make release ARGS="--type app --version 1.0.7 --yes"`,或 `make release ARGS="--type plugin --version 1.0.10 --plugin-mode selected --plugin calendar --yes"`。 +- 预览将执行的步骤可追加 `--dry-run`;正式发布前工作区必须干净。 +- 本地发布前复制 `scripts/release.local.env.sample` 为 `scripts/release.local.env`,至少填写 `DEVELOPER_ID_APPLICATION`。 +- 如需 Apple 公证,首次使用 `xcrun notarytool store-credentials` 保存凭证。 +- 版本号默认读取 `project.yml` 中的 `MARKETING_VERSION` 和 `CURRENT_PROJECT_VERSION`。 +- 生成本地正式包仍可使用底层脚本:`./scripts/release-local.sh`;发布到 GitHub Release 前需先完成 `gh auth login`,再执行 `./scripts/release-local.sh --publish`。 +- 插件库发布使用 `plugins-*` 批次 tag 触发 `Plugin Release` workflow。默认只构建和上传版本递增的插件,并将新条目合并进生产 catalog;catalog 私钥、Developer ID 证书和 GitHub token 必须来自 CI secrets 或本地环境变量。 +- GitHub Actions 自动构建与发布配置见 `docs/github-actions.md`;插件 catalog、包结构和批次发布流程见 `docs/plugins/plugin-catalog.md`。 diff --git a/Configs/AppDebug.xcconfig b/Configs/AppDebug.xcconfig new file mode 100644 index 0000000..c4f5b3f --- /dev/null +++ b/Configs/AppDebug.xcconfig @@ -0,0 +1,2 @@ +#include "Debug.xcconfig" +#include "AppVersion.xcconfig" diff --git a/Configs/AppRelease.xcconfig b/Configs/AppRelease.xcconfig new file mode 100644 index 0000000..761459a --- /dev/null +++ b/Configs/AppRelease.xcconfig @@ -0,0 +1,2 @@ +#include "Release.xcconfig" +#include "AppVersion.xcconfig" diff --git a/Configs/AppVersion.xcconfig b/Configs/AppVersion.xcconfig new file mode 100644 index 0000000..accdc6d --- /dev/null +++ b/Configs/AppVersion.xcconfig @@ -0,0 +1,2 @@ +MARKETING_VERSION = 1.1.0 +CURRENT_PROJECT_VERSION = 62 diff --git a/Configs/Debug.xcconfig b/Configs/Debug.xcconfig new file mode 100644 index 0000000..33dd2f4 --- /dev/null +++ b/Configs/Debug.xcconfig @@ -0,0 +1,4 @@ +#include? "../LocalConfig.xcconfig" + +SWIFT_STRICT_CONCURRENCY = complete +PLUGIN_CATALOG_PUBLIC_KEY = V7knIDPAmvC9rhCB3k3EQnSR+ZKiUlA12AIAgg/ajVk= diff --git a/Configs/MacTools-Info.plist b/Configs/MacTools-Info.plist new file mode 100644 index 0000000..5f0c22f --- /dev/null +++ b/Configs/MacTools-Info.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER).url.right-click + CFBundleURLSchemes + + $(MACTOOLS_URL_SCHEME) + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + MTPluginCatalogPublicKey + $(PLUGIN_CATALOG_PUBLIC_KEY) + MTRightClickConfigurationHomeRelativePath + $(RIGHT_CLICK_CONFIGURATION_HOME_RELATIVE_PATH) + NSAppleEventsUsageDescription + 点击日历日期时需要控制系统日历应用并定位到对应日期。 + NSBluetoothAlwaysUsageDescription + 读取蓝牙外设电量,用于在设备电量组件中显示鼠标、键盘和耳机状态。 + NSCalendarsUsageDescription + 读取系统日历事件,用于在右键日历组件中显示当天日程。 + NSCalendarsFullAccessUsageDescription + 读取系统日历事件,用于在右键日历组件中显示当天日程。 + SUAllowsAutomaticUpdates + + SUEnableAutomaticChecks + + SUFeedURL + https://mactools.ggbond.app/appcast.xml + SUPublicEDKey + $(SPARKLE_PUBLIC_ED_KEY) + UTImportedTypeDeclarations + + + UTTypeConformsTo + + public.directory + + UTTypeDescription + MacTools Plugin + UTTypeIdentifier + com.ggbond.mactools.plugin-package + UTTypeTagSpecification + + public.filename-extension + + mactoolsplugin + + + + + + diff --git a/Configs/MacTools.entitlements b/Configs/MacTools.entitlements new file mode 100644 index 0000000..fa584ca --- /dev/null +++ b/Configs/MacTools.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.automation.apple-events + + com.apple.security.personal-information.calendars + + + diff --git a/Configs/Release.xcconfig b/Configs/Release.xcconfig new file mode 100644 index 0000000..33dd2f4 --- /dev/null +++ b/Configs/Release.xcconfig @@ -0,0 +1,4 @@ +#include? "../LocalConfig.xcconfig" + +SWIFT_STRICT_CONCURRENCY = complete +PLUGIN_CATALOG_PUBLIC_KEY = V7knIDPAmvC9rhCB3k3EQnSR+ZKiUlA12AIAgg/ajVk= diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..f0ef4eb --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,4 @@ +@./AGENTS.md + +## Gemini CLI Adapter +- Shared repository instructions live in `AGENTS.md`; update that file instead of duplicating rules here. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LocalConfig.sample.xcconfig b/LocalConfig.sample.xcconfig new file mode 100644 index 0000000..5540417 --- /dev/null +++ b/LocalConfig.sample.xcconfig @@ -0,0 +1,2 @@ +DEVELOPMENT_TEAM = +BUNDLE_IDENTIFIER_PREFIX = com.example diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..97d9f0b --- /dev/null +++ b/Makefile @@ -0,0 +1,149 @@ +SHELL := /bin/zsh + +PROJECT_NAME := MacTools +APP_PRODUCT_NAME ?= MacTools Dev +REMOTE_URL ?= git@github.com:owner/MacTools.git +PLACEHOLDER_REMOTE_URL := git@github.com:owner/MacTools.git +PROJECT_FILE := $(PROJECT_NAME).xcodeproj +WORKSPACE_FILE := $(PROJECT_NAME).xcworkspace +DERIVED_DATA := build/DerivedData +APP_PATH := $(DERIVED_DATA)/Build/Products/Debug/$(APP_PRODUCT_NAME).app +APP_EXECUTABLE := $(APP_PATH)/Contents/MacOS/$(APP_PRODUCT_NAME) +HOST_ARCH := $(shell uname -m) +BUILD_DESTINATION := platform=macOS,arch=$(HOST_ARCH) +XCODEBUILD ?= $(abspath scripts/xcodebuild-filtered.sh) +LOCAL_PLUGIN_SOURCE_DIR ?= Plugins +PLUGIN_PROJECT_SOURCE_DIR ?= Plugins +GENERATED_PLUGIN_PROJECT_CONFIG := Configs/GeneratedPlugins.yml +LOCAL_PLUGIN_BUILD_DIR ?= build/LocalPlugins +LOCAL_PLUGIN_CATALOG := $(LOCAL_PLUGIN_BUILD_DIR)/catalog.dev.json +DEBUG_BUILD_PRODUCTS_DIR := $(DERIVED_DATA)/Build/Products/Debug +DEBUG_PLUGIN_INSTALL_DIR ?= $(HOME)/Library/Application Support/MacTools Dev/Plugins/Installed +LOCAL_ICON_GALLERY_DIR ?= build/LocalIconGallery +LOCAL_ICON_GALLERY_CATALOG := $(LOCAL_ICON_GALLERY_DIR)/catalog.dev.json +PLUGIN_RELEASE_REPO ?= ggbond268/MacTools +PLUGIN_RELEASE_TAG ?= plugins-local +PLUGIN_RELEASE_BUILD_DIR ?= build/PluginRelease/Build +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_RELEASE_BASE_URL ?= https://github.com/$(PLUGIN_RELEASE_REPO)/releases/download/$(PLUGIN_RELEASE_TAG) + +.PHONY: setup generate-plugin-config generate build sync-debug-plugins build-plugin build-plugins generate-icon-gallery package-plugins-release run run-open clean release release-local + +setup: + @if [ ! -f LocalConfig.xcconfig ]; then cp LocalConfig.sample.xcconfig LocalConfig.xcconfig; fi + @if [ ! -d .git ]; then git init; fi + @git branch -M main + @if [ "$(REMOTE_URL)" = "$(PLACEHOLDER_REMOTE_URL)" ]; then echo "Skipping origin remote setup. Pass REMOTE_URL=git@github.com:/MacTools.git to make setup when ready."; \ + else \ + if git remote get-url origin >/dev/null 2>&1; then git remote set-url origin $(REMOTE_URL); else git remote add origin $(REMOTE_URL); fi; \ + fi + +generate-plugin-config: + @./scripts/plugins/generate-plugin-project-config.rb \ + --source-dir "$(PLUGIN_PROJECT_SOURCE_DIR)" \ + --output "$(GENERATED_PLUGIN_PROJECT_CONFIG)" + +generate: generate-plugin-config + @xcodegen generate + +build: generate + @$(XCODEBUILD) -project $(PROJECT_FILE) -scheme $(PROJECT_NAME) -configuration Debug -destination "$(BUILD_DESTINATION)" -derivedDataPath $(DERIVED_DATA) build -quiet + +sync-debug-plugins: build + @if [ -n "$(PLUGIN)" ]; then \ + PLUGIN_ARGS=(--plugin "$(PLUGIN)"); \ + else \ + PLUGIN_ARGS=(); \ + fi; \ + ./scripts/plugins/sync-debug-plugins.sh \ + --source-dir "$(LOCAL_PLUGIN_SOURCE_DIR)" \ + --products-dir "$(DEBUG_BUILD_PRODUCTS_DIR)" \ + --output-dir "$(LOCAL_PLUGIN_BUILD_DIR)" \ + --install-dir "$(DEBUG_PLUGIN_INSTALL_DIR)" \ + $${PLUGIN_ARGS[@]} + +build-plugin: generate + @if [ -n "$(PLUGIN)" ]; then \ + PLUGIN_ARGS=(--plugin "$(PLUGIN)"); \ + else \ + PLUGIN_ARGS=(); \ + fi; \ + ./scripts/plugins/build-local-plugins.sh \ + --source-dir "$(LOCAL_PLUGIN_SOURCE_DIR)" \ + --output-dir "$(LOCAL_PLUGIN_BUILD_DIR)" \ + --destination "$(BUILD_DESTINATION)" \ + --xcodebuild "$(XCODEBUILD)" \ + $${PLUGIN_ARGS[@]} + +build-plugins: build-plugin + +generate-icon-gallery: + @./scripts/icons/generate-local-icon-gallery.py \ + --output-dir "$(LOCAL_ICON_GALLERY_DIR)" + +package-plugins-release: generate + @./scripts/plugins/build-plugin-release-assets.sh \ + --source-dir "$(LOCAL_PLUGIN_SOURCE_DIR)" \ + --build-dir "$(PLUGIN_RELEASE_BUILD_DIR)" \ + --dist-dir "$(PLUGIN_RELEASE_DIST_DIR)" \ + --assets-dir "$(PLUGIN_RELEASE_ASSETS_DIR)" \ + --base-url "$(PLUGIN_RELEASE_BASE_URL)" \ + --catalog-output "$(PLUGIN_RELEASE_CATALOG)" \ + --signed-catalog-output "$(PLUGIN_RELEASE_SIGNED_CATALOG)" \ + --sign-identity "$(PLUGIN_CODE_SIGN_IDENTITY)" \ + --destination "$(BUILD_DESTINATION)" \ + --xcodebuild "$(XCODEBUILD)" \ + --release-notes-url "https://github.com/$(PLUGIN_RELEASE_REPO)/releases/tag/$(PLUGIN_RELEASE_TAG)" + +run: sync-debug-plugins generate-icon-gallery + @CATALOG_URL="$(MACTOOLS_PLUGIN_CATALOG_URL)"; \ + ICON_CATALOG_URL="$(MACTOOLS_ICON_CATALOG_URL)"; \ + if [ -z "$$CATALOG_URL" ] && [ -f "$(LOCAL_PLUGIN_CATALOG)" ]; then \ + CATALOG_URL="file://$(abspath $(LOCAL_PLUGIN_CATALOG))"; \ + fi; \ + if [ -z "$$ICON_CATALOG_URL" ] && [ -f "$(LOCAL_ICON_GALLERY_CATALOG)" ]; then \ + ICON_CATALOG_URL="file://$(abspath $(LOCAL_ICON_GALLERY_CATALOG))"; \ + fi; \ + if [ -n "$$CATALOG_URL" ]; then \ + echo "Using plugin catalog: $$CATALOG_URL"; \ + fi; \ + if [ -n "$$ICON_CATALOG_URL" ]; then \ + echo "Using icon catalog: $$ICON_CATALOG_URL"; \ + fi; \ + RUN_ENV=(); \ + if [ -n "$$CATALOG_URL" ]; then \ + RUN_ENV+=("MACTOOLS_PLUGIN_CATALOG_URL=$$CATALOG_URL"); \ + fi; \ + if [ -n "$$ICON_CATALOG_URL" ]; then \ + RUN_ENV+=("MACTOOLS_ICON_CATALOG_URL=$$ICON_CATALOG_URL"); \ + fi; \ + stop_app() { \ + if [ -n "$${APP_PID:-}" ]; then \ + echo "Stopping $(APP_PRODUCT_NAME)..."; \ + /bin/kill -TERM "$$APP_PID" >/dev/null 2>&1 || true; \ + wait "$$APP_PID" >/dev/null 2>&1 || true; \ + fi; \ + }; \ + trap 'stop_app; exit 130' INT TERM; \ + if [ -z "$$CATALOG_URL" ] && [ -z "$$ICON_CATALOG_URL" ]; then \ + echo "No local plugin catalog found. Run 'make build-plugin' or set MACTOOLS_PLUGIN_CATALOG_URL."; \ + fi; \ + env "$${RUN_ENV[@]}" "$(APP_EXECUTABLE)" & \ + APP_PID=$$!; \ + wait "$$APP_PID" + +run-open: build + @open -n -W "$(APP_PATH)" + +clean: + @rm -rf build $(PROJECT_FILE) $(WORKSPACE_FILE) "$(GENERATED_PLUGIN_PROJECT_CONFIG)" + +release: + @./scripts/release.py $(ARGS) + +release-local: + @./scripts/release-local.sh $(ARGS) diff --git a/Plugins/ActivityBar/Bundle/ActivityBarPluginBundleEntrypoint.swift b/Plugins/ActivityBar/Bundle/ActivityBarPluginBundleEntrypoint.swift new file mode 100644 index 0000000..3e8c3bf --- /dev/null +++ b/Plugins/ActivityBar/Bundle/ActivityBarPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import ActivityBarPlugin + +private let activityBarPluginFactoryAnchor: Any.Type = ActivityBarPluginFactory.self diff --git a/Plugins/ActivityBar/Resources/Localizable.xcstrings b/Plugins/ActivityBar/Resources/Localizable.xcstrings new file mode 100644 index 0000000..f051128 --- /dev/null +++ b/Plugins/ActivityBar/Resources/Localizable.xcstrings @@ -0,0 +1,4692 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "component.ai.activeSessions": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d الجلسات النشطة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d aktive Sitzungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d active sessions" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d sesiones activas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d sessions actives" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d アクティブなセッション" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 활성 세션" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d sessões ativas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d активных сессий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个活动会话" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個活動會話" + } + } + } + }, + "component.ai.dailyAverage.above": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d%% أعلى من المتوسط اليومي (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d%% über Ihrem Tagesdurchschnitt (%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d%% above your daily average (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d%% por encima de tu promedio diario (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d%% au-dessus de votre moyenne quotidienne (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d%% 日平均 (%@) を上回る" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일일 평균보다 높은 %d%%(%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d%% acima da sua média diária (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d%% выше среднего дневного значения (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "比日均高 %d%%(%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比日均高 %d%%(%@)" + } + } + } + }, + "component.ai.dailyAverage.below": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d%% أقل من المتوسط اليومي (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d%% unter Ihrem Tagesdurchschnitt (%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d%% below your daily average (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d%% por debajo de tu promedio diario (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d%% en dessous de votre moyenne quotidienne (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d%% は 1 日の平均値 (%@) を下回ります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일일 평균보다 낮은 %d%%(%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d%% abaixo da sua média diária (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d%% ниже среднего дневного значения (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "比日均低 %d%%(%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "比日均低 %d%%(%@)" + } + } + } + }, + "component.ai.dailyAverage.same": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساويًا لمتوسطك اليومي (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entspricht Ihrem Tagesdurchschnitt (%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On par with your daily average (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "A la par con tu promedio diario (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À égalité avec votre moyenne quotidienne (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 日の平均 (%@) と同等" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일일 평균(%@)과 동일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "No mesmo nível da sua média diária (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "На уровне среднего показателя за день (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与日均持平(%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "與日均持平(%@)" + } + } + } + }, + "component.ai.detail": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ كلمة · %@ استدعاءات الأداة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Wörter · %@ Toolaufrufe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ words · %@ tool calls" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ palabras · %@ llamadas a herramientas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ mots · %@ appels d'outils" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ ワード · %@ ツール呼び出し" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 단어 · %@ 도구 호출" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ palavras · %@ chamadas de ferramentas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ слов · %@ вызовов инструментов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 字 · %@ 次工具调用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 字 · %@ 次工具調用" + } + } + } + }, + "component.ai.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وقت عمل الذكاء الاصطناعي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Arbeitszeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AI Work Time" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de trabajo de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Temps de travail de l'IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI の作業時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 작업시간" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tempo de trabalho da IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Время работы ИИ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 工作时长" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 工作時長" + } + } + } + }, + "component.chart.emptyCoding": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد نشاط ترميزي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Codierungsaktivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No coding activity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin actividad de codificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune activité de codage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コーディング作業はありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코딩 활동 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma atividade de codificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет кодирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无编程活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無編程活動" + } + } + } + }, + "component.chart.emptyInput": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد نشاط إدخال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Eingabeaktivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No input activity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin actividad de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune activité de saisie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力アクティビティがありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 활동 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma atividade de entrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет активности ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无输入活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無輸入活動" + } + } + } + }, + "component.date.today": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heute" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aujourd'hui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сегодня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + } + } + }, + "component.date.yesterday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أمس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gestern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ayer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "昨日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "어제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ontem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вчера" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + } + } + }, + "component.footer.close": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fechar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + } + } + }, + "component.footer.hooks": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطافات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Haken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hooks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ganchos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Crochets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "후크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ganchos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Крючки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Hook" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Hook" + } + } + } + }, + "component.footer.permissions": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأذونات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permissions" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permisos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisations" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permissões" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "權限" + } + } + } + }, + "component.funFact.clickMinutes": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لقد قمت بالنقر فوق %@ مرة. وهذا يشبه النقر على مكتبك لمدة %d دقيقة تقريبًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie haben %@ Mal geklickt. Das ist so, als würden Sie etwa %d Minuten lang auf Ihren Schreibtisch klopfen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You clicked %@ times. That's like tapping your desk for about %d minutes." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hiciste clic %@ veces. Eso es como tocar tu escritorio durante aproximadamente %d minutos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous avez cliqué %@ fois. C'est comme si vous tapotiez votre bureau pendant environ %d minutes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 回クリックしました。これは、約 %d 分間机をたたくことに似ています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@번 클릭하셨습니다. 이는 약 %d분 동안 책상을 두드리는 것과 같습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Você clicou %@ vezes. Isso é como bater na sua mesa por cerca de %d minutos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вы нажали %@ раз. Это все равно, что стучать по столу примерно %d минут." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你今天点击了 %@ 次,约等于敲桌子 %d 分钟。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你今天點擊了 %@ 次,約等於敲桌子 %d 分鐘。" + } + } + } + }, + "component.funFact.typedPages": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لقد أجريت %@ ضغطات على المفاتيح اليوم. يتعلق الأمر بكتابة %d صفحة كاملة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie haben heute %@ Tastenanschläge gemacht. Das bedeutet, dass %d ganze Seiten geschrieben werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You made %@ keystrokes today. That's about writing %d full pages." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hoy hiciste %@ pulsaciones de teclas. Se trata de escribir %d páginas completas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous avez effectué %@ frappes au clavier aujourd'hui. Il s'agit d'écrire %d pages complètes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日、%@ 回のキーストロークを行いました。つまり、%d ページ全体を書くことになります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘 %@ 키를 누르셨습니다. 그것은 %d 전체 페이지를 작성하는 것과 같습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Você pressionou %@ hoje. Trata-se de escrever %d páginas inteiras." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сегодня вы нажали %@ клавиш. Речь идет о написании %d полных страниц." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "你今天敲了 %@ 次键盘,约等于写满 %d 页。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "你今天敲了 %@ 次鍵盤,約等於寫滿 %d 頁。" + } + } + } + }, + "component.inputInsight.enableTracking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "امنح مراقبة الإدخال وقم بتشغيلها لبدء جمع الإحصائيات المحلية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewähren Sie die Eingabeüberwachung und aktivieren Sie diese, um mit der Erfassung lokaler Statistiken zu beginnen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant Input Monitoring and turn this on to start collecting local stats." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otorgue monitoreo de entrada y actívelo para comenzar a recopilar estadísticas locales." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accordez la surveillance des entrées et activez-la pour commencer à collecter des statistiques locales." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリングを許可し、これをオンにしてローカル統計の収集を開始します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링을 부여하고 이 기능을 켜면 로컬 통계 수집이 시작됩니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conceda monitoramento de entrada e ative-o para começar a coletar estatísticas locais." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешите мониторинг ввода и включите его, чтобы начать сбор локальной статистики." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权输入监控并开启后即可开始记录本地统计。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權輸入監控並開啟後即可開始記錄本地統計。" + } + } + } + }, + "component.legend.clicks": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النقرات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicks" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clicks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Clics" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clics" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클릭수" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cliques" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Клики" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊" + } + } + } + }, + "component.legend.keys": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المفاتيح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlüssel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keys" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Claves" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Chaves" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按鍵" + } + } + } + }, + "component.legend.scrolls": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخطوطات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schriftrollen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scrolls" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pergaminos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Parchemins" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "巻物" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스크롤" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pergaminhos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свитки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "滚动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "滾動" + } + } + } + }, + "component.metric.clicks": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النقرات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicks" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clicks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Clics" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Clics" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリック数" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클릭수" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cliques" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Клики" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊" + } + } + } + }, + "component.metric.keystrokes": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ضغطات المفاتيح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastenanschläge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keystrokes" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pulsiones de teclas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Frappes au clavier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーストローク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키 입력" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pressionamentos de teclas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажатия клавиш" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按鍵" + } + } + } + }, + "component.metric.screenTime": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وقت النظر إلى الشاشة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmzeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Screen Time" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Temps d'écran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上映時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스크린 타임" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tempo de uso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экранное время" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "螢幕時間" + } + } + } + }, + "component.metric.scrolls": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مخطوطات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schriftrollen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scrolls" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pergaminos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Parchemins" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "巻物" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스크롤" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pergaminhos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свитки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "滚动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "滾動" + } + } + } + }, + "component.screenTime.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وقت النظر إلى الشاشة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirmzeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Screen Time" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Temps d'écran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "上映時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스크린 타임" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tempo de uso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экранное время" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "螢幕時間" + } + } + } + }, + "component.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إحصائيات النشاط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivitätsstatistiken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Activity Stats" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estadísticas de actividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statistiques d'activité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティビティ統計" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활동 통계" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Estatísticas de atividade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статистика активности" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活动统计" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "活動統計" + } + } + } + }, + "component.topApps.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد أي نشاط بعد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Aktivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No activity yet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay actividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune activité pour l'instant" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだアクティビティはありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 활동이 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma atividade ainda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пока никаких действий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無活動" + } + } + } + }, + "component.topApps.enableTracking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بتشغيل التتبع لرؤية تصنيفات التطبيقات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren Sie das Tracking, um App-Rankings anzuzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on tracking to see app rankings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activa el seguimiento para ver las clasificaciones de las aplicaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activez le suivi pour voir le classement des applications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリのランキングを確認するにはトラッキングをオンにしてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추적을 켜서 앱 순위를 확인하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ative o rastreamento para ver as classificações do aplicativo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включите отслеживание, чтобы увидеть рейтинг приложения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开启统计后查看应用排行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟統計後查看應用排行" + } + } + } + }, + "component.topApps.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أفضل التطبيقات حسب مدة استخدام الجهاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Top-Apps nach Bildschirmzeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Top Apps by Screen Time" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicaciones principales por tiempo de pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Meilleures applications par temps d'écran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スクリーンタイム別トップアプリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 사용 시간별 인기 앱" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Principais aplicativos por tempo de uso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лучшие приложения по экранному времени" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕时间最多的应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "螢幕時間最多的應用" + } + } + } + }, + "component.trendMode.codingTools": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدوات الترميز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codierungstools" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Coding Tools" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Herramientas de codificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Outils de codage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コーディング ツール" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "코딩 도구" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ferramentas de codificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Инструменты кодирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编程工具" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編程工具" + } + } + } + }, + "component.trendMode.input": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإدخال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Input" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Entrée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Entrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ввод" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入" + } + } + } + }, + "component.trends.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتجاهات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Trends" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trends" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tendencias" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tendances" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トレンド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "트렌드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tendências" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тенденции" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "趋势" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "趨勢" + } + } + } + }, + "component.subtitle.aiListening": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستماع للذكاء الاصطناعي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI lauscht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AI listening" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escuchando IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écoute IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI 監視中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 수신 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IA ouvindo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ИИ слушает" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 监听中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 監聽中" + } + } + } + }, + "component.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未開啟" + } + } + } + }, + "component.subtitle.inputs": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ المدخلات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Eingaben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ inputs" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ entradas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "entrées %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 入力" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 입력" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ entradas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ входы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 次输入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 次輸入" + } + } + } + }, + "error.hook.installFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تثبيت الخطاف: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hook-Installation fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hook installation failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en la instalación del gancho: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'installation du hook : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックのインストールに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "후크 설치 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na instalação do gancho: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось установить перехватчик: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Hook 安装失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Hook 安裝失敗:%@" + } + } + } + }, + "error.hook.invalidJSON": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على تحليل ملف التكوين: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfigurationsdatei konnte nicht geparst werden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to parse config file: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede analizar el archivo de configuración: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'analyser le fichier de configuration : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "構成ファイルを解析できません: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성 파일을 구문 분석할 수 없습니다: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível analisar o arquivo de configuração: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно проанализировать файл конфигурации: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法解析配置文件:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法解析配置檔案:%@" + } + } + } + }, + "error.socket.bindFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل ربط المقبس: %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket konnte nicht gebunden werden: %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to bind socket: %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al vincular el socket: %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la liaison du socket : %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケットのバインドに失敗しました: %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 바인딩 실패: %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao vincular o soquete: %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось привязать сокет: %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "绑定 Socket 失败:%d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "綁定 Socket 失敗:%d" + } + } + } + }, + "error.socket.createFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل في إنشاء مأخذ التوصيل: %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket konnte nicht erstellt werden: %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to create socket: %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al crear el socket: %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la création du socket : %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケットの作成に失敗しました: %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 생성 실패: %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao criar soquete: %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось создать сокет: %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建 Socket 失败:%d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "創建 Socket 失敗:%d" + } + } + } + }, + "error.socket.listenFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الاستماع على المقبس: %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket konnte nicht überwacht werden: %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to listen on socket: %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al escuchar en el socket: %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'écoute sur le socket : %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケットでリッスンできませんでした: %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 수신 실패: %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao escutar no soquete: %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прослушать сокет: %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监听 Socket 失败:%d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "監聽 Socket 失敗:%d" + } + } + } + }, + "error.socket.pathTooLong": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسار مأخذ التوصيل طويل جدًا: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Socket-Pfad ist zu lang: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Socket path is too long: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La ruta del socket es demasiado larga: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le chemin du socket est trop long : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソケット パスが長すぎます: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소켓 경로가 너무 깁니다: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O caminho do soquete é muito longo: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Путь к сокету слишком длинный: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Socket 路径过长:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Socket 路徑過長:%@" + } + } + } + }, + "error.socket.startFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل في بدء تشغيل مستمع نشاط الذكاء الاصطناعي: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AI-Aktivitäts-Listener konnte nicht gestartet werden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to start AI activity listener: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al iniciar el detector de actividad de IA: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du démarrage de l'écouteur d'activité IA : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI アクティビティ リスナーの開始に失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 활동 리스너 시작 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao iniciar o ouvinte de atividade de IA: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось запустить прослушиватель активности ИИ: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 活动监听启动失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 活動監聽啟動失敗:%@" + } + } + } + }, + "hook.status.installFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التثبيت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installation fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installation Failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de instalación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'installation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストールに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na instalação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка установки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝失敗" + } + } + } + }, + "hook.status.installed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التثبيت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установлен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已安裝" + } + } + } + }, + "hook.status.listening": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستمع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lauscht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Listening" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escuchando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À l'écoute" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "監視中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수신 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ouvindo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Слушает" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监听中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "監聽中" + } + } + } + }, + "hook.status.installedWithDate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التثبيت: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Installiert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installé : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済み: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установлено: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已安裝:%@" + } + } + } + }, + "hook.status.notInstalled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير مثبت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht installiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not Installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non installé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストールされていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não instalado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не установлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未安裝" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتبع الإدخال ووقت استخدام التطبيق الأمامي ونشاط ترميز الذكاء الاصطناعي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfolgen Sie Eingaben, Nutzungsdauer der Vordergrund-App und KI-Codierungsaktivitäten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Track input, foreground app usage time, and AI coding activity." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seguimiento de la entrada, el tiempo de uso de la aplicación en primer plano y la actividad de codificación de IA." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivez les entrées, la durée d'utilisation de l'application au premier plan et l'activité de codage de l'IA." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力、フォアグラウンド アプリの使用時間、AI コーディング アクティビティを追跡します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력, 포그라운드 앱 사용 시간 및 AI 코딩 활동을 추적합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rastreie a entrada, o tempo de uso do aplicativo em primeiro plano e a atividade de codificação de IA." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отслеживайте ввод данных, время использования приложений на переднем плане и активность ИИ-кодирования." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计输入、前台应用使用时长和 AI 编程活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "統計輸入、前臺應用使用時長和 AI 編程活動" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إحصائيات النشاط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivitätsstatistiken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Activity Stats" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estadísticas de actividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statistiques d'activité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティビティ統計" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활동 통계" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Estatísticas de atividade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статистика активности" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活动统计" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "活動統計" + } + } + } + }, + "panel.action.trackingEnabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتبع النشاط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivitätsverfolgung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Activity Tracking" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seguimiento de actividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivi des activités" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティビティの追跡" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활동 추적" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rastreamento de atividades" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отслеживание активности" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活动统计" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "活動統計" + } + } + } + }, + "panel.action.installHooks": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت أو تحديث AI Hooks" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "AI Hooks installieren oder aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install or Update AI Hooks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar o actualizar AI Hooks" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installer ou mettre à jour les AI Hooks" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI フックのインストールまたは更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI Hook 설치 또는 업데이트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalar ou atualizar AI Hooks" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить или обновить AI-хуки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装或更新 AI Hook" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝或更新 AI Hook" + } + } + } + }, + "panel.action.openInputMonitoring": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح إعدادات مراقبة الإدخال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen zur Eingabeüberwachung öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Input Monitoring Settings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir configuración de monitoreo de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres de surveillance des entrées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリング設定を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링 설정 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir configurações de monitoramento de entrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки мониторинга ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开输入监控设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開輸入監控設定" + } + } + } + }, + "panel.action.resetToday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح إحصائيات اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heutige Statistiken löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Today's Stats" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar estadísticas de hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer les statistiques du jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日の統計をクリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘의 통계 지우기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar as estatísticas de hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить сегодняшнюю статистику" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空今日统计" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空今日統計" + } + } + } + }, + "panel.subtitle.aiListening": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستماع إلى نشاط الذكاء الاصطناعي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Aktivität wird überwacht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Listening for AI activity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escuchando actividad de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écoute de l'activité IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI アクティビティを監視中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 활동 수신 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ouvindo atividade de IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прослушивание активности ИИ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 活动监听中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 活動監聽中" + } + } + } + }, + "panel.subtitle.default": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتبع نشاط الإدخال والتشفير بالذكاء الاصطناعي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verfolgen Sie Eingaben und KI-Codierungsaktivitäten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Track input and AI coding activity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seguimiento de la entrada y la actividad de codificación de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivez les entrées et l'activité de codage de l'IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力と AI コーディング アクティビティを追跡" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 및 AI 코딩 활동 추적" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rastreie a entrada e a atividade de codificação de IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отслеживание ввода и активности ИИ-кодирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "统计输入与 AI 编程活动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "統計輸入與 AI 編程活動" + } + } + } + }, + "panel.subtitle.todayInputs": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ المدخلات اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ gibt heute ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ inputs today" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ entradas hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ entrées aujourd'hui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日は%@ 件の入力" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 오늘 입력" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ entradas hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ ввел данные сегодня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今日 %@ 次输入" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今日 %@ 次輸入" + } + } + } + }, + "settings.aiHooks.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت الخطافات أو تحديثها" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hooks installieren oder aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install or Update Hooks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar o actualizar ganchos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installer ou mettre à jour les hooks" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フックのインストールまたは更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "후크 설치 또는 업데이트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalar ou atualizar ganchos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Установить или обновить хуки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装或更新 Hook" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝或更新 Hook" + } + } + } + }, + "settings.aiHooks.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجل المطالبات، واستدعاءات الأداة، ووقت التنفيذ من Claude Code، وCursor، وCodex." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufforderungen, Werkzeugaufrufe und Ausführungszeit von Claude Code, Cursor und Codex aufzeichnen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Record prompts, tool calls, and execution time from Claude Code, Cursor, and Codex." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registrar indicaciones, llamadas de herramientas y tiempo de ejecución de Claude Code, Cursor y Codex." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrez les invites, les appels d'outils et le temps d'exécution de Claude Code, Cursor et Codex." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code、Cursor、Codex からのプロンプト、ツール呼び出し、実行時間を記録します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude Code, Cursor 및 Codex의 프롬프트, 도구 호출 및 실행 시간을 기록합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Registre prompts, chamadas de ferramenta e tempo de execução de Claude Code, Cursor e Codex." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Записывайте подсказки, вызовы инструментов и время выполнения от Claude Code, Cursor и Codex." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "记录 Claude Code、Cursor 和 Codex 的提示、工具调用与执行时长。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "記錄 Claude Code、Cursor 和 Codex 的提示、工具調用與執行時長。" + } + } + } + }, + "settings.aiHooks.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يكتب إعدادات الخطاف لـ Claude Code، Cursor، و Codex. ترسل البرامج النصية أحداث النشاط محليًا من خلال مقبس Unix." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreibt Hook-Einstellungen für Claude Code, Cursor und Codex. Skripte senden Aktivitätsereignisse lokal über einen Unix-Socket." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Writes hook settings for Claude Code, Cursor, and Codex. AI activity listening does not require Input Monitoring." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escribe la configuración del enlace para Claude Code, Cursor y Codex. Los scripts envían eventos de actividad localmente a través de un socket Unix." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écrit les paramètres de hook pour Claude Code, Cursor et Codex. Les scripts envoient des événements d'activité localement via un socket Unix." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Claude Code、Cursor、および Codex のフック設定を書き込みます。スクリプトは、Unix ソケットを通じてアクティビティ イベントをローカルに送信します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Claude Code, Cursor 및 Codex에 대한 후크 설정을 작성합니다. 스크립트는 Unix 소켓을 통해 로컬로 활동 이벤트를 보냅니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Grava configurações de gancho para Claude Code, Cursor e Codex. Os scripts enviam eventos de atividade localmente por meio de um soquete Unix." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Записывает настройки ловушки для Claude Code, Cursor и Codex. Скрипты отправляют события активности локально через сокет Unix." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击后会写入 Claude Code、Cursor 和 Codex 的 hook 配置;AI 活动监听不需要输入监控权限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊後會寫入 Claude Code、Cursor 和 Codex 的 hook 配置;AI 活動監聽不需要輸入監控權限。" + } + } + } + }, + "settings.aiHooks.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطافات أدوات الذكاء الاصطناعي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KI-Tool-Hooks" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "AI Tool Hooks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ganchos de herramientas de IA" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Crochets d'outils IA" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "AI ツールフック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "AI 도구 후크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ganchos para ferramentas de IA" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приемы для инструментов искусственного интеллекта" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 工具 Hook" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "AI 工具 Hook" + } + } + } + }, + "settings.inputMonitoring.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح إعدادات النظام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir configuración del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム設定を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abra as configurações do sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки системы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开系统设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開系統設定" + } + } + } + }, + "settings.inputMonitoring.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحسب أحداث لوحة المفاتيح، والنقر بالماوس، والتمرير." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zählt Tastatur-, Mausklick- und Bildlaufereignisse." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Counts keyboard, mouse click, and scroll events." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuenta eventos de teclado, clic del mouse y desplazamiento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compte les événements de clavier, de clic de souris et de défilement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーボード、マウスのクリック、スクロールのイベントをカウントします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키보드, 마우스 클릭 및 스크롤 이벤트를 계산합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conta eventos de teclado, clique do mouse e rolagem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Считает события клавиатуры, щелчка мыши и прокрутки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用于统计键盘、鼠标点击和滚动事件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用於統計鍵盤、滑鼠點擊和滾動事件。" + } + } + } + }, + "settings.inputMonitoring.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتطلب إحصائيات لوحة المفاتيح والماوس والتمرير السماح بـ MacTools في مراقبة الإدخال. لا يزال من الممكن تسجيل وقت استخدام التطبيق الأمامي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tastatur-, Maus- und Scroll-Statistiken erfordern die Zulassung von MacTools in der Eingabeüberwachung. Die Nutzungsdauer der Vordergrund-App kann weiterhin aufgezeichnet werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keyboard, mouse, and scroll stats require allowing MacTools in Input Monitoring. Foreground app usage time can still be recorded." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las estadísticas de teclado, mouse y desplazamiento requieren permitir MacTools en Monitoreo de entrada. Aún se puede registrar el tiempo de uso de la aplicación en primer plano." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les statistiques du clavier, de la souris et du défilement nécessitent l'autorisation de MacTools dans la surveillance des entrées. Le temps d'utilisation de l'application au premier plan peut toujours être enregistré." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーボード、マウス、およびスクロールの統計情報を使用するには、入力モニタリングで MacTools を許可する必要があります。フォアグラウンド アプリの使用時間は引き続き記録できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "키보드, 마우스 및 스크롤 통계를 사용하려면 입력 모니터링에서 MacTools을 허용해야 합니다. 포그라운드 앱 사용 시간은 계속 기록될 수 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As estatísticas de teclado, mouse e rolagem exigem a permissão de MacTools no monitoramento de entrada. O tempo de uso do app em primeiro plano ainda pode ser registrado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Статистика клавиатуры, мыши и прокрутки требует разрешения MacTools в мониторинге ввода. Время использования приложений переднего плана по-прежнему можно записать." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "键盘、鼠标和滚动统计需要在系统设置中允许 MacTools 进行输入监控。前台应用使用时长仍可记录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鍵盤、滑鼠和滾動統計需要在系統設定中允許 MacTools 進行輸入監控。前臺應用使用時長仍可記錄。" + } + } + } + }, + "settings.inputMonitoring.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مراقبة الإدخال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabeüberwachung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Input Monitoring" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Monitoreo de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillance des entrées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de entrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入监控" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入監控" + } + } + } + }, + "status.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未開啟" + } + } + } + }, + "status.permissionRequired": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإذن مطلوب" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigung erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permission Required" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permiso requerido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisation requise" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可が必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한 필요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permissão necessária" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется разрешение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要授權" + } + } + } + }, + "status.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الجري" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Corriendo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d'exécution" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "달리기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Correndo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Бег" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行中" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarCodingSessionStore.swift b/Plugins/ActivityBar/Sources/ActivityBarCodingSessionStore.swift new file mode 100644 index 0000000..0d492fd --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarCodingSessionStore.swift @@ -0,0 +1,213 @@ +import Foundation +import MacToolsPluginKit + +@MainActor +final class ActivityBarCodingSessionStore: ObservableObject { + private enum StorageKey { + static let days = "activity-bar.coding.days.v1" + } + + private struct ActiveSession: Equatable { + var project: String + var tool: String + var status: ActivityBarHookStatus + var startedAt: Date + var lastUpdatedAt: Date + } + + @Published private(set) var days: [String: ActivityBarCodingDailyStats] + @Published private(set) var activeSessionCount = 0 + + private var activeSessions: [String: ActiveSession] = [:] + private let storage: PluginStorage + private let calendar: Calendar + private let dateProvider: () -> Date + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init( + storage: PluginStorage, + calendar: Calendar = .current, + dateProvider: @escaping () -> Date = Date.init + ) { + self.storage = storage + self.calendar = calendar + self.dateProvider = dateProvider + self.days = Self.loadDays(storage: storage, decoder: decoder) + } + + var today: ActivityBarCodingDailyStats { + days[dateKey(for: dateProvider())] ?? ActivityBarCodingDailyStats(date: dateKey(for: dateProvider())) + } + + var sortedDateKeys: [String] { + days.keys.sorted() + } + + func stats(for date: String) -> ActivityBarCodingDailyStats { + days[date] ?? ActivityBarCodingDailyStats(date: date) + } + + func recentDays(count: Int, endingAt endDate: Date? = nil) -> [ActivityBarCodingDailyStats] { + let end = endDate ?? dateProvider() + let boundedCount = max(count, 1) + + return (0.. 0.5, session.status != .waitingForInput, session.status != .ended { + addDuration(elapsed, project: session.project, tool: session.tool) + } + + session.lastUpdatedAt = now + activeSessions[sessionID] = session + } + + private func addWords(_ count: Int, project: String, tool: String) { + guard count > 0 else { + return + } + + mutateToday(project: project, tool: tool) { day, projectStats, toolStats in + day.wordCount += count + projectStats.wordCount += count + toolStats.wordCount += count + } + } + + private func addToolCall(project: String, tool: String) { + mutateToday(project: project, tool: tool) { day, projectStats, toolStats in + day.toolCallCount += 1 + projectStats.toolCallCount += 1 + toolStats.toolCallCount += 1 + } + } + + private func addDuration(_ seconds: TimeInterval, project: String, tool: String) { + mutateToday(project: project, tool: tool) { day, projectStats, toolStats in + day.durationSeconds += seconds + projectStats.durationSeconds += seconds + toolStats.durationSeconds += seconds + } + } + + private func mutateToday( + project rawProject: String, + tool rawTool: String, + update: (inout ActivityBarCodingDailyStats, inout ActivityBarProjectStats, inout ActivityBarProjectStats) -> Void + ) { + let project = rawProject.isEmpty ? "Unknown" : rawProject + let tool = rawTool.isEmpty ? ActivityBarCodingTool.claudeCode.rawValue : rawTool + let key = dateKey(for: dateProvider()) + var day = days[key] ?? ActivityBarCodingDailyStats(date: key) + var projectStats = day.perProject[project] ?? ActivityBarProjectStats() + var toolStats = day.perTool[tool] ?? ActivityBarProjectStats() + + update(&day, &projectStats, &toolStats) + + day.perProject[project] = projectStats + day.perTool[tool] = toolStats + days[key] = day + } + + private func countWords(_ prompt: String) -> Int { + prompt + .split { $0.isWhitespace || $0.isNewline } + .count + } + + private func projectName(from cwd: String?) -> String { + guard let cwd, !cwd.isEmpty else { + return "Unknown" + } + + return URL(fileURLWithPath: cwd).lastPathComponent + } + + private func dateKey(for date: Date) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + let year = components.year ?? 0 + let month = components.month ?? 0 + let day = components.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } + + private func persist() { + do { + let data = try encoder.encode(days) + storage.set(data, forKey: StorageKey.days) + } catch { + ActivityBarLog.hooks.error("Failed to persist coding stats: \(error.localizedDescription, privacy: .public)") + } + } + + private static func loadDays(storage: PluginStorage, decoder: JSONDecoder) -> [String: ActivityBarCodingDailyStats] { + guard let data = storage.data(forKey: StorageKey.days) else { + return [:] + } + + do { + return try decoder.decode([String: ActivityBarCodingDailyStats].self, from: data) + } catch { + ActivityBarLog.hooks.error("Failed to load coding stats: \(error.localizedDescription, privacy: .public)") + return [:] + } + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift b/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift new file mode 100644 index 0000000..7276111 --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarComponentView.swift @@ -0,0 +1,1345 @@ +import AppKit +import Charts +import SwiftUI +import MacToolsPluginKit + +private enum ActivityBarComponentLayout { + static let cardCornerRadius = PluginComponentPanelLayoutMetrics.cardCornerRadius +} + +private enum ActivityBarTrendMode: String, CaseIterable { + case codingTools = "Coding Tools" + case input = "Input" + + func label(localization: PluginLocalization) -> String { + switch self { + case .codingTools: + localization.string("component.trendMode.codingTools", defaultValue: "Coding Tools") + case .input: + localization.string("component.trendMode.input", defaultValue: "Input") + } + } +} + +private enum ActivityBarChartRange: String, CaseIterable { + case oneDay = "1d" + case sevenDays = "7d" + case fourteenDays = "14d" + case thirtyDays = "30d" + + var dayCount: Int { + switch self { + case .oneDay: + return 1 + case .sevenDays: + return 7 + case .fourteenDays: + return 14 + case .thirtyDays: + return 30 + } + } + + var label: String { rawValue } +} + +private enum ActivityBarFunFact { + static let secondsPerClick = 0.50 + static let keysPerPage = 550 + + static func forDay(_ stats: ActivityBarDailyStats, localization: PluginLocalization) -> String? { + var facts: [String] = [] + + if stats.keystrokes > 0 { + let pages = Double(stats.keystrokes) / Double(keysPerPage) + if pages >= 1 { + let formatted = ActivityBarFormatting.decimal(stats.keystrokes) + let fullPages = Int(pages.rounded()) + facts.append( + localization.format( + "component.funFact.typedPages", + defaultValue: "You typed %@ keys today. That's about writing %d full pages.", + formatted, + fullPages + ) + ) + } + } + + if stats.pointerClicks > 0 { + let clickMins = (Double(stats.pointerClicks) * secondsPerClick) / 60 + if clickMins >= 1 { + let formatted = ActivityBarFormatting.decimal(stats.pointerClicks) + let minutes = Int(clickMins.rounded()) + facts.append( + localization.format( + "component.funFact.clickMinutes", + defaultValue: "You clicked %@ times. That's like tapping your desk for about %d minutes.", + formatted, + minutes + ) + ) + } + } + + guard !facts.isEmpty else { + return nil + } + + let hash = stats.date.utf8.reduce(5381) { (($0 << 5) &+ $0) &+ Int($1) } + return facts[abs(hash) % facts.count] + } +} + +struct ActivityBarComponentView: View { + private static let claudeColor = Color(red: 0xCB / 255.0, green: 0x64 / 255.0, blue: 0x41 / 255.0) + private static let cursorColor = Color.primary + private static let codexColor = Color(red: 0x10 / 255.0, green: 0xA3 / 255.0, blue: 0x7F / 255.0) + static let visibleCodingTools: [ActivityBarCodingTool] = [.claudeCode, .cursor, .codex] + + private static func codingToolColor(_ tool: ActivityBarCodingTool) -> Color { + switch tool { + case .claudeCode: + return claudeColor + case .cursor: + return cursorColor + case .codex: + return codexColor + } + } + + private static func codingToolLegendLabel(_ tool: ActivityBarCodingTool) -> String { + switch tool { + case .claudeCode: + return "Claude" + case .cursor: + return "Cursor" + case .codex: + return "Codex" + } + } + + @ObservedObject var controller: ActivityBarController + let localization: PluginLocalization + let dismiss: () -> Void + + @State private var hoveredDate: String? + @State private var hoveredScreenTimeDate: String? + @State private var expandedAppName: String? + @State private var selectedDateOffset = 0 + @State private var trendMode: ActivityBarTrendMode = .input + @AppStorage("activity-bar.stats-expanded") private var statsExpanded = true + @AppStorage("activity-bar.chart-range") private var chartRange = ActivityBarChartRange.sevenDays + + init( + controller: ActivityBarController, + localization: PluginLocalization = PluginLocalization(bundle: .main), + dismiss: @escaping () -> Void = {} + ) { + self.controller = controller + self.localization = localization + self.dismiss = dismiss + } + + private var selectedDate: Date { + Calendar.current.date(byAdding: .day, value: selectedDateOffset, to: Date()) ?? Date() + } + + private var selectedDateKey: String { + dateKey(for: selectedDate) + } + + private var selectedInputStats: ActivityBarDailyStats { + controller.inputStats.stats(for: selectedDateKey) + } + + private var selectedCodingStats: ActivityBarCodingDailyStats { + controller.codingStats.stats(for: selectedDateKey) + } + + private var isViewingToday: Bool { + selectedDateOffset == 0 + } + + private var canGoBack: Bool { + let keys = Set(controller.inputStats.sortedDateKeys + controller.codingStats.sortedDateKeys) + guard let earliest = keys.sorted().first else { + return false + } + + return selectedDateKey > earliest + } + + private var hasCodingToolsData: Bool { + controller.codingStats.days.values.contains { day in + hasCodingStats(visibleCodingAggregateStats(in: day)) + } + } + + var body: some View { + VStack(spacing: 0) { + headerBar + todayStats + + if showsAISection { + divider + aiSection + } + + divider + topAppsSection + + divider + statsDisclosure + if statsExpanded { + if trendMode == .codingTools { + codingToolsChart + } else { + weeklyChart + divider + screenTimeSection + } + } + + divider + footerBar + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(ActivityBarComponentBackground(cornerRadius: ActivityBarComponentLayout.cardCornerRadius)) + .clipShape( + RoundedRectangle( + cornerRadius: ActivityBarComponentLayout.cardCornerRadius, + style: .continuous + ) + ) + .animation(.easeInOut(duration: 0.2), value: statsExpanded) + .animation(.easeInOut(duration: 0.2), value: chartRange) + .animation(.easeInOut(duration: 0.2), value: trendMode) + .animation(.easeInOut(duration: 0.15), value: selectedDateOffset) + .onAppear { + controller.refresh() + trendMode = hasCodingToolsData ? .codingTools : .input + } + } + + private var headerBar: some View { + HStack { + Text(localization.string("component.title", defaultValue: "Activity Bar")) + .font(.title3.bold()) + .lineLimit(1) + + Spacer(minLength: 8) + + HStack(spacing: 4) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + selectedDateOffset -= 1 + expandedAppName = nil + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary.opacity(canGoBack ? 0.55 : 0.2)) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!canGoBack) + + Text(displayDateString) + .font(.body) + .foregroundStyle(.primary.opacity(0.55)) + .lineLimit(1) + + Button { + withAnimation(.easeInOut(duration: 0.15)) { + selectedDateOffset += 1 + expandedAppName = nil + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary.opacity(isViewingToday ? 0.2 : 0.55)) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isViewingToday) + } + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 10) + } + + private var todayStats: some View { + let day = selectedInputStats + + return VStack(spacing: 8) { + HStack(spacing: 10) { + statCell( + icon: "keyboard", + value: ActivityBarFormatting.decimal(day.keystrokes), + label: localization.string("component.metric.keystrokes", defaultValue: "Keystrokes") + ) + statCell( + icon: "cursorarrow.click.2", + value: ActivityBarFormatting.decimal(day.pointerClicks), + label: localization.string("component.metric.clicks", defaultValue: "Clicks") + ) + } + + HStack(spacing: 10) { + statCell( + icon: "scroll", + value: ActivityBarFormatting.decimal(day.scrollEvents), + label: localization.string("component.metric.scrolls", defaultValue: "Scrolls") + ) + statCell( + icon: "macwindow.on.rectangle", + value: ActivityBarFormatting.duration(day.screenTimeSeconds), + label: localization.string("component.metric.screenTime", defaultValue: "Screen Time") + ) + } + + if let fact = inputInsightText { + Text(fact) + .font(.caption.weight(.medium)) + .foregroundStyle(.primary.opacity(0.7)) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + .blue.opacity(0.08), + in: RoundedRectangle( + cornerRadius: ActivityBarComponentLayout.cardCornerRadius, + style: .continuous + ) + ) + .padding(.horizontal, 4) + .padding(.top, 4) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private func statCell(icon: String, value: String, label: String, tint: Color = .blue) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(tint, in: Circle()) + + VStack(alignment: .leading, spacing: 1) { + Text(value) + .font(.system(size: 14, weight: .semibold).monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.7) + + Text(label) + .font(.system(size: 10)) + .foregroundStyle(.primary.opacity(0.5)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + .primary.opacity(0.04), + in: RoundedRectangle( + cornerRadius: ActivityBarComponentLayout.cardCornerRadius, + style: .continuous + ) + ) + } + + private var inputInsightText: String? { + if let fact = ActivityBarFunFact.forDay(selectedInputStats, localization: localization) { + return fact + } + + if isViewingToday, !controller.isTrackingEnabled { + return localization.string( + "component.inputInsight.enableTracking", + defaultValue: "Grant Input Monitoring and turn this on to start collecting local stats." + ) + } + + return nil + } + + private var showsAISection: Bool { + !codingToolRows(for: selectedCodingStats).isEmpty + || (isViewingToday && controller.codingStats.activeSessionCount > 0) + } + + private var aiSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text(localization.string("component.ai.title", defaultValue: "Time AI Worked for You")) + .font(.headline) + .padding(.horizontal, 22) + .padding(.bottom, 2) + + let rows = codingToolRows(for: selectedCodingStats) + if rows.isEmpty { + codingToolRow( + row: CodingToolDisplayRow( + id: "active-ai", + name: "Claude / Codex", + duration: 0, + detail: localization.format( + "component.ai.activeSessions", + defaultValue: "%d active", + controller.codingStats.activeSessionCount + ), + tint: Self.codexColor, + systemImage: "terminal", + iconSize: 13 + ) + ) + } else { + ForEach(rows) { row in + codingToolRow(row: row) + } + } + + if isViewingToday { + aiWeeklyComparison + } + } + .padding(.vertical, 8) + } + + private func codingToolRow(row: CodingToolDisplayRow) -> some View { + HStack(spacing: 8) { + Image(systemName: row.systemImage) + .font(.system(size: row.iconSize, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(row.tint, in: Circle()) + + VStack(alignment: .leading, spacing: 1) { + Text(row.name) + .font(.body) + .lineLimit(1) + + if let detail = row.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.primary.opacity(0.55)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + + Spacer(minLength: 8) + + Text(ActivityBarFormatting.duration(row.duration)) + .font(.body.weight(.semibold).monospacedDigit()) + .foregroundStyle(.primary.opacity(0.55)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + .primary.opacity(0.04), + in: RoundedRectangle( + cornerRadius: ActivityBarComponentLayout.cardCornerRadius, + style: .continuous + ) + ) + .padding(.horizontal, 16) + } + + @ViewBuilder + private var aiWeeklyComparison: some View { + let avg = aiDailyAvgLastWeek + if avg > 0 { + let today = visibleCodingAggregateStats(in: controller.codingStats.today).durationSeconds + let ratio = today / avg + let aboveAvg = ratio >= 1 + let pct = Int(((ratio - 1) * 100).rounded()) + + HStack(spacing: 6) { + Image(systemName: aboveAvg ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(aboveAvg ? .green : .primary.opacity(0.45)) + + if pct == 0 { + Text( + localization.format( + "component.ai.dailyAverage.same", + defaultValue: "On par with your daily avg (%@)", + shortDuration(avg) + ) + ) + .font(.caption) + .foregroundStyle(.primary.opacity(0.55)) + } else { + let comparison = aboveAvg + ? localization.format( + "component.ai.dailyAverage.above", + defaultValue: "%d%% above your daily avg (%@)", + abs(pct), + shortDuration(avg) + ) + : localization.format( + "component.ai.dailyAverage.below", + defaultValue: "%d%% below your daily avg (%@)", + abs(pct), + shortDuration(avg) + ) + Text(comparison) + .font(.caption) + .foregroundStyle(.primary.opacity(0.55)) + } + } + .padding(.horizontal, 22) + .padding(.top, 4) + } + } + + private var aiDailyAvgLastWeek: TimeInterval { + let today = dateKey(for: Date()) + let days = controller.codingStats + .recentDays(count: 8) + .filter { $0.date != today && visibleCodingAggregateStats(in: $0).durationSeconds > 0 } + + guard !days.isEmpty else { + return 0 + } + + return days + .map { visibleCodingAggregateStats(in: $0).durationSeconds } + .reduce(0, +) / Double(days.count) + } + + private var topAppsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(localization.string("component.topApps.title", defaultValue: "Top Apps by Screen Time")) + .font(.headline) + .padding(.horizontal, 6) + .padding(.bottom, 2) + + let apps = Array(selectedInputStats.topApps.prefix(5)) + if apps.isEmpty { + Text( + controller.isTrackingEnabled + ? localization.string("component.topApps.empty", defaultValue: "No activity yet") + : localization.string("component.topApps.enableTracking", defaultValue: "Turn on tracking to rank your apps") + ) + .font(.subheadline) + .foregroundStyle(.primary.opacity(0.55)) + .padding(.vertical, 4) + .padding(.horizontal, 6) + } else { + let maxTime = apps.first?.stats.screenTimeSeconds ?? 1 + ForEach(apps, id: \.name) { app in + appRow(name: app.name, stats: app.stats, maxScreenTime: maxTime) + } + .onAppear { + if expandedAppName == nil { + expandedAppName = apps.first?.name + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func appRow(name: String, stats: ActivityBarAppStats, maxScreenTime: Double) -> some View { + let isExpanded = expandedAppName == name + + return VStack(spacing: 3) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + expandedAppName = isExpanded ? nil : name + } + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.primary.opacity(0.55)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + + Text(name) + .font(.body) + .lineLimit(1) + + Spacer(minLength: 8) + + Text(ActivityBarFormatting.duration(stats.screenTimeSeconds)) + .font(.body.monospacedDigit()) + .foregroundStyle(.primary.opacity(0.55)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + GeometryReader { geo in + let ratio = CGFloat(stats.screenTimeSeconds) / CGFloat(Swift.max(maxScreenTime, 1)) + RoundedRectangle(cornerRadius: 2) + .fill(.blue.opacity(0.25)) + .frame(width: geo.size.width * ratio, height: 3) + } + .frame(height: 3) + + if isExpanded { + HStack(spacing: 16) { + appDetailItem(icon: "keyboard", value: ActivityBarFormatting.decimal(stats.keystrokes)) + appDetailItem(icon: "cursorarrow.click.2", value: ActivityBarFormatting.decimal(stats.pointerClicks)) + appDetailItem(icon: "scroll", value: ActivityBarFormatting.decimal(stats.scrollEvents)) + } + .padding(.top, 4) + .padding(.leading, 16) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + } + + private func appDetailItem(icon: String, value: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9)) + .foregroundStyle(.primary.opacity(0.55)) + + Text(value) + .font(.caption.monospacedDigit()) + .foregroundStyle(.primary.opacity(0.55)) + .lineLimit(1) + } + } + + private var statsDisclosure: some View { + HStack { + Button { + statsExpanded.toggle() + } label: { + HStack(spacing: 6) { + Text(localization.string("component.trends.title", defaultValue: "Trends")) + .font(.headline) + + Image(systemName: "chevron.right") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary.opacity(0.55)) + .rotationEffect(.degrees(statsExpanded ? 90 : 0)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if statsExpanded { + Spacer(minLength: 8) + + ForEach(ActivityBarTrendMode.allCases, id: \.self) { mode in + Button { + withAnimation(.easeInOut(duration: 0.15)) { + trendMode = mode + hoveredDate = nil + } + } label: { + Text(mode.label(localization: localization)) + .font(.caption.weight(.medium)) + .foregroundStyle(.primary.opacity(trendMode == mode ? 1 : 0.35)) + } + .buttonStyle(.plain) + } + } else { + Spacer() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var codingToolsChart: some View { + let days = controller.codingStats.recentDays(count: chartRange.dayCount, endingAt: selectedDate) + let compactMode = chartRange.dayCount > 7 + let dateLabels = days.map { chartLabel($0.date) } + let hasData = days.contains { hasCodingStats(visibleCodingAggregateStats(in: $0)) } + let maxMinutes = days + .flatMap { day in + Self.visibleCodingTools.map { tool in + toolStats(tool, in: day).durationSeconds / 60 + } + } + .max() ?? 0 + let yUpperBound = Swift.max(maxMinutes * 1.15, 1) + + return VStack(alignment: .leading, spacing: 8) { + HStack { + rangePickerBar + Spacer(minLength: 8) + ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in + legendDot(color: Self.codingToolColor(tool), label: Self.codingToolLegendLabel(tool)) + } + } + .padding(.horizontal, 6) + + ZStack { + Chart { + // `hasData ? days : []` keeps the content a plain ForEach (no + // `_ConditionalContent`, whose ChartContent conformance is macOS 27-only); + // an empty range draws nothing, exactly as the old `if hasData` did. + ForEach(hasData ? days : []) { day in + let d = chartLabel(day.date) + let isHovered = hoveredDate == d + + ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in + let label = Self.codingToolLegendLabel(tool) + let minutes = toolStats(tool, in: day).durationSeconds / 60 + + LineMark(x: .value("Date", d), y: .value("Minutes", minutes), series: .value("Tool", label)) + .foregroundStyle(by: .value("Tool", label)) + .interpolationMethod(.catmullRom) + } + + // Unconditional marks; opacity/symbol size carry the hover state (the old + // if/else-if produced _ConditionalContent). The annotation body is a plain + // View, so its `if isHovered` is fine. + RuleMark(x: .value("Date", d)) + .foregroundStyle(.gray.opacity(isHovered ? 0.3 : 0)) + .lineStyle(StrokeStyle(dash: [4, 4])) + .annotation(position: .top, spacing: 0, overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) { + if isHovered { + VStack(spacing: 2) { + Text(shortDate(day.date)) + .font(.system(size: 9)) + .foregroundStyle(.primary.opacity(0.55)) + HStack(spacing: 6) { + ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in + let minutes = toolStats(tool, in: day).durationSeconds / 60 + Text(shortDuration(minutes * 60)) + .foregroundStyle(Self.codingToolColor(tool)) + } + } + .font(.system(size: 10).bold().monospacedDigit()) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 4)) + .offset(y: 30) + } + } + + ForEach(Self.visibleCodingTools, id: \.rawValue) { tool in + let minutes = toolStats(tool, in: day).durationSeconds / 60 + chartPoint( + x: d, + y: minutes, + color: Self.codingToolColor(tool), + size: hoverPointSize(isHovered: isHovered, compactMode: compactMode) + ) + } + } + } + .chartForegroundStyleScale([ + "Claude": Self.claudeColor, + "Cursor": Self.cursorColor, + "Codex": Self.codexColor, + ]) + .chartLegend(.hidden) + .chartYScale(domain: 0...yUpperBound) + .chartYAxis { + if hasData { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let minutes = value.as(Double.self) { + Text(talkAxisLabel(minutes)) + .font(.system(size: 9)) + } + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic) { value in + AxisValueLabel { + if let label = value.as(String.self), !compactMode || shouldShowXLabel(label, in: dateLabels) { + Text(label) + .font(.system(size: 9)) + } + } + } + } + .chartOverlay { proxy in + if hasData { + chartHoverOverlay(proxy: proxy, labels: dateLabels, hoveredLabel: $hoveredDate) + } + } + + if !hasData { + emptyChartMessage(localization.string("component.chart.emptyCoding", defaultValue: "No coding activity")) + } + } + .frame(height: 120) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var weeklyChart: some View { + let days = controller.inputStats.recentDays(count: chartRange.dayCount, endingAt: selectedDate) + let compactMode = chartRange.dayCount > 7 + let dateLabels = days.map { chartLabel($0.date) } + let hasData = days.contains { $0.totalInputs > 0 } + let maxInputCount = days + .map { day in + Swift.max(day.keystrokes, Swift.max(day.pointerClicks, day.scrollEvents)) + } + .max() ?? 0 + let yUpperBound = Swift.max(Double(maxInputCount) * 1.15, 1) + + return VStack(alignment: .leading, spacing: 8) { + HStack { + rangePickerBar + Spacer(minLength: 8) + legendDot(color: .blue, label: localization.string("component.legend.keys", defaultValue: "Keys")) + legendDot(color: .orange, label: localization.string("component.legend.clicks", defaultValue: "Clicks")) + legendDot(color: .green, label: localization.string("component.legend.scrolls", defaultValue: "Scrolls")) + } + .padding(.horizontal, 6) + + ZStack { + Chart { + // See codingToolsChart: `hasData ? days : []` avoids _ConditionalContent. + ForEach(hasData ? days : []) { day in + let d = chartLabel(day.date) + let isHovered = hoveredDate == d + + LineMark(x: .value("Date", d), y: .value("Count", day.keystrokes), series: .value("Metric", "Keystrokes")) + .foregroundStyle(by: .value("Metric", "Keystrokes")) + .interpolationMethod(.catmullRom) + LineMark(x: .value("Date", d), y: .value("Count", day.pointerClicks), series: .value("Metric", "Clicks")) + .foregroundStyle(by: .value("Metric", "Clicks")) + .interpolationMethod(.catmullRom) + LineMark(x: .value("Date", d), y: .value("Count", day.scrollEvents), series: .value("Metric", "Scrolls")) + .foregroundStyle(by: .value("Metric", "Scrolls")) + .interpolationMethod(.catmullRom) + + RuleMark(x: .value("Date", d)) + .foregroundStyle(.gray.opacity(isHovered ? 0.3 : 0)) + .lineStyle(StrokeStyle(dash: [4, 4])) + .annotation(position: .top, spacing: 0, overflowResolution: .init(x: .fit(to: .chart), y: .disabled)) { + if isHovered { + VStack(spacing: 2) { + Text(shortDate(day.date)) + .font(.system(size: 9)) + .foregroundStyle(.primary.opacity(0.55)) + HStack(spacing: 6) { + Text("\(day.keystrokes)").foregroundStyle(.blue) + Text("\(day.pointerClicks)").foregroundStyle(.orange) + Text("\(day.scrollEvents)").foregroundStyle(.green) + } + .font(.system(size: 10).bold().monospacedDigit()) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 4)) + .offset(y: 30) + } + } + + chartPoint(x: d, y: Double(day.keystrokes), color: .blue, size: hoverPointSize(isHovered: isHovered, compactMode: compactMode)) + chartPoint(x: d, y: Double(day.pointerClicks), color: .orange, size: hoverPointSize(isHovered: isHovered, compactMode: compactMode)) + chartPoint(x: d, y: Double(day.scrollEvents), color: .green, size: hoverPointSize(isHovered: isHovered, compactMode: compactMode)) + } + } + .chartForegroundStyleScale([ + "Keystrokes": Color.blue, + "Clicks": Color.orange, + "Scrolls": Color.green, + ]) + .chartLegend(.hidden) + .chartYScale(domain: 0...yUpperBound) + .chartYAxis { + if hasData { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let count = value.as(Double.self) { + Text(ActivityBarFormatting.count(Int(count))) + .font(.system(size: 9)) + } + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic) { value in + AxisValueLabel { + if let label = value.as(String.self), !compactMode || shouldShowXLabel(label, in: dateLabels) { + Text(label) + .font(.system(size: 9)) + } + } + } + } + .chartOverlay { proxy in + if hasData { + chartHoverOverlay(proxy: proxy, labels: dateLabels, hoveredLabel: $hoveredDate) + } + } + + if !hasData { + emptyChartMessage(localization.string("component.chart.emptyInput", defaultValue: "No input activity")) + } + } + .frame(height: 120) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var screenTimeSection: some View { + let days = controller.inputStats.recentDays(count: chartRange.dayCount, endingAt: selectedDate) + let compactMode = chartRange.dayCount > 7 + let labels = days.map { chartLabel($0.date) } + + return VStack(alignment: .leading, spacing: 6) { + Text(localization.string("component.screenTime.title", defaultValue: "Screen Time")) + .font(.headline) + + Chart { + ForEach(days) { day in + let d = chartLabel(day.date) + let isHovered = hoveredScreenTimeDate == d + BarMark( + x: .value("Date", d), + y: .value("Duration", day.screenTimeSeconds / 60) + ) + .foregroundStyle(isHovered ? .blue.opacity(0.7) : .blue.opacity(0.4)) + .cornerRadius(2) + .annotation(position: .top, spacing: 2) { + if isHovered, day.screenTimeSeconds > 0 { + Text(shortDuration(day.screenTimeSeconds)) + .font(.system(size: 9).bold().monospacedDigit()) + .foregroundStyle(.primary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 4)) + } + } + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisGridLine() + AxisValueLabel { + if let minutes = value.as(Double.self) { + Text(talkAxisLabel(minutes)) + .font(.system(size: 9)) + } + } + } + } + .chartXAxis { + AxisMarks(values: .automatic) { value in + AxisGridLine() + AxisValueLabel { + if let label = value.as(String.self), !compactMode || shouldShowXLabel(label, in: labels) { + Text(label) + .font(.system(size: 9)) + } + } + } + } + .chartOverlay { proxy in + chartHoverOverlay(proxy: proxy, labels: labels, hoveredLabel: $hoveredScreenTimeDate) + } + .frame(height: 60) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var rangePickerBar: some View { + HStack(spacing: 0) { + ForEach(ActivityBarChartRange.allCases, id: \.self) { range in + Button { + withAnimation(.easeInOut(duration: 0.15)) { + chartRange = range + hoveredDate = nil + hoveredScreenTimeDate = nil + } + } label: { + Text(range.label) + .font(.caption.weight(.medium)) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .foregroundStyle(chartRange == range ? .white : .primary.opacity(0.55)) + .background( + chartRange == range ? Color.blue : Color.clear, + in: RoundedRectangle(cornerRadius: 5, style: .continuous) + ) + } + .buttonStyle(.plain) + } + } + } + + private func legendDot(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(label) + .font(.caption) + .foregroundStyle(.primary.opacity(0.55)) + .lineLimit(1) + } + } + + private func emptyChartMessage(_ text: String) -> some View { + Text(text) + .font(.caption) + .foregroundStyle(.primary.opacity(0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + + private func chartPoint(x: String, y: Double, color: Color, size: CGFloat) -> some ChartContent { + PointMark(x: .value("Date", x), y: .value("Value", y)) + .foregroundStyle(color) + .symbolSize(size) + } + + /// Hover-driven point size, kept unconditional (0 hides the mark on compact + /// non-hovered days) so the Chart body has no `_ConditionalContent` — whose + /// `ChartContent` conformance is macOS 27-only and fails the 27-SDK build at + /// a 14.0 deployment target. + private func hoverPointSize(isHovered: Bool, compactMode: Bool) -> CGFloat { + if isHovered { return 30 } + return compactMode ? 0 : 12 + } + + private func chartHoverOverlay(proxy: ChartProxy, labels: [String], hoveredLabel: Binding) -> some View { + GeometryReader { geo in + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let location): + guard let plotFrame = proxy.plotFrame else { + return + } + + let frame = geo[plotFrame] + let x = location.x - frame.origin.x + var closest: String? + var closestDist: CGFloat = .infinity + + for label in labels { + if let position = proxy.position(forX: label) { + let distance = abs(position - x) + if distance < closestDist { + closestDist = distance + closest = label + } + } + } + + hoveredLabel.wrappedValue = closest + case .ended: + hoveredLabel.wrappedValue = nil + } + } + } + } + + private var footerBar: some View { + HStack(spacing: 12) { + Button { + if controller.areHooksInstalled { + controller.uninstallHooks() + } else { + controller.installHooks() + } + } label: { + Text(hookActionTitle) + .font(.subheadline) + .foregroundStyle(.primary.opacity(0.55)) + } + .buttonStyle(.plain) + + Button { + controller.openInputMonitoringSettings() + } label: { + Text(localization.string("component.footer.permissions", defaultValue: "Permissions")) + .font(.subheadline) + .foregroundStyle(.primary.opacity(0.55)) + } + .buttonStyle(.plain) + + Spacer() + + Button(action: dismiss) { + Text(localization.string("component.footer.close", defaultValue: "Close")) + .font(.subheadline) + .foregroundStyle(.primary.opacity(0.55)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var divider: some View { + Divider() + .padding(.horizontal, 12) + } + + private func codingToolRows(for day: ActivityBarCodingDailyStats) -> [CodingToolDisplayRow] { + let knownRows = Self.visibleCodingTools.compactMap { tool -> CodingToolDisplayRow? in + let stats = toolStats(tool, in: day) + guard hasCodingStats(stats) else { + return nil + } + + return displayRow(for: tool, stats: stats) + } + + if !knownRows.isEmpty { + return knownRows + } + + let aggregateStats = ActivityBarProjectStats( + durationSeconds: day.durationSeconds, + wordCount: day.wordCount, + toolCallCount: day.toolCallCount + ) + guard hasCodingStats(aggregateStats) else { + return [] + } + + return [ + CodingToolDisplayRow( + id: "ai-tools", + name: "AI Tools", + duration: aggregateStats.durationSeconds, + detail: codingDetailText(for: aggregateStats), + tint: Self.codexColor, + systemImage: "terminal", + iconSize: 13 + ) + ] + } + + private func displayRow(for tool: ActivityBarCodingTool, stats: ActivityBarProjectStats) -> CodingToolDisplayRow { + switch tool { + case .claudeCode: + return CodingToolDisplayRow( + id: tool.rawValue, + name: tool.rawValue, + duration: stats.durationSeconds, + detail: codingDetailText(for: stats), + tint: Self.claudeColor, + systemImage: "sparkles", + iconSize: 12 + ) + case .cursor: + return CodingToolDisplayRow( + id: tool.rawValue, + name: tool.rawValue, + duration: stats.durationSeconds, + detail: codingDetailText(for: stats), + tint: Self.cursorColor, + systemImage: "cube.fill", + iconSize: 13 + ) + case .codex: + return CodingToolDisplayRow( + id: tool.rawValue, + name: tool.rawValue, + duration: stats.durationSeconds, + detail: codingDetailText(for: stats), + tint: Self.codexColor, + systemImage: "terminal", + iconSize: 13 + ) + } + } + + private var hookActionTitle: String { + if controller.areHooksInstalled { + return localization.string("component.footer.uninstallHooks", defaultValue: "卸载 Hook") + } + + return localization.string("component.footer.hooks", defaultValue: "Hooks") + } + + private func toolStats(_ tool: ActivityBarCodingTool, in day: ActivityBarCodingDailyStats) -> ActivityBarProjectStats { + if let stats = day.perTool[tool.rawValue] { + return stats + } + + if day.perTool.isEmpty, tool == .claudeCode { + return ActivityBarProjectStats( + durationSeconds: day.durationSeconds, + wordCount: day.wordCount, + toolCallCount: day.toolCallCount + ) + } + + return ActivityBarProjectStats() + } + + private func visibleCodingAggregateStats(in day: ActivityBarCodingDailyStats) -> ActivityBarProjectStats { + let visibleToolStats = Self.visibleCodingTools.map { toolStats($0, in: day) } + if visibleToolStats.contains(where: { hasCodingStats($0) }) { + return visibleToolStats.reduce(ActivityBarProjectStats()) { result, stats in + ActivityBarProjectStats( + durationSeconds: result.durationSeconds + stats.durationSeconds, + wordCount: result.wordCount + stats.wordCount, + toolCallCount: result.toolCallCount + stats.toolCallCount + ) + } + } + + guard day.perTool.isEmpty else { + return ActivityBarProjectStats() + } + + return ActivityBarProjectStats( + durationSeconds: day.durationSeconds, + wordCount: day.wordCount, + toolCallCount: day.toolCallCount + ) + } + + private func hasCodingStats(_ stats: ActivityBarProjectStats) -> Bool { + stats.durationSeconds > 0 || stats.wordCount > 0 || stats.toolCallCount > 0 + } + + private func codingDetailText(for stats: ActivityBarProjectStats) -> String { + localization.format( + "component.ai.detail", + defaultValue: "%@ words · %@ tools", + ActivityBarFormatting.decimal(stats.wordCount), + ActivityBarFormatting.decimal(stats.toolCallCount) + ) + } + + private var displayDateString: String { + if isViewingToday { + return localization.string("component.date.today", defaultValue: "Today") + } + + if selectedDateOffset == -1 { + return localization.string("component.date.yesterday", defaultValue: "Yesterday") + } + + return ActivityBarFormatting.monthDay(selectedDate) + } + + private func shortDate(_ dateString: String) -> String { + let parts = dateString.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]), + let date = Calendar.current.date(from: DateComponents(year: year, month: month, day: day)) + else { + return dateString + } + + return ActivityBarFormatting.monthDay(date) + } + + private func chartLabel(_ dateString: String) -> String { + if chartRange.dayCount <= 7 { + return shortDate(dateString) + } + + let parts = dateString.split(separator: "-") + guard parts.count == 3 else { + return dateString + } + + let day = Int(parts[2]) ?? 0 + if day == 1 { + return shortDate(dateString) + } + + return "\(day)" + } + + private func shouldShowXLabel(_ label: String, in allLabels: [String]) -> Bool { + guard let index = allLabels.firstIndex(of: label) else { + return false + } + + let step = chartRange.dayCount <= 14 ? 2 : 5 + return index % step == 0 || index == allLabels.count - 1 + } + + private func talkAxisLabel(_ minutes: Double) -> String { + if minutes >= 60 { + return "\(Int(minutes / 60))h" + } + + return "\(Int(minutes))m" + } + + private func shortDuration(_ seconds: Double) -> String { + let total = max(Int(seconds), 0) + let hours = total / 3_600 + let minutes = (total % 3_600) / 60 + let secs = total % 60 + + if hours > 0 { + return "\(hours)h \(minutes)m" + } + + if minutes > 0 { + return "\(minutes)m \(secs)s" + } + + return "\(secs)s" + } + + private func dateKey(for date: Date) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.year, .month, .day], from: date) + let year = components.year ?? 0 + let month = components.month ?? 0 + let day = components.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } + + private struct CodingToolDisplayRow: Identifiable { + let id: String + let name: String + let duration: TimeInterval + let detail: String? + let tint: Color + let systemImage: String + let iconSize: CGFloat + } +} + +private struct ActivityBarComponentBackground: View { + let cornerRadius: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.primary.opacity(0.045)) + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarController.swift b/Plugins/ActivityBar/Sources/ActivityBarController.swift new file mode 100644 index 0000000..e172c24 --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarController.swift @@ -0,0 +1,400 @@ +import AppKit +import Foundation +import MacToolsPluginKit + +@MainActor +final class ActivityBarController: ObservableObject { + static let pluginID = ActivityBarConstants.pluginID + static let defaultSocketPath = ActivityBarConstants.defaultSocketPath + + enum HookInstallState: Equatable { + case notInstalled + case installed(timestamp: String) + case failed + } + + private enum StorageKey { + static let isTrackingEnabled = "activity-bar.tracking.enabled" + static let hooksInstalledAt = "activity-bar.hooks.installed-at" + } + + @Published private(set) var isTrackingEnabled: Bool + @Published private(set) var lastErrorMessage: String? + @Published private(set) var hookInstallState: HookInstallState = .notInstalled + + let localization: PluginLocalization + let inputStats: ActivityBarStatsStore + let codingStats: ActivityBarCodingSessionStore + + var onStateChange: (() -> Void)? + + private let storage: PluginStorage + private let inputMonitor: any ActivityBarInputMonitoring + private let socketServer: any ActivityBarSocketServing + private let inputEventNotificationDelay: Duration + private var hookInstallerPaths: ActivityBarHookInstallerPaths + private var inputEventNotificationTask: Task? + private var terminateObserver: NSObjectProtocol? + + init( + context: PluginRuntimeContext, + inputMonitor: (any ActivityBarInputMonitoring)? = nil, + socketServer: (any ActivityBarSocketServing)? = nil, + inputStats: ActivityBarStatsStore? = nil, + codingStats: ActivityBarCodingSessionStore? = nil, + hookInstallerPaths: ActivityBarHookInstallerPaths? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main), + inputEventNotificationDelay: Duration = .milliseconds(750) + ) { + let resolvedInputStats = inputStats ?? ActivityBarStatsStore(storage: context.storage) + let resolvedCodingStats = codingStats ?? ActivityBarCodingSessionStore(storage: context.storage) + let resolvedInputMonitor = inputMonitor ?? ActivityBarInputMonitor() + let resolvedHookInstallerPaths = hookInstallerPaths + ?? ActivityBarHookInstallerPaths.defaults( + supportDirectory: context.supportDirectory, + homeDirectory: FileManager.default.homeDirectoryForCurrentUser + ) + let resolvedSocketServer = socketServer ?? ActivityBarHookSocketServer { event in + resolvedCodingStats.handleEvent(event) + } + + storage = context.storage + self.localization = localization + self.inputStats = resolvedInputStats + self.codingStats = resolvedCodingStats + self.inputMonitor = resolvedInputMonitor + self.hookInstallerPaths = resolvedHookInstallerPaths + self.socketServer = resolvedSocketServer + self.inputEventNotificationDelay = inputEventNotificationDelay + isTrackingEnabled = context.storage.bool(forKey: StorageKey.isTrackingEnabled) + + self.inputMonitor.onEvent = { [weak self] event in + guard let self else { + return + } + self.inputStats.record(event) + self.scheduleInputEventNotification() + } + + if let installedAt = context.storage.string(forKey: StorageKey.hooksInstalledAt) { + hookInstallState = .installed(timestamp: installedAt) + } + + terminateObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.inputStats.flushPendingChanges() + self?.codingStats.flushActiveDurations() + } + } + } + + @MainActor deinit { + inputEventNotificationTask?.cancel() + if let terminateObserver { + NotificationCenter.default.removeObserver(terminateObserver) + } + } + + var hookStatusMessage: String? { + switch hookInstallState { + case .notInstalled: + return nil + case .installed(let timestamp): + return localization.format("hook.status.installedWithDate", defaultValue: "已安装:%@", timestamp) + case .failed: + return localization.string("hook.status.installFailed", defaultValue: "安装失败") + } + } + + var isHookListenerRunning: Bool { + socketServer.isRunning + } + + var areHooksInstalled: Bool { + if case .installed = hookInstallState { + return true + } + + return false + } + + var monitorStatus: ActivityBarInputMonitorStatus { + inputMonitor.status + } + + var todayInputStats: ActivityBarDailyStats { + inputStats.today + } + + var todayCodingStats: ActivityBarCodingDailyStats { + codingStats.today + } + + var panelSubtitle: String { + if let lastErrorMessage { + return lastErrorMessage + } + + if isTrackingEnabled { + return localization.format( + "panel.subtitle.todayInputs", + defaultValue: "今日 %@ 次输入", + ActivityBarFormatting.count(todayInputStats.totalInputs) + ) + } + + if isHookListenerRunning { + return localization.string("panel.subtitle.aiListening", defaultValue: "AI 活动监听中") + } + + return localization.string("panel.subtitle.default", defaultValue: "统计输入与 AI 编程活动") + } + + var componentSubtitle: String { + if isTrackingEnabled { + return localization.format( + "component.subtitle.inputs", + defaultValue: "%@ 次输入", + ActivityBarFormatting.count(todayInputStats.totalInputs) + ) + } + if isHookListenerRunning { + return localization.string("component.subtitle.aiListening", defaultValue: "AI 监听中") + } + return localization.string("component.subtitle.disabled", defaultValue: "未开启") + } + + var inputMonitoringFootnote: String? { + switch inputMonitor.status { + case .inputMonitoringDenied: + return localization.string( + "settings.inputMonitoring.footnote", + defaultValue: "键盘、鼠标和滚动统计需要在系统设置中允许 MacTools 进行输入监控。前台应用使用时长仍可记录。" + ) + case .idle, .running: + return nil + } + } + + var hookInstallFootnote: String { + localization.string( + "settings.aiHooks.footnote", + defaultValue: "点击后会写入 Claude Code、Cursor 和 Codex 的 hook 配置;AI 活动监听不需要输入监控权限。" + ) + } + + var hookUninstallFootnote: String { + localization.string( + "settings.aiHooks.uninstallFootnote", + defaultValue: "只移除 MacTools 写入的 Hook 条目和脚本,不会清空其他工具配置。" + ) + } + + var hookActionFootnote: String { + areHooksInstalled ? hookUninstallFootnote : hookInstallFootnote + } + + func activate(context: PluginRuntimeContext) { + hookInstallerPaths = ActivityBarHookInstallerPaths.defaults( + supportDirectory: context.supportDirectory, + homeDirectory: FileManager.default.homeDirectoryForCurrentUser + ) + + startHookListenerIfNeeded() + if isTrackingEnabled { + startInputTracking() + } + } + + func deactivate(reason: PluginDeactivationReason) { + stopRuntime() + inputEventNotificationTask?.cancel() + inputEventNotificationTask = nil + } + + func refresh() { + inputStats.flushPendingChanges() + codingStats.flushActiveDurations() + notifyChange() + } + + func setTrackingEnabled(_ enabled: Bool) { + isTrackingEnabled = enabled + storage.set(enabled, forKey: StorageKey.isTrackingEnabled) + + if enabled { + startInputTracking() + startHookListenerIfNeeded() + } else { + stopInputTracking() + if !shouldRunHookListener { + lastErrorMessage = nil + } + } + + notifyChange() + } + + func resetToday() { + inputStats.resetToday() + codingStats.resetToday() + notifyChange() + } + + func installHooks() { + let installer = ActivityBarHookInstaller(paths: hookInstallerPaths) + + do { + let summary = try installer.install() + let timestamp = Self.installTimestamp() + storage.set(timestamp, forKey: StorageKey.hooksInstalledAt) + hookInstallState = .installed(timestamp: timestamp) + lastErrorMessage = nil + startHookListenerIfNeeded() + ActivityBarLog.hooks.info( + "Activity bar hooks installed in \(summary.scriptDirectory.path, privacy: .public)" + ) + } catch { + lastErrorMessage = localization.format( + "error.hook.installFailed", + defaultValue: "Hook 安装失败:%@", + localizedDescription(for: error) + ) + hookInstallState = .failed + ActivityBarLog.hooks.error("Activity bar hook installation failed: \(error.localizedDescription, privacy: .public)") + } + + notifyChange() + } + + func uninstallHooks() { + let installer = ActivityBarHookInstaller(paths: hookInstallerPaths) + + do { + let summary = try installer.uninstall() + storage.removeObject(forKey: StorageKey.hooksInstalledAt) + hookInstallState = .notInstalled + lastErrorMessage = nil + socketServer.stop() + codingStats.flushActiveDurations() + ActivityBarLog.hooks.info( + "Activity bar hooks uninstalled from \(summary.scriptDirectory.path, privacy: .public)" + ) + } catch { + lastErrorMessage = localization.format( + "error.hook.uninstallFailed", + defaultValue: "Hook 卸载失败:%@", + localizedDescription(for: error) + ) + hookInstallState = .failed + ActivityBarLog.hooks.error("Activity bar hook uninstallation failed: \(error.localizedDescription, privacy: .public)") + } + + notifyChange() + } + + func openInputMonitoringSettings() { + openPrivacyPane(anchor: "Privacy_ListenEvent") + } + + private var shouldRunHookListener: Bool { + if case .installed = hookInstallState { + return true + } + + return false + } + + private func startInputTracking() { + inputMonitor.start() + } + + private func startHookListenerIfNeeded() { + guard shouldRunHookListener else { + return + } + + do { + try socketServer.start() + } catch { + lastErrorMessage = localization.format( + "error.socket.startFailed", + defaultValue: "AI 活动监听启动失败:%@", + localizedDescription(for: error) + ) + ActivityBarLog.socket.error("Activity bar socket start failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func stopRuntime() { + stopInputTracking() + socketServer.stop() + codingStats.flushActiveDurations() + } + + private func stopInputTracking() { + inputMonitor.stop() + inputStats.flushPendingChanges() + } + + private func openPrivacyPane(anchor: String) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { + return + } + + NSWorkspace.shared.open(url) + } + + private func notifyChange() { + inputEventNotificationTask?.cancel() + inputEventNotificationTask = nil + objectWillChange.send() + onStateChange?() + } + + private func scheduleInputEventNotification() { + guard inputEventNotificationTask == nil else { + return + } + + inputEventNotificationTask = Task { @MainActor [weak self] in + guard let self else { + return + } + + do { + try await Task.sleep(for: inputEventNotificationDelay) + } catch { + return + } + + guard !Task.isCancelled else { + return + } + + inputEventNotificationTask = nil + objectWillChange.send() + onStateChange?() + } + } + + private func localizedDescription(for error: Error) -> String { + if let hookError = error as? ActivityBarHookInstallerError { + return hookError.localizedDescription(localization: localization) + } + if let socketError = error as? ActivityBarSocketError { + return socketError.localizedDescription(localization: localization) + } + return error.localizedDescription + } + + private static func installTimestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: Date()) + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarHookInstaller.swift b/Plugins/ActivityBar/Sources/ActivityBarHookInstaller.swift new file mode 100644 index 0000000..9a0581e --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarHookInstaller.swift @@ -0,0 +1,650 @@ +import Foundation +import MacToolsPluginKit + +struct ActivityBarHookInstallSummary: Equatable, Sendable { + let scriptDirectory: URL + let installedTools: [String] +} + +struct ActivityBarHookUninstallSummary: Equatable, Sendable { + let scriptDirectory: URL + let removedTools: [String] +} + +struct ActivityBarHookInstallerPaths: Equatable, Sendable { + let hookScriptsDirectory: URL + let claudeSettingsPath: URL + let cursorHooksPath: URL + let codexConfigPath: URL + let codexHooksPath: URL + + init(homeDirectory: URL, hookScriptsDirectory: URL? = nil) { + self.hookScriptsDirectory = hookScriptsDirectory + ?? homeDirectory.appendingPathComponent(".mactools/activity-bar/hooks") + self.claudeSettingsPath = homeDirectory.appendingPathComponent(".claude/settings.json") + self.cursorHooksPath = homeDirectory.appendingPathComponent(".cursor/hooks.json") + self.codexConfigPath = homeDirectory.appendingPathComponent(".codex/config.toml") + self.codexHooksPath = homeDirectory.appendingPathComponent(".codex/hooks.json") + } + + static func defaults( + supportDirectory: URL?, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ActivityBarHookInstallerPaths { + ActivityBarHookInstallerPaths( + homeDirectory: homeDirectory, + hookScriptsDirectory: supportDirectory?.appendingPathComponent("hooks") + ) + } +} + +enum ActivityBarHookInstallerError: LocalizedError, Equatable { + case invalidJSON(URL) + + var errorDescription: String? { + localizedDescription(localization: PluginLocalization(bundle: .main)) + } + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case let .invalidJSON(url): + return localization.format("error.hook.invalidJSON", defaultValue: "无法解析配置文件:%@", url.path) + } + } +} + +struct ActivityBarHookInstaller { + private enum FileName { + static let claude = "mactools-activity-claude-hook.sh" + static let cursor = "mactools-activity-cursor-hook.sh" + static let codex = "mactools-activity-codex-hook.sh" + } + + private let paths: ActivityBarHookInstallerPaths + private let socketPath: String + private let fileManager: FileManager + + init( + paths: ActivityBarHookInstallerPaths, + socketPath: String = ActivityBarConstants.defaultSocketPath, + fileManager: FileManager = .default + ) { + self.paths = paths + self.socketPath = socketPath + self.fileManager = fileManager + } + + func install() throws -> ActivityBarHookInstallSummary { + try installScript(fileName: FileName.claude, content: claudeHookScript) + try installScript(fileName: FileName.cursor, content: cursorHookScript) + try installScript(fileName: FileName.codex, content: codexHookScript) + + try registerClaudeHooks() + try registerCursorHooks() + try enableCodexHooksFeature() + try registerCodexHooks() + + return ActivityBarHookInstallSummary( + scriptDirectory: paths.hookScriptsDirectory, + installedTools: ["Claude Code", "Cursor", "Codex"] + ) + } + + func uninstall() throws -> ActivityBarHookUninstallSummary { + try unregisterClaudeHooks() + try unregisterCursorHooks() + try unregisterCodexHooks() + try removeInstalledScripts() + + return ActivityBarHookUninstallSummary( + scriptDirectory: paths.hookScriptsDirectory, + removedTools: ["Claude Code", "Cursor", "Codex"] + ) + } + + private var claudeScriptURL: URL { + paths.hookScriptsDirectory.appendingPathComponent(FileName.claude) + } + + private var cursorScriptURL: URL { + paths.hookScriptsDirectory.appendingPathComponent(FileName.cursor) + } + + private var codexScriptURL: URL { + paths.hookScriptsDirectory.appendingPathComponent(FileName.codex) + } + + private func installScript(fileName: String, content: String) throws { + try fileManager.createDirectory( + at: paths.hookScriptsDirectory, + withIntermediateDirectories: true + ) + + let scriptURL = paths.hookScriptsDirectory.appendingPathComponent(fileName) + let existingContent = try? String(contentsOf: scriptURL, encoding: .utf8) + if existingContent != content { + try content.write(to: scriptURL, atomically: true, encoding: .utf8) + } + + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: scriptURL.path + ) + } + + private func registerClaudeHooks() throws { + let events = [ + "PreToolUse", "PostToolUse", "UserPromptSubmit", + "Stop", "SubagentStop", "PreCompact", + "SessionStart", "SessionEnd", "PermissionRequest", + ] + var settings = try readJSONObject(at: paths.claudeSettingsPath) + var hooks = settings["hooks"] as? [String: Any] ?? [:] + let command: [String: Any] = [ + "type": "command", + "command": shellQuoted(claudeScriptURL.path), + "timeout": 5000, + ] + var needsWrite = false + + for event in events { + var groups = hooks[event] as? [[String: Any]] ?? [] + let alreadyRegistered = groups.contains { group in + guard let groupHooks = group["hooks"] as? [[String: Any]] else { + return false + } + return groupHooks.contains { hook in + (hook["command"] as? String)?.contains(FileName.claude) == true + } + } + + if !alreadyRegistered { + groups.append([ + "matcher": "", + "hooks": [command], + ]) + hooks[event] = groups + needsWrite = true + } + } + + guard needsWrite else { + return + } + + settings["hooks"] = hooks + try writeJSONObject(settings, to: paths.claudeSettingsPath) + } + + private func unregisterClaudeHooks() throws { + try unregisterGroupedHooks(at: paths.claudeSettingsPath, scriptFileName: FileName.claude) + } + + private func registerCursorHooks() throws { + let events = [ + "beforeSubmitPrompt", "stop", "afterFileEdit", + "beforeReadFile", "beforeShellExecution", "beforeMCPExecution", + ] + var root = try readJSONObject(at: paths.cursorHooksPath) + if root["version"] == nil { + root["version"] = 1 + } + + var hooks = root["hooks"] as? [String: Any] ?? [:] + var needsWrite = false + + for event in events { + var entries = hooks[event] as? [[String: Any]] ?? [] + let alreadyRegistered = entries.contains { entry in + (entry["command"] as? String)?.contains(FileName.cursor) == true + } + + if !alreadyRegistered { + entries.append([ + "command": "\(shellQuoted(cursorScriptURL.path)) \(event)", + ]) + hooks[event] = entries + needsWrite = true + } + } + + guard needsWrite else { + return + } + + root["hooks"] = hooks + try writeJSONObject(root, to: paths.cursorHooksPath) + } + + private func unregisterCursorHooks() throws { + guard fileManager.fileExists(atPath: paths.cursorHooksPath.path) else { + return + } + + var root = try readJSONObject(at: paths.cursorHooksPath) + guard var hooks = root["hooks"] as? [String: Any] else { + return + } + + var needsWrite = false + + for event in Array(hooks.keys) { + guard let entries = hooks[event] as? [[String: Any]] else { + continue + } + + let filteredEntries = entries.filter { entry in + !containsHookCommand(entry["command"], scriptFileName: FileName.cursor) + } + + guard filteredEntries.count != entries.count else { + continue + } + + if filteredEntries.isEmpty { + hooks.removeValue(forKey: event) + } else { + hooks[event] = filteredEntries + } + needsWrite = true + } + + guard needsWrite else { + return + } + + if hooks.isEmpty { + root.removeValue(forKey: "hooks") + } else { + root["hooks"] = hooks + } + try writeJSONObject(root, to: paths.cursorHooksPath) + } + + private func enableCodexHooksFeature() throws { + try createParentDirectory(for: paths.codexConfigPath) + + var config = "" + if fileManager.fileExists(atPath: paths.codexConfigPath.path) { + config = try String(contentsOf: paths.codexConfigPath, encoding: .utf8) + } + + let lines = config.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var updatedLines = lines + var foundCodexHooks = false + + for index in updatedLines.indices { + if updatedLines[index].trimmingCharacters(in: .whitespaces).hasPrefix("codex_hooks") { + updatedLines[index] = "codex_hooks = true" + foundCodexHooks = true + } + } + + if !foundCodexHooks { + if let featuresIndex = updatedLines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { + updatedLines.insert("codex_hooks = true", at: featuresIndex + 1) + } else { + if !updatedLines.isEmpty, updatedLines.last != "" { + updatedLines.append("") + } + updatedLines.append("[features]") + updatedLines.append("codex_hooks = true") + } + } + + var updated = updatedLines.joined(separator: "\n") + if !updated.hasSuffix("\n") { + updated += "\n" + } + if updated != config { + try updated.write(to: paths.codexConfigPath, atomically: true, encoding: .utf8) + } + } + + private func registerCodexHooks() throws { + let events = [ + "PreToolUse", "PostToolUse", "UserPromptSubmit", + "Stop", "SessionStart", "SessionEnd", + ] + var root = try readJSONObject(at: paths.codexHooksPath) + var hooks = root["hooks"] as? [String: Any] ?? [:] + let command: [String: Any] = [ + "type": "command", + "command": shellQuoted(codexScriptURL.path), + "timeout": 5000, + ] + var needsWrite = false + + for event in events { + var groups = hooks[event] as? [[String: Any]] ?? [] + let alreadyRegistered = groups.contains { group in + guard let groupHooks = group["hooks"] as? [[String: Any]] else { + return false + } + return groupHooks.contains { hook in + (hook["command"] as? String)?.contains(FileName.codex) == true + } + } + + if !alreadyRegistered { + groups.append([ + "matcher": "", + "hooks": [command], + ]) + hooks[event] = groups + needsWrite = true + } + } + + guard needsWrite else { + return + } + + root["hooks"] = hooks + try writeJSONObject(root, to: paths.codexHooksPath) + } + + private func unregisterCodexHooks() throws { + try unregisterGroupedHooks(at: paths.codexHooksPath, scriptFileName: FileName.codex) + } + + private func unregisterGroupedHooks(at url: URL, scriptFileName: String) throws { + guard fileManager.fileExists(atPath: url.path) else { + return + } + + var root = try readJSONObject(at: url) + guard var hooks = root["hooks"] as? [String: Any] else { + return + } + + var needsWrite = false + + for event in Array(hooks.keys) { + guard let groups = hooks[event] as? [[String: Any]] else { + continue + } + + var updatedGroups: [[String: Any]] = [] + + for group in groups { + guard let groupHooks = group["hooks"] as? [[String: Any]] else { + updatedGroups.append(group) + continue + } + + let filteredHooks = groupHooks.filter { hook in + !containsHookCommand(hook["command"], scriptFileName: scriptFileName) + } + + if filteredHooks.count != groupHooks.count { + needsWrite = true + } + + guard !filteredHooks.isEmpty else { + continue + } + + var updatedGroup = group + updatedGroup["hooks"] = filteredHooks + updatedGroups.append(updatedGroup) + } + + if updatedGroups.isEmpty { + hooks.removeValue(forKey: event) + needsWrite = true + } else { + hooks[event] = updatedGroups + } + } + + guard needsWrite else { + return + } + + if hooks.isEmpty { + root.removeValue(forKey: "hooks") + } else { + root["hooks"] = hooks + } + try writeJSONObject(root, to: url) + } + + private func removeInstalledScripts() throws { + for scriptURL in [claudeScriptURL, cursorScriptURL, codexScriptURL] { + guard fileManager.fileExists(atPath: scriptURL.path) else { + continue + } + + try fileManager.removeItem(at: scriptURL) + } + + guard + fileManager.fileExists(atPath: paths.hookScriptsDirectory.path), + (try? fileManager.contentsOfDirectory(atPath: paths.hookScriptsDirectory.path).isEmpty) == true + else { + return + } + + try? fileManager.removeItem(at: paths.hookScriptsDirectory) + } + + private func readJSONObject(at url: URL) throws -> [String: Any] { + guard fileManager.fileExists(atPath: url.path) else { + return [:] + } + + let data = try Data(contentsOf: url) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ActivityBarHookInstallerError.invalidJSON(url) + } + return object + } + + private func writeJSONObject(_ object: [String: Any], to url: URL) throws { + try createParentDirectory(for: url) + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: url, options: .atomic) + } + + private func createParentDirectory(for url: URL) throws { + let parent = url.deletingLastPathComponent() + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + } + + private func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private func containsHookCommand(_ value: Any?, scriptFileName: String) -> Bool { + (value as? String)?.contains(scriptFileName) == true + } +} + +private extension ActivityBarHookInstaller { + var claudeHookScript: String { + """ + #!/bin/bash + # MacTools Activity Bar hook for Claude Code. + + SOCKET_PATH="\(socketPath)" + [ -S "$SOCKET_PATH" ] || exit 0 + + IS_INTERACTIVE=true + for CHECK_PID in $PPID $(ps -o ppid= -p $PPID 2>/dev/null | tr -d ' '); do + if ps -o args= -p "$CHECK_PID" 2>/dev/null | grep -qE '(^| )(-p|--print)( |$)'; then + IS_INTERACTIVE=false + break + fi + done + export MACTOOLS_ACTIVITY_INTERACTIVE=$IS_INTERACTIVE + + /usr/bin/python3 -c ' + import json + import os + import socket + import sys + + socket_path = sys.argv[1] + try: + input_data = json.load(sys.stdin) + except Exception: + sys.exit(0) + + hook_event = input_data.get("hook_event_name", "") + status_map = { + "UserPromptSubmit": "processing", + "PreCompact": "compacting", + "SessionStart": "waiting_for_input", + "SessionEnd": "ended", + "PreToolUse": "running_tool", + "PostToolUse": "processing", + "PermissionRequest": "waiting_for_input", + "Stop": "waiting_for_input", + "SubagentStop": "waiting_for_input", + } + output = { + "session_id": input_data.get("session_id", ""), + "cwd": input_data.get("cwd", ""), + "event": hook_event, + "status": input_data.get("status", status_map.get(hook_event, "unknown")), + "interactive": os.environ.get("MACTOOLS_ACTIVITY_INTERACTIVE", "true") == "true", + } + if hook_event == "UserPromptSubmit" and input_data.get("prompt"): + output["user_prompt"] = input_data.get("prompt") + if input_data.get("tool_name"): + output["tool"] = input_data.get("tool_name") + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(socket_path) + sock.sendall(json.dumps(output).encode()) + sock.close() + except Exception: + pass + ' "$SOCKET_PATH" + """ + } + + var cursorHookScript: String { + """ + #!/bin/bash + # MacTools Activity Bar hook for Cursor. + + SOCKET_PATH="\(socketPath)" + [ -S "$SOCKET_PATH" ] || { cat > /dev/null 2>&1; exit 0; } + EVENT_TYPE="$1" + SESSION_ID="cursor-$(echo "${PWD}-$(date +%Y-%m-%d)" | shasum | cut -c1-12)" + + /usr/bin/python3 -c ' + import json + import os + import socket + import sys + + socket_path = sys.argv[1] + event_type = sys.argv[2] if len(sys.argv) > 2 else "" + session_id = sys.argv[3] if len(sys.argv) > 3 else "cursor" + event_map = { + "beforeSubmitPrompt": "UserPromptSubmit", + "afterFileEdit": "PostToolUse", + "stop": "Stop", + "beforeReadFile": "PreToolUse", + "beforeShellExecution": "PreToolUse", + "beforeMCPExecution": "PreToolUse", + } + mapped_event = event_map.get(event_type) + if not mapped_event: + sys.exit(0) + status_map = { + "UserPromptSubmit": "processing", + "PostToolUse": "processing", + "Stop": "waiting_for_input", + "PreToolUse": "running_tool", + } + output = { + "session_id": session_id, + "cwd": os.getcwd(), + "event": mapped_event, + "status": status_map.get(mapped_event, "unknown"), + "interactive": True, + } + try: + input_data = json.load(sys.stdin) + if isinstance(input_data, dict): + for key in ("prompt", "message", "content", "text", "query"): + if isinstance(input_data.get(key), str): + output["user_prompt"] = input_data[key] + break + if input_data.get("filePath"): + output["tool"] = "Read" + if input_data.get("command"): + output["tool"] = "Bash" + except Exception: + pass + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(socket_path) + sock.sendall(json.dumps(output).encode()) + sock.close() + except Exception: + pass + ' "$SOCKET_PATH" "$EVENT_TYPE" "$SESSION_ID" + """ + } + + var codexHookScript: String { + """ + #!/bin/bash + # MacTools Activity Bar hook for Codex. + + SOCKET_PATH="\(socketPath)" + [ -S "$SOCKET_PATH" ] || exit 0 + + /usr/bin/python3 -c ' + import json + import socket + import sys + + socket_path = sys.argv[1] + try: + input_data = json.load(sys.stdin) + except Exception: + sys.exit(0) + + hook_event = input_data.get("hook_event_name", "") + session_id = input_data.get("session_id", "") + if session_id and not session_id.startswith("codex-"): + session_id = "codex-" + session_id + status_map = { + "UserPromptSubmit": "processing", + "SessionStart": "waiting_for_input", + "SessionEnd": "ended", + "PreToolUse": "running_tool", + "PostToolUse": "processing", + "Stop": "waiting_for_input", + } + output = { + "session_id": session_id, + "cwd": input_data.get("cwd", ""), + "event": hook_event, + "status": status_map.get(hook_event, "unknown"), + "interactive": True, + } + if hook_event == "UserPromptSubmit" and input_data.get("prompt"): + output["user_prompt"] = input_data.get("prompt") + if input_data.get("tool_name"): + output["tool"] = input_data.get("tool_name") + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(socket_path) + sock.sendall(json.dumps(output).encode()) + sock.close() + except Exception: + pass + ' "$SOCKET_PATH" + """ + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarInputMonitor.swift b/Plugins/ActivityBar/Sources/ActivityBarInputMonitor.swift new file mode 100644 index 0000000..ade1ce6 --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarInputMonitor.swift @@ -0,0 +1,203 @@ +import AppKit +import ApplicationServices +import Foundation + +@MainActor +protocol ActivityBarInputMonitoring: AnyObject { + var status: ActivityBarInputMonitorStatus { get } + var onEvent: ((ActivityBarInputEvent) -> Void)? { get set } + + func start() + func stop() +} + +@MainActor +final class ActivityBarInputMonitor: ActivityBarInputMonitoring { + private enum Timing { + static let screenTimeFlushInterval: TimeInterval = 30 + static let minimumScreenTimeFlush: TimeInterval = 0.5 + static let scrollGestureGap: TimeInterval = 0.3 + } + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var activeApp: String? + private var activeAppSince: Date? + private var screenTimeTimer: Timer? + private var workspaceObserver: NSObjectProtocol? + + private nonisolated(unsafe) static var lastScrollTime: Date = .distantPast + + var status: ActivityBarInputMonitorStatus = .idle + var onEvent: ((ActivityBarInputEvent) -> Void)? + + func start() { + guard status != .running else { + return + } + + startScreenTimeTracking() + startEventTap() + } + + func stop() { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + } + + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + } + + eventTap = nil + runLoopSource = nil + + flushScreenTime() + screenTimeTimer?.invalidate() + screenTimeTimer = nil + + if let workspaceObserver { + NSWorkspace.shared.notificationCenter.removeObserver(workspaceObserver) + } + workspaceObserver = nil + + activeApp = nil + activeAppSince = nil + status = .idle + } + + private var frontmostAppName: String { + let application = NSWorkspace.shared.frontmostApplication + + if let localizedName = application?.localizedName, !localizedName.isEmpty { + return localizedName + } + + if let bundleIdentifier = application?.bundleIdentifier, !bundleIdentifier.isEmpty { + return bundleIdentifier + } + + return "Unknown" + } + + private func startEventTap() { + let eventMask: CGEventMask = ( + (1 << CGEventType.keyDown.rawValue) | + (1 << CGEventType.leftMouseDown.rawValue) | + (1 << CGEventType.rightMouseDown.rawValue) | + (1 << CGEventType.otherMouseDown.rawValue) | + (1 << CGEventType.scrollWheel.rawValue) + ) + + let userInfo = Unmanaged.passUnretained(self).toOpaque() + + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .tailAppendEventTap, + options: .listenOnly, + eventsOfInterest: eventMask, + callback: { _, eventType, event, userInfo -> Unmanaged? in + guard let userInfo else { + return Unmanaged.passUnretained(event) + } + + let monitor = Unmanaged + .fromOpaque(userInfo) + .takeUnretainedValue() + + Task { @MainActor in + monitor.handleCGEvent(type: eventType) + } + + return Unmanaged.passUnretained(event) + }, + userInfo: userInfo + ) else { + status = .inputMonitoringDenied + ActivityBarLog.input.warning("Input event tap could not be created; Input Monitoring may be missing") + return + } + + eventTap = tap + runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + status = .running + } + + private func handleCGEvent(type: CGEventType) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: true) + } + return + } + + switch type { + case .keyDown: + onEvent?(.keystroke(app: currentActiveAppName())) + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + onEvent?(.pointerClick(app: currentActiveAppName())) + case .scrollWheel: + let now = Date() + guard now.timeIntervalSince(Self.lastScrollTime) > Timing.scrollGestureGap else { + Self.lastScrollTime = now + return + } + Self.lastScrollTime = now + onEvent?(.scroll(app: currentActiveAppName())) + default: + break + } + } + + private func startScreenTimeTracking() { + activeApp = frontmostAppName + activeAppSince = Date() + + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let activatedAppName = (notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)? + .localizedName + + Task { @MainActor [weak self, activatedAppName] in + guard let self else { + return + } + + self.flushScreenTime() + self.activeApp = activatedAppName ?? self.frontmostAppName + self.activeAppSince = Date() + } + } + + screenTimeTimer = Timer.scheduledTimer(withTimeInterval: Timing.screenTimeFlushInterval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { + return + } + + self.flushScreenTime() + self.activeAppSince = Date() + } + } + } + + private func flushScreenTime() { + guard let app = activeApp, let since = activeAppSince else { + return + } + + let elapsed = Date().timeIntervalSince(since) + if elapsed > Timing.minimumScreenTimeFlush { + onEvent?(.screenTime(app: app, seconds: elapsed)) + } + } + + private func currentActiveAppName() -> String { + activeApp ?? frontmostAppName + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarLog.swift b/Plugins/ActivityBar/Sources/ActivityBarLog.swift new file mode 100644 index 0000000..c0163c9 --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarLog.swift @@ -0,0 +1,15 @@ +import Foundation +import OSLog + +enum ActivityBarLog { + static let input = logger(category: "ActivityBarInput") + static let socket = logger(category: "ActivityBarSocket") + static let hooks = logger(category: "ActivityBarHooks") + + private static func logger(category: String) -> Logger { + Logger( + subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", + category: category + ) + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarModels.swift b/Plugins/ActivityBar/Sources/ActivityBarModels.swift new file mode 100644 index 0000000..3260174 --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarModels.swift @@ -0,0 +1,253 @@ +import Foundation +import MacToolsPluginKit + +enum ActivityBarConstants { + static let pluginID = "activity-bar" + static let defaultSocketPath = "/tmp/mactools-activity-bar.sock" +} + +enum ActivityBarInputEvent: Equatable, Sendable { + case keystroke(app: String) + case pointerClick(app: String) + case scroll(app: String) + case screenTime(app: String, seconds: TimeInterval) +} + +enum ActivityBarInputMonitorStatus: Equatable, Sendable { + case idle + case running + case inputMonitoringDenied +} + +struct ActivityBarAppStats: Codable, Equatable, Sendable { + var keystrokes: Int = 0 + var pointerClicks: Int = 0 + var scrollEvents: Int = 0 + var screenTimeSeconds: TimeInterval = 0 + + var totalInputs: Int { + keystrokes + pointerClicks + scrollEvents + } +} + +struct ActivityBarDailyStats: Codable, Identifiable, Equatable, Sendable { + let date: String + var keystrokes: Int = 0 + var pointerClicks: Int = 0 + var scrollEvents: Int = 0 + var screenTimeSeconds: TimeInterval = 0 + var perApp: [String: ActivityBarAppStats] = [:] + + var id: String { date } + + var totalInputs: Int { + keystrokes + pointerClicks + scrollEvents + } + + var topApps: [(name: String, stats: ActivityBarAppStats)] { + perApp + .filter { !$0.key.isEmpty && $0.key != "loginwindow" } + .map { (name: $0.key, stats: $0.value) } + .sorted { + if $0.stats.screenTimeSeconds == $1.stats.screenTimeSeconds { + return $0.stats.totalInputs > $1.stats.totalInputs + } + return $0.stats.screenTimeSeconds > $1.stats.screenTimeSeconds + } + } +} + +enum ActivityBarHookEventType: String, Codable, Equatable, Sendable { + case userPromptSubmit = "UserPromptSubmit" + case preToolUse = "PreToolUse" + case postToolUse = "PostToolUse" + case stop = "Stop" + case subagentStop = "SubagentStop" + case preCompact = "PreCompact" + case sessionStart = "SessionStart" + case sessionEnd = "SessionEnd" + case permissionRequest = "PermissionRequest" + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = ActivityBarHookEventType(rawValue: rawValue) ?? .unknown + } +} + +enum ActivityBarHookStatus: String, Codable, Equatable, Sendable { + case processing + case compacting + case waitingForInput = "waiting_for_input" + case ended + case runningTool = "running_tool" + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = ActivityBarHookStatus(rawValue: rawValue) ?? .unknown + } +} + +struct ActivityBarHookEvent: Codable, Equatable, Sendable { + let sessionID: String + let cwd: String? + let event: ActivityBarHookEventType + let status: ActivityBarHookStatus + let userPrompt: String? + let tool: String? + let interactive: Bool? + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case cwd + case event + case status + case userPrompt = "user_prompt" + case tool + case interactive + } +} + +struct ActivityBarProjectStats: Codable, Equatable, Sendable { + var durationSeconds: TimeInterval = 0 + var wordCount: Int = 0 + var toolCallCount: Int = 0 +} + +enum ActivityBarCodingTool: String, CaseIterable, Codable, Equatable, Sendable { + case claudeCode = "Claude Code" + case cursor = "Cursor" + case codex = "Codex" + + static func displayName(forSessionID sessionID: String) -> String { + if sessionID.hasPrefix("cursor-") { + return cursor.rawValue + } + + if sessionID.hasPrefix("codex-") { + return codex.rawValue + } + + return claudeCode.rawValue + } +} + +struct ActivityBarCodingDailyStats: Codable, Identifiable, Equatable, Sendable { + let date: String + var durationSeconds: TimeInterval = 0 + var wordCount: Int = 0 + var toolCallCount: Int = 0 + var perProject: [String: ActivityBarProjectStats] = [:] + var perTool: [String: ActivityBarProjectStats] = [:] + + init( + date: String, + durationSeconds: TimeInterval = 0, + wordCount: Int = 0, + toolCallCount: Int = 0, + perProject: [String: ActivityBarProjectStats] = [:], + perTool: [String: ActivityBarProjectStats] = [:] + ) { + self.date = date + self.durationSeconds = durationSeconds + self.wordCount = wordCount + self.toolCallCount = toolCallCount + self.perProject = perProject + self.perTool = perTool + } + + var id: String { date } + + var topProjects: [(name: String, stats: ActivityBarProjectStats)] { + perProject + .map { (name: $0.key, stats: $0.value) } + .sorted { + if $0.stats.durationSeconds == $1.stats.durationSeconds { + return $0.stats.wordCount > $1.stats.wordCount + } + return $0.stats.durationSeconds > $1.stats.durationSeconds + } + } + + var topTools: [(name: String, stats: ActivityBarProjectStats)] { + let knownTools = ActivityBarCodingTool.allCases.map(\.rawValue) + let knownRows = knownTools.compactMap { name -> (name: String, stats: ActivityBarProjectStats)? in + guard let stats = perTool[name], stats.durationSeconds > 0 || stats.wordCount > 0 || stats.toolCallCount > 0 else { + return nil + } + return (name: name, stats: stats) + } + + let customRows = perTool + .filter { !knownTools.contains($0.key) } + .map { (name: $0.key, stats: $0.value) } + .sorted { + if $0.stats.durationSeconds == $1.stats.durationSeconds { + return $0.stats.wordCount > $1.stats.wordCount + } + return $0.stats.durationSeconds > $1.stats.durationSeconds + } + + return knownRows + customRows + } + + enum CodingKeys: String, CodingKey { + case date + case durationSeconds + case wordCount + case toolCallCount + case perProject + case perTool + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(String.self, forKey: .date) + durationSeconds = try container.decodeIfPresent(TimeInterval.self, forKey: .durationSeconds) ?? 0 + wordCount = try container.decodeIfPresent(Int.self, forKey: .wordCount) ?? 0 + toolCallCount = try container.decodeIfPresent(Int.self, forKey: .toolCallCount) ?? 0 + perProject = try container.decodeIfPresent([String: ActivityBarProjectStats].self, forKey: .perProject) ?? [:] + perTool = try container.decodeIfPresent([String: ActivityBarProjectStats].self, forKey: .perTool) ?? [:] + } +} + +enum ActivityBarFormatting { + static func monthDay(_ date: Date, locale: Locale = PluginRuntimeLocalization.locale) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter.string(from: date) + } + + static func decimal(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = PluginRuntimeLocalization.locale + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + static func count(_ value: Int) -> String { + if value >= 10_000 { + return "\(value / 1_000)k" + } + return "\(value)" + } + + static func duration(_ seconds: TimeInterval) -> String { + let total = max(Int(seconds), 0) + let hours = total / 3_600 + let minutes = (total % 3_600) / 60 + let secs = total % 60 + + if hours > 0 { + return "\(hours)h \(minutes)m" + } + if minutes > 0 { + return "\(minutes)m \(secs)s" + } + return "\(secs)s" + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift b/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift new file mode 100644 index 0000000..610f68f --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarPlugin.swift @@ -0,0 +1,337 @@ +import AppKit +import SwiftUI +import MacToolsPluginKit + +public final class ActivityBarPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + ActivityBarPluginProvider(context: context) + } +} + +@MainActor +private struct ActivityBarPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [ + ActivityBarPlugin( + context: context, + localization: PluginLocalization(bundle: context.resourceBundle) + ), + ] + } +} + +@MainActor +final class ActivityBarPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginComponentPanel { + private enum ControlID { + static let trackingEnabled = "tracking-enabled" + static let installHooks = "install-hooks" + static let uninstallHooks = "uninstall-hooks" + static let resetToday = "reset-today" + static let openInputMonitoring = "open-input-monitoring" + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + let descriptor = PluginComponentDescriptor( + span: PluginComponentSpan( + width: 4, + height: PluginComponentPanelLayoutMetrics.default.heightSpan(closestToOriginalSpanHeight: 10) + )! + ) + + private let localization: PluginLocalization + private let controller: ActivityBarController + private var isExpanded = false + + var onStateChange: (() -> Void)? { + didSet { + controller.onStateChange = onStateChange + } + } + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + init( + context: PluginRuntimeContext, + controller: ActivityBarController? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.metadata = PluginMetadata( + id: ActivityBarController.pluginID, + title: localization.string("metadata.title", defaultValue: "活动统计"), + iconName: "chart.bar.xaxis", + iconTint: Color(nsColor: .systemGreen), + order: 18, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "统计输入、前台应用使用时长和 AI 编程活动" + ) + ) + self.controller = controller ?? ActivityBarController(context: context, localization: localization) + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: controller.panelSubtitle, + isOn: controller.isTrackingEnabled, + isExpanded: isExpanded, + isEnabled: true, + isVisible: true, + detail: isExpanded ? panelDetail : nil, + errorMessage: controller.lastErrorMessage + ) + } + + var componentPanelState: PluginComponentState { + PluginComponentState( + subtitle: controller.componentSubtitle, + isActive: controller.isTrackingEnabled || controller.isHookListenerRunning, + isEnabled: true, + isVisible: true, + errorMessage: controller.lastErrorMessage + ) + } + + var settingsSections: [PluginSettingsSection] { + [ + PluginSettingsSection( + id: "input-monitoring", + title: localization.string("settings.inputMonitoring.title", defaultValue: "输入监控"), + description: localization.string( + "settings.inputMonitoring.description", + defaultValue: "用于统计键盘、鼠标点击和滚动事件。" + ), + status: inputMonitoringSettingsStatus, + footnote: controller.inputMonitoringFootnote, + buttonTitle: localization.string("settings.inputMonitoring.button", defaultValue: "打开系统设置"), + actionID: ControlID.openInputMonitoring + ), + PluginSettingsSection( + id: "ai-hooks", + title: localization.string("settings.aiHooks.title", defaultValue: "AI 工具 Hook"), + description: localization.string( + "settings.aiHooks.description", + defaultValue: "记录 Claude Code、Cursor 和 Codex 的提示、工具调用与执行时长。" + ), + status: hookSettingsStatus, + footnote: controller.hookActionFootnote, + buttonTitle: hookActionButtonTitle, + actionID: hookActionControlID + ) + ] + } + + func activate(context: PluginRuntimeContext) { + controller.activate(context: context) + } + + func deactivate(reason: PluginDeactivationReason) { + controller.deactivate(reason: reason) + } + + func refresh() { + controller.refresh() + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setSwitch(isEnabled): + controller.setTrackingEnabled(isEnabled) + case let .setDisclosureExpanded(value): + isExpanded = value + onStateChange?() + case let .invokeAction(controlID): + handleAction(controlID: controlID) + case .setSelection, .setNavigationSelection, + .clearNavigationSelection, .setDate, .setSlider: + return + } + } + + func makeView(context: PluginComponentContext) -> AnyView { + AnyView( + ActivityBarComponentView( + controller: controller, + localization: localization, + dismiss: context.dismiss + ) + ) + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + + func handleSettingsAction(id: String) { + handleAction(controlID: id) + } + + func handleShortcutAction(id: String) {} + + private var panelDetail: PluginPanelDetail { + let trackingControl = PluginPanelControl( + id: ControlID.trackingEnabled, + kind: .switchRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.trackingEnabled", defaultValue: "活动统计"), + actionIconSystemName: "chart.bar.xaxis", + isEnabled: true + ) + + let openSettingsControl = PluginPanelControl( + id: ControlID.openInputMonitoring, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.openInputMonitoring", defaultValue: "打开输入监控设置"), + actionIconSystemName: "keyboard.badge.eye", + isEnabled: true + ) + + let hookActionControl = PluginPanelControl( + id: hookActionControlID, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: hookActionPanelTitle, + actionIconSystemName: controller.areHooksInstalled ? "trash" : "terminal", + isEnabled: true + ) + + let resetControl = PluginPanelControl( + id: ControlID.resetToday, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.resetToday", defaultValue: "清空今日统计"), + actionIconSystemName: "arrow.counterclockwise", + showsLeadingDivider: true, + isEnabled: true + ) + + return PluginPanelDetail( + primaryControls: [trackingControl, openSettingsControl, hookActionControl, resetControl], + secondaryPanel: nil + ) + } + + private var hookActionControlID: String { + controller.areHooksInstalled ? ControlID.uninstallHooks : ControlID.installHooks + } + + private var hookActionButtonTitle: String { + if controller.areHooksInstalled { + return localization.string("settings.aiHooks.uninstallButton", defaultValue: "卸载 Hook") + } + + return localization.string("settings.aiHooks.button", defaultValue: "安装或更新 Hook") + } + + private var hookActionPanelTitle: String { + if controller.areHooksInstalled { + return localization.string("panel.action.uninstallHooks", defaultValue: "卸载 AI Hook") + } + + return localization.string("panel.action.installHooks", defaultValue: "安装或更新 AI Hook") + } + + private var inputMonitoringSettingsStatus: PluginSettingsSection.Status { + switch controller.monitorStatus { + case .running: + return PluginSettingsSection.Status( + text: localization.string("status.running", defaultValue: "运行中"), + systemImage: "checkmark.circle.fill", + tone: .positive + ) + case .inputMonitoringDenied: + return PluginSettingsSection.Status( + text: localization.string("status.permissionRequired", defaultValue: "需要授权"), + systemImage: "exclamationmark.triangle.fill", + tone: .caution + ) + case .idle: + return PluginSettingsSection.Status( + text: localization.string("status.disabled", defaultValue: "未开启"), + systemImage: "pause.circle", + tone: .neutral + ) + } + } + + private var hookSettingsStatus: PluginSettingsSection.Status { + switch controller.hookInstallState { + case .installed: + if controller.isHookListenerRunning { + return PluginSettingsSection.Status( + text: localization.string("hook.status.listening", defaultValue: "监听中"), + systemImage: "checkmark.circle.fill", + tone: .positive + ) + } + + return PluginSettingsSection.Status( + text: localization.string("hook.status.installed", defaultValue: "已安装"), + systemImage: "checkmark.circle.fill", + tone: .positive + ) + case .failed: + return PluginSettingsSection.Status( + text: localization.string("hook.status.installFailed", defaultValue: "安装失败"), + systemImage: "exclamationmark.triangle.fill", + tone: .caution + ) + case .notInstalled: + return PluginSettingsSection.Status( + text: localization.string("hook.status.notInstalled", defaultValue: "未安装"), + systemImage: "terminal", + tone: .neutral + ) + } + } + + private func handleAction(controlID: String) { + switch controlID { + case ControlID.installHooks: + controller.installHooks() + case ControlID.uninstallHooks: + controller.uninstallHooks() + case ControlID.resetToday: + controller.resetToday() + case ControlID.openInputMonitoring: + controller.openInputMonitoringSettings() + default: + break + } + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarSocketServer.swift b/Plugins/ActivityBar/Sources/ActivityBarSocketServer.swift new file mode 100644 index 0000000..96fe7ff --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarSocketServer.swift @@ -0,0 +1,218 @@ +import Darwin +import Foundation +import MacToolsPluginKit + +protocol ActivityBarSocketServing: AnyObject { + var isRunning: Bool { get } + + func start() throws + func stop() +} + +enum ActivityBarSocketError: LocalizedError, Equatable { + case pathTooLong(String) + case socketFailed(Int32) + case bindFailed(Int32) + case listenFailed(Int32) + + var errorDescription: String? { + localizedDescription(localization: PluginLocalization(bundle: .main)) + } + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case let .pathTooLong(path): + return localization.format("error.socket.pathTooLong", defaultValue: "Socket 路径过长:%@", path) + case let .socketFailed(code): + return localization.format("error.socket.createFailed", defaultValue: "创建 Socket 失败:%d", code) + case let .bindFailed(code): + return localization.format("error.socket.bindFailed", defaultValue: "绑定 Socket 失败:%d", code) + case let .listenFailed(code): + return localization.format("error.socket.listenFailed", defaultValue: "监听 Socket 失败:%d", code) + } + } +} + +final class ActivityBarHookSocketServer: ActivityBarSocketServing, @unchecked Sendable { + private let socketPath: String + private let onEvent: @MainActor (ActivityBarHookEvent) -> Void + private let stateLock = NSLock() + private var serverFD: Int32 = -1 + private var running = false + + var isRunning: Bool { + stateLock.withLock { running } + } + + init( + socketPath: String = ActivityBarConstants.defaultSocketPath, + onEvent: @escaping @MainActor (ActivityBarHookEvent) -> Void + ) { + self.socketPath = socketPath + self.onEvent = onEvent + } + + func start() throws { + guard !isRunning else { + return + } + + removeSocketFile() + + let fileDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard fileDescriptor >= 0 else { + throw ActivityBarSocketError.socketFailed(errno) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) + guard pathBytes.count < maxPathLength else { + close(fileDescriptor) + throw ActivityBarSocketError.pathTooLong(socketPath) + } + + withUnsafeMutableBytes(of: &address.sun_path) { buffer in + for index in 0...size)) + } + } + + guard bindResult == 0 else { + let code = errno + close(fileDescriptor) + throw ActivityBarSocketError.bindFailed(code) + } + + guard listen(fileDescriptor, 5) == 0 else { + let code = errno + close(fileDescriptor) + throw ActivityBarSocketError.listenFailed(code) + } + + let flags = fcntl(fileDescriptor, F_GETFL) + _ = fcntl(fileDescriptor, F_SETFL, flags | O_NONBLOCK) + + stateLock.withLock { + serverFD = fileDescriptor + running = true + } + + ActivityBarLog.socket.info("Activity bar socket listening at \(self.socketPath, privacy: .public)") + + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.acceptLoop() + } + } + + func stop() { + let fileDescriptor = stateLock.withLock { () -> Int32 in + running = false + let fd = serverFD + serverFD = -1 + return fd + } + + if fileDescriptor >= 0 { + close(fileDescriptor) + } + + removeSocketFile() + } + + private func acceptLoop() { + while stateLock.withLock({ running }) { + var clientAddress = sockaddr_un() + var clientLength = socklen_t(MemoryLayout.size) + let fd = stateLock.withLock { serverFD } + + guard fd >= 0 else { + return + } + + let clientFD = withUnsafeMutablePointer(to: &clientAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + accept(fd, socketAddress, &clientLength) + } + } + + if clientFD >= 0 { + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.handleClient(clientFD) + } + } else if errno == EAGAIN || errno == EWOULDBLOCK { + Thread.sleep(forTimeInterval: 0.05) + } else if stateLock.withLock({ running }) { + ActivityBarLog.socket.error("Activity bar socket accept failed: \(errno)") + Thread.sleep(forTimeInterval: 0.1) + } + } + } + + private func handleClient(_ fileDescriptor: Int32) { + defer { + close(fileDescriptor) + } + + var timeout = timeval(tv_sec: 2, tv_usec: 0) + setsockopt( + fileDescriptor, + SOL_SOCKET, + SO_RCVTIMEO, + &timeout, + socklen_t(MemoryLayout.size) + ) + + var data = Data() + let bufferSize = 8_192 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { + buffer.deallocate() + } + + while true { + let bytesRead = read(fileDescriptor, buffer, bufferSize) + if bytesRead > 0 { + data.append(buffer, count: bytesRead) + } else { + break + } + } + + guard !data.isEmpty else { + return + } + + do { + let event = try JSONDecoder().decode(ActivityBarHookEvent.self, from: data) + let callback = onEvent + Task { @MainActor in + callback(event) + } + } catch { + ActivityBarLog.socket.error("Activity bar hook decode failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func removeSocketFile() { + unlink(socketPath) + } +} + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { + unlock() + } + + return try body() + } +} diff --git a/Plugins/ActivityBar/Sources/ActivityBarStatsStore.swift b/Plugins/ActivityBar/Sources/ActivityBarStatsStore.swift new file mode 100644 index 0000000..7d445e9 --- /dev/null +++ b/Plugins/ActivityBar/Sources/ActivityBarStatsStore.swift @@ -0,0 +1,217 @@ +import Foundation +import MacToolsPluginKit + +@MainActor +final class ActivityBarStatsStore: ObservableObject { + private enum StorageKey { + static let days = "activity-bar.input.days.v1" + } + + // Direct observers receive every input mutation and bypass controller-level + // UI throttling; keep hot-path UI subscriptions on ActivityBarController. + @Published private(set) var days: [String: ActivityBarDailyStats] + + private let storage: PluginStorage + private let calendar: Calendar + private let dateProvider: () -> Date + private let persistenceDelay: Duration + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private var persistenceTask: Task? + private var hasPendingChanges = false + + private enum Persistence { + static let debounceDelay: Duration = .seconds(30) + static let retainedDayCount = 370 + } + + init( + storage: PluginStorage, + calendar: Calendar = .current, + dateProvider: @escaping () -> Date = Date.init, + persistenceDelay: Duration = Persistence.debounceDelay + ) { + self.storage = storage + self.calendar = calendar + self.dateProvider = dateProvider + self.persistenceDelay = persistenceDelay + self.days = Self.prunedDays(Self.loadDays(storage: storage, decoder: decoder)) + } + + deinit { + persistenceTask?.cancel() + } + + var today: ActivityBarDailyStats { + days[dateKey(for: dateProvider())] ?? ActivityBarDailyStats(date: dateKey(for: dateProvider())) + } + + var sortedDateKeys: [String] { + days.keys.sorted() + } + + func stats(for date: String) -> ActivityBarDailyStats { + days[date] ?? ActivityBarDailyStats(date: date) + } + + func recentDays(count: Int, endingAt endDate: Date? = nil) -> [ActivityBarDailyStats] { + let end = endDate ?? dateProvider() + let boundedCount = max(count, 1) + + return (0.. 0 else { + return + } + + mutateToday(app: app) { day, appStats in + day.screenTimeSeconds += seconds + appStats.screenTimeSeconds += seconds + } + } + + func record(_ event: ActivityBarInputEvent) { + switch event { + case let .keystroke(app): + incrementKeystroke(app: app) + case let .pointerClick(app): + incrementPointerClick(app: app) + case let .scroll(app): + incrementScroll(app: app) + case let .screenTime(app, seconds): + addScreenTime(seconds, app: app) + } + } + + func resetToday() { + days[dateKey(for: dateProvider())] = ActivityBarDailyStats(date: dateKey(for: dateProvider())) + hasPendingChanges = true + persistPendingChanges(force: true) + } + + func flushPendingChanges() { + persistPendingChanges() + } + + private func mutateToday( + app rawApp: String, + update: (inout ActivityBarDailyStats, inout ActivityBarAppStats) -> Void + ) { + let app = sanitizedAppName(rawApp) + let key = dateKey(for: dateProvider()) + var day = days[key] ?? ActivityBarDailyStats(date: key) + var appStats = day.perApp[app] ?? ActivityBarAppStats() + + update(&day, &appStats) + + day.perApp[app] = appStats + days[key] = day + hasPendingChanges = true + schedulePersistence() + } + + private func sanitizedAppName(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Unknown" : trimmed + } + + private func dateKey(for date: Date) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + let year = components.year ?? 0 + let month = components.month ?? 0 + let day = components.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } + + private func schedulePersistence() { + guard persistenceTask == nil else { + return + } + + persistenceTask = Task { @MainActor [weak self] in + guard let self else { + return + } + + do { + try await Task.sleep(for: self.persistenceDelay) + } catch { + return + } + + guard !Task.isCancelled else { + return + } + + self.persistPendingChanges() + } + } + + private func persistPendingChanges(force: Bool = false) { + persistenceTask?.cancel() + persistenceTask = nil + + guard force || hasPendingChanges else { + return + } + + days = Self.prunedDays(days) + + do { + let data = try encoder.encode(days) + storage.set(data, forKey: StorageKey.days) + hasPendingChanges = false + } catch { + ActivityBarLog.input.error("Failed to persist input stats: \(error.localizedDescription, privacy: .public)") + } + } + + private static func loadDays(storage: PluginStorage, decoder: JSONDecoder) -> [String: ActivityBarDailyStats] { + guard let data = storage.data(forKey: StorageKey.days) else { + return [:] + } + + do { + return try decoder.decode([String: ActivityBarDailyStats].self, from: data) + } catch { + ActivityBarLog.input.error("Failed to load input stats: \(error.localizedDescription, privacy: .public)") + return [:] + } + } + + private static func prunedDays(_ days: [String: ActivityBarDailyStats]) -> [String: ActivityBarDailyStats] { + guard days.count > Persistence.retainedDayCount else { + return days + } + + let retainedKeys = Set(days.keys.sorted().suffix(Persistence.retainedDayCount)) + return days.filter { retainedKeys.contains($0.key) } + } +} diff --git a/Plugins/ActivityBar/THIRD_PARTY_NOTICES.txt b/Plugins/ActivityBar/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..e1a6312 --- /dev/null +++ b/Plugins/ActivityBar/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,24 @@ +ActivityBarPlugin adapts portions of SuveenE/activity-bar: +https://github.com/SuveenE/activity-bar + +MIT License + +Copyright (c) 2026 Suveen Ellawela + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plugins/ActivityBar/Tests/ActivityBarCodingSessionStoreTests.swift b/Plugins/ActivityBar/Tests/ActivityBarCodingSessionStoreTests.swift new file mode 100644 index 0000000..0b0ec48 --- /dev/null +++ b/Plugins/ActivityBar/Tests/ActivityBarCodingSessionStoreTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import ActivityBarPlugin + +@MainActor +final class ActivityBarCodingSessionStoreTests: XCTestCase { + func testCodingEventsTrackWordsToolsAndActiveDuration() { + let storage = ActivityBarMemoryStorage() + var now = activityBarTestDate(hour: 10) + let store = ActivityBarCodingSessionStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { now } + ) + + store.handleEvent( + ActivityBarHookEvent( + sessionID: "session-1", + cwd: "/tmp/MacTools", + event: .userPromptSubmit, + status: .processing, + userPrompt: "make the plugin work", + tool: nil, + interactive: true + ) + ) + + now = now.addingTimeInterval(10) + store.handleEvent( + ActivityBarHookEvent( + sessionID: "session-1", + cwd: "/tmp/MacTools", + event: .preToolUse, + status: .runningTool, + userPrompt: nil, + tool: "Read", + interactive: true + ) + ) + + now = now.addingTimeInterval(5) + store.handleEvent( + ActivityBarHookEvent( + sessionID: "session-1", + cwd: "/tmp/MacTools", + event: .stop, + status: .waitingForInput, + userPrompt: nil, + tool: nil, + interactive: true + ) + ) + + XCTAssertEqual(store.today.wordCount, 4) + XCTAssertEqual(store.today.toolCallCount, 1) + XCTAssertEqual(store.today.durationSeconds, 15, accuracy: 0.1) + XCTAssertEqual(store.today.topProjects.first?.name, "MacTools") + XCTAssertEqual(store.activeSessionCount, 1) + } + + func testSessionEndRemovesActiveSession() { + let storage = ActivityBarMemoryStorage() + let store = ActivityBarCodingSessionStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + store.handleEvent( + ActivityBarHookEvent( + sessionID: "session-1", + cwd: "/tmp/MacTools", + event: .sessionStart, + status: .waitingForInput, + userPrompt: nil, + tool: nil, + interactive: true + ) + ) + store.handleEvent( + ActivityBarHookEvent( + sessionID: "session-1", + cwd: "/tmp/MacTools", + event: .sessionEnd, + status: .ended, + userPrompt: nil, + tool: nil, + interactive: true + ) + ) + + XCTAssertEqual(store.activeSessionCount, 0) + } + + func testCodingEventsAggregateByTool() { + let storage = ActivityBarMemoryStorage() + var now = activityBarTestDate(hour: 10) + let store = ActivityBarCodingSessionStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { now } + ) + + store.handleEvent( + ActivityBarHookEvent( + sessionID: "cursor-session-1", + cwd: "/tmp/MacTools", + event: .userPromptSubmit, + status: .processing, + userPrompt: "update the ui", + tool: nil, + interactive: true + ) + ) + + now = now.addingTimeInterval(8) + store.handleEvent( + ActivityBarHookEvent( + sessionID: "cursor-session-1", + cwd: "/tmp/MacTools", + event: .preToolUse, + status: .runningTool, + userPrompt: nil, + tool: "Read", + interactive: true + ) + ) + + guard let cursorStats = store.today.perTool["Cursor"] else { + XCTFail("Expected Cursor stats to be recorded") + return + } + XCTAssertEqual(cursorStats.wordCount, 3) + XCTAssertEqual(cursorStats.toolCallCount, 1) + XCTAssertEqual(cursorStats.durationSeconds, 8, accuracy: 0.1) + XCTAssertEqual(store.today.topTools.first?.name, "Cursor") + } +} diff --git a/Plugins/ActivityBar/Tests/ActivityBarHookInstallerTests.swift b/Plugins/ActivityBar/Tests/ActivityBarHookInstallerTests.swift new file mode 100644 index 0000000..754dcaa --- /dev/null +++ b/Plugins/ActivityBar/Tests/ActivityBarHookInstallerTests.swift @@ -0,0 +1,188 @@ +import XCTest +@testable import ActivityBarPlugin + +final class ActivityBarHookInstallerTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarHookInstallerTests-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + func testInstallWritesScriptsAndToolConfigurations() throws { + let paths = ActivityBarHookInstallerPaths( + homeDirectory: temporaryDirectory, + hookScriptsDirectory: temporaryDirectory.appendingPathComponent("hooks") + ) + let installer = ActivityBarHookInstaller(paths: paths, socketPath: "/tmp/mactools-test.sock") + + let summary = try installer.install() + + XCTAssertEqual(summary.installedTools, ["Claude Code", "Cursor", "Codex"]) + XCTAssertTrue(FileManager.default.fileExists(atPath: paths.hookScriptsDirectory.path)) + + let claudeScript = paths.hookScriptsDirectory.appendingPathComponent("mactools-activity-claude-hook.sh") + let scriptText = try String(contentsOf: claudeScript, encoding: .utf8) + XCTAssertTrue(scriptText.contains("/tmp/mactools-test.sock")) + + let permissions = try FileManager.default.attributesOfItem(atPath: claudeScript.path)[.posixPermissions] as? Int + XCTAssertEqual((permissions ?? 0) & 0o111, 0o111) + + let claudeSettings = try readJSONObject(paths.claudeSettingsPath) + let claudeHooks = try XCTUnwrap(claudeSettings["hooks"] as? [String: Any]) + XCTAssertNotNil(claudeHooks["UserPromptSubmit"]) + + let cursorSettings = try readJSONObject(paths.cursorHooksPath) + let cursorHooks = try XCTUnwrap(cursorSettings["hooks"] as? [String: Any]) + XCTAssertNotNil(cursorHooks["beforeSubmitPrompt"]) + + let codexConfig = try String(contentsOf: paths.codexConfigPath, encoding: .utf8) + XCTAssertTrue(codexConfig.contains("codex_hooks = true")) + + let codexSettings = try readJSONObject(paths.codexHooksPath) + let codexHooks = try XCTUnwrap(codexSettings["hooks"] as? [String: Any]) + XCTAssertNotNil(codexHooks["PreToolUse"]) + } + + func testInstallIsIdempotentForClaudeHooks() throws { + let paths = ActivityBarHookInstallerPaths( + homeDirectory: temporaryDirectory, + hookScriptsDirectory: temporaryDirectory.appendingPathComponent("hooks") + ) + let installer = ActivityBarHookInstaller(paths: paths, socketPath: "/tmp/mactools-test.sock") + + _ = try installer.install() + let first = try Data(contentsOf: paths.claudeSettingsPath) + _ = try installer.install() + let second = try Data(contentsOf: paths.claudeSettingsPath) + + XCTAssertEqual(first, second) + } + + func testUninstallRemovesOnlyActivityBarHooks() throws { + let paths = ActivityBarHookInstallerPaths( + homeDirectory: temporaryDirectory, + hookScriptsDirectory: temporaryDirectory.appendingPathComponent("hooks") + ) + try writeJSONObject( + [ + "hooks": [ + "UserPromptSubmit": [ + [ + "matcher": "", + "hooks": [ + [ + "type": "command", + "command": "/usr/bin/true", + ] + ], + ] + ], + ], + ], + to: paths.claudeSettingsPath + ) + try writeJSONObject( + [ + "version": 1, + "hooks": [ + "beforeSubmitPrompt": [ + [ + "command": "/usr/bin/true", + ] + ], + ], + ], + to: paths.cursorHooksPath + ) + try writeJSONObject( + [ + "hooks": [ + "PreToolUse": [ + [ + "matcher": "", + "hooks": [ + [ + "type": "command", + "command": "/usr/bin/true", + ] + ], + ] + ], + ], + ], + to: paths.codexHooksPath + ) + + let installer = ActivityBarHookInstaller(paths: paths, socketPath: "/tmp/mactools-test.sock") + + _ = try installer.install() + let summary = try installer.uninstall() + + XCTAssertEqual(summary.removedTools, ["Claude Code", "Cursor", "Codex"]) + XCTAssertFalse(FileManager.default.fileExists(atPath: paths.hookScriptsDirectory.path)) + + let claudeObject = try readJSONObject(paths.claudeSettingsPath) + let cursorObject = try readJSONObject(paths.cursorHooksPath) + let codexObject = try readJSONObject(paths.codexHooksPath) + let claudeSettings = try serializedJSONObject(claudeObject) + let cursorSettings = try serializedJSONObject(cursorObject) + let codexSettings = try serializedJSONObject(codexObject) + + XCTAssertFalse(claudeSettings.contains("mactools-activity")) + XCTAssertFalse(cursorSettings.contains("mactools-activity")) + XCTAssertFalse(codexSettings.contains("mactools-activity")) + XCTAssertTrue(containsString("/usr/bin/true", in: claudeObject)) + XCTAssertTrue(containsString("/usr/bin/true", in: cursorObject)) + XCTAssertTrue(containsString("/usr/bin/true", in: codexObject)) + } + + private func readJSONObject(_ url: URL) throws -> [String: Any] { + let data = try Data(contentsOf: url) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func serializedJSONObject(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys] + ) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + + private func writeJSONObject(_ object: [String: Any], to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys] + ) + try data.write(to: url, options: .atomic) + } + + private func containsString(_ expected: String, in object: Any) -> Bool { + if let value = object as? String { + return value == expected + } + + if let values = object as? [Any] { + return values.contains { containsString(expected, in: $0) } + } + + if let values = object as? [String: Any] { + return values.values.contains { containsString(expected, in: $0) } + } + + return false + } +} diff --git a/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift b/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift new file mode 100644 index 0000000..a00b1f7 --- /dev/null +++ b/Plugins/ActivityBar/Tests/ActivityBarPluginTests.swift @@ -0,0 +1,395 @@ +import XCTest +import MacToolsPluginKit +@testable import ActivityBarPlugin + +@MainActor +final class ActivityBarPluginTests: XCTestCase { + func testMonthDayFormattingUsesLocaleSpecificFieldOrder() { + let date = Calendar(identifier: .gregorian).date( + from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 1, + day: 2, + hour: 12 + ) + )! + let locale = Locale(identifier: "de_DE") + let expectedFormatter = DateFormatter() + expectedFormatter.locale = locale + expectedFormatter.setLocalizedDateFormatFromTemplate("MMM d") + + XCTAssertEqual( + ActivityBarFormatting.monthDay(date, locale: locale), + expectedFormatter.string(from: date) + ) + } + + func testMetadataAndPanelsAreExposed() { + let harness = makeHarness() + + XCTAssertEqual(harness.plugin.metadata.id, "activity-bar") + XCTAssertEqual(harness.plugin.metadata.title, "活动统计") + XCTAssertEqual(harness.plugin.primaryPanelDescriptor.controlStyle, .disclosure) + XCTAssertEqual(harness.plugin.descriptor.span, PluginComponentSpan(width: 4, height: 127)!) + } + + func testVisibleCodingToolsIncludeCursor() { + XCTAssertEqual(ActivityBarComponentView.visibleCodingTools, [.claudeCode, .cursor, .codex]) + } + + func testPrimaryPanelStartsCollapsed() { + let harness = makeHarness() + + XCTAssertFalse(harness.plugin.primaryPanelState.isExpanded) + XCTAssertNil(harness.plugin.primaryPanelState.detail) + } + + func testPrimaryPanelExpandsWithTrackingSwitchAndActions() throws { + let harness = makeHarness() + + harness.plugin.handleAction(.setDisclosureExpanded(true)) + + let state = harness.plugin.primaryPanelState + let controls = try XCTUnwrap(state.detail?.primaryControls) + + XCTAssertTrue(state.isExpanded) + XCTAssertEqual(controls.map(\.id), [ + "tracking-enabled", + "open-input-monitoring", + "install-hooks", + "reset-today" + ]) + XCTAssertEqual(controls.first?.kind, .switchRow) + XCTAssertFalse(state.isOn) + } + + func testHookSettingUsesSingleDynamicAction() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarPluginTests-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + } + let paths = ActivityBarHookInstallerPaths( + homeDirectory: root, + hookScriptsDirectory: root.appendingPathComponent("hooks") + ) + let harness = makeHarness(hookInstallerPaths: paths) + + var hookSections = harness.plugin.settingsSections.filter { $0.id.hasPrefix("ai-hooks") } + + XCTAssertEqual(hookSections.count, 1) + XCTAssertEqual(hookSections.first?.id, "ai-hooks") + XCTAssertEqual(hookSections.first?.buttonTitle, "安装或更新 Hook") + XCTAssertEqual(hookSections.first?.actionID, "install-hooks") + + harness.controller.installHooks() + hookSections = harness.plugin.settingsSections.filter { $0.id.hasPrefix("ai-hooks") } + + XCTAssertEqual(hookSections.count, 1) + XCTAssertEqual(hookSections.first?.id, "ai-hooks") + XCTAssertEqual(hookSections.first?.buttonTitle, "卸载 Hook") + XCTAssertEqual(hookSections.first?.actionID, "uninstall-hooks") + } + + func testPrimaryPanelShowsUninstallActionAfterHooksInstalled() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarPluginTests-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + } + let paths = ActivityBarHookInstallerPaths( + homeDirectory: root, + hookScriptsDirectory: root.appendingPathComponent("hooks") + ) + let harness = makeHarness(hookInstallerPaths: paths) + + harness.controller.installHooks() + harness.plugin.handleAction(.setDisclosureExpanded(true)) + + let controls = try XCTUnwrap(harness.plugin.primaryPanelState.detail?.primaryControls) + + XCTAssertEqual(controls.map(\.id), [ + "tracking-enabled", + "open-input-monitoring", + "uninstall-hooks", + "reset-today" + ]) + } + + func testSwitchStartsAndStopsRuntime() { + let harness = makeHarness() + + harness.plugin.handleAction(.setSwitch(true)) + + XCTAssertTrue(harness.controller.isTrackingEnabled) + XCTAssertEqual(harness.inputMonitor.startCallCount, 1) + XCTAssertEqual(harness.socketServer.startCallCount, 0) + XCTAssertTrue(harness.plugin.primaryPanelState.isOn) + + harness.plugin.handleAction(.setSwitch(false)) + + XCTAssertFalse(harness.controller.isTrackingEnabled) + XCTAssertEqual(harness.inputMonitor.stopCallCount, 1) + XCTAssertEqual(harness.socketServer.stopCallCount, 0) + } + + func testActivateStartsInstalledHookSocketWithoutInputTracking() { + let storage = ActivityBarMemoryStorage() + storage.set("2026-05-18 09:00", forKey: "activity-bar.hooks.installed-at") + let harness = makeHarness(storage: storage) + + harness.plugin.activate(context: harness.context) + + XCTAssertFalse(harness.controller.isTrackingEnabled) + XCTAssertEqual(harness.inputMonitor.startCallCount, 0) + XCTAssertEqual(harness.socketServer.startCallCount, 1) + XCTAssertTrue(harness.socketServer.isRunning) + XCTAssertTrue(harness.plugin.componentPanelState.isActive) + XCTAssertEqual(harness.plugin.componentPanelState.subtitle, "AI 监听中") + } + + func testInstallHooksStartsSocketWithoutTrackingSwitch() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarPluginTests-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + } + let paths = ActivityBarHookInstallerPaths( + homeDirectory: root, + hookScriptsDirectory: root.appendingPathComponent("hooks") + ) + let harness = makeHarness(hookInstallerPaths: paths) + + harness.controller.installHooks() + + XCTAssertFalse(harness.controller.isTrackingEnabled) + XCTAssertEqual(harness.inputMonitor.startCallCount, 0) + XCTAssertEqual(harness.socketServer.startCallCount, 1) + XCTAssertTrue(harness.socketServer.isRunning) + } + + func testDisablingTrackingKeepsInstalledHookSocketRunning() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarPluginTests-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + } + let paths = ActivityBarHookInstallerPaths( + homeDirectory: root, + hookScriptsDirectory: root.appendingPathComponent("hooks") + ) + let harness = makeHarness(hookInstallerPaths: paths) + + harness.controller.installHooks() + harness.plugin.handleAction(.setSwitch(true)) + harness.plugin.handleAction(.setSwitch(false)) + + XCTAssertFalse(harness.controller.isTrackingEnabled) + XCTAssertEqual(harness.inputMonitor.stopCallCount, 1) + XCTAssertEqual(harness.socketServer.startCallCount, 1) + XCTAssertEqual(harness.socketServer.stopCallCount, 0) + XCTAssertTrue(harness.socketServer.isRunning) + } + + func testUninstallHooksStopsSocketAndClearsInstalledState() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarPluginTests-\(UUID().uuidString)") + defer { + try? FileManager.default.removeItem(at: root) + } + let paths = ActivityBarHookInstallerPaths( + homeDirectory: root, + hookScriptsDirectory: root.appendingPathComponent("hooks") + ) + let harness = makeHarness(hookInstallerPaths: paths) + + harness.controller.installHooks() + harness.controller.uninstallHooks() + + XCTAssertNil(harness.storage.string(forKey: "activity-bar.hooks.installed-at")) + XCTAssertEqual(harness.controller.hookInstallState, .notInstalled) + XCTAssertEqual(harness.socketServer.stopCallCount, 1) + XCTAssertFalse(harness.socketServer.isRunning) + XCTAssertFalse(harness.plugin.componentPanelState.isActive) + } + + func testDeactivateForUpdatingStopsHookSocket() { + let storage = ActivityBarMemoryStorage() + storage.set("2026-05-18 09:00", forKey: "activity-bar.hooks.installed-at") + let harness = makeHarness(storage: storage) + + harness.plugin.activate(context: harness.context) + harness.plugin.deactivate(reason: .updating) + + XCTAssertEqual(harness.socketServer.startCallCount, 1) + XCTAssertEqual(harness.socketServer.stopCallCount, 1) + XCTAssertFalse(harness.socketServer.isRunning) + } + + func testInputTrackingDoesNotHideHookSocketStartError() { + let storage = ActivityBarMemoryStorage() + storage.set("2026-05-18 09:00", forKey: "activity-bar.hooks.installed-at") + storage.set(true, forKey: "activity-bar.tracking.enabled") + let harness = makeHarness(storage: storage) + harness.socketServer.startError = ActivityBarSocketError.socketFailed(1) + + harness.plugin.activate(context: harness.context) + + XCTAssertEqual(harness.inputMonitor.startCallCount, 1) + XCTAssertEqual(harness.socketServer.startCallCount, 1) + XCTAssertTrue(harness.controller.lastErrorMessage?.contains("AI 活动监听启动失败") == true) + } + + func testExpandedTrackingSwitchReflectsEnabledState() throws { + let harness = makeHarness() + + harness.plugin.handleAction(.setDisclosureExpanded(true)) + harness.plugin.handleAction(.setSwitch(true)) + + let controls = try XCTUnwrap(harness.plugin.primaryPanelState.detail?.primaryControls) + + XCTAssertEqual(controls.first?.kind, .switchRow) + XCTAssertTrue(harness.plugin.primaryPanelState.isOn) + } + + func testMonitorEventsUpdateComponentSubtitle() { + let harness = makeHarness() + + harness.plugin.handleAction(.setSwitch(true)) + harness.inputMonitor.emit(.keystroke(app: "Terminal")) + harness.inputMonitor.emit(.pointerClick(app: "Terminal")) + + XCTAssertEqual(harness.controller.todayInputStats.totalInputs, 2) + XCTAssertEqual(harness.plugin.componentPanelState.subtitle, "2 次输入") + } + + func testMonitorEventsBatchPluginStateNotifications() { + let harness = makeHarness(inputEventNotificationDelay: .seconds(60)) + var notificationCount = 0 + harness.plugin.onStateChange = { + notificationCount += 1 + } + + harness.plugin.handleAction(.setSwitch(true)) + notificationCount = 0 + + harness.inputMonitor.emit(.keystroke(app: "Terminal")) + harness.inputMonitor.emit(.pointerClick(app: "Terminal")) + harness.inputMonitor.emit(.scroll(app: "Terminal")) + + XCTAssertEqual(harness.controller.todayInputStats.totalInputs, 3) + XCTAssertEqual(notificationCount, 0) + + harness.plugin.handleAction(.setSwitch(false)) + + XCTAssertEqual(notificationCount, 1) + XCTAssertEqual(harness.storage.setCallCount(forKey: "activity-bar.input.days.v1"), 1) + } + + func testAppTerminationFlushesPendingInputStats() { + let harness = makeHarness() + + harness.inputMonitor.emit(.keystroke(app: "Terminal")) + NotificationCenter.default.post( + name: NSApplication.willTerminateNotification, + object: nil + ) + + let reloaded = ActivityBarStatsStore(storage: harness.storage) + + XCTAssertEqual(reloaded.today.totalInputs, 1) + } + + func testAppTerminationFlushesActiveCodingDuration() { + let storage = ActivityBarMemoryStorage() + var now = activityBarTestDate(hour: 10) + let codingStats = ActivityBarCodingSessionStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { now } + ) + let harness = makeHarness(storage: storage, codingStats: codingStats) + + harness.controller.codingStats.handleEvent( + ActivityBarHookEvent( + sessionID: "session-1", + cwd: "/tmp/MacTools", + event: .sessionStart, + status: .processing, + userPrompt: nil, + tool: nil, + interactive: true + ) + ) + + now = now.addingTimeInterval(10) + NotificationCenter.default.post( + name: NSApplication.willTerminateNotification, + object: nil + ) + + let reloaded = ActivityBarCodingSessionStore( + storage: harness.storage, + calendar: activityBarTestCalendar(), + dateProvider: { now } + ) + + XCTAssertEqual(reloaded.today.durationSeconds, 10, accuracy: 0.1) + } + + func testResetActionClearsToday() { + let harness = makeHarness() + + harness.inputMonitor.emit(.keystroke(app: "Terminal")) + XCTAssertEqual(harness.controller.todayInputStats.totalInputs, 1) + + harness.plugin.handleAction(.invokeAction(controlID: "reset-today")) + + XCTAssertEqual(harness.controller.todayInputStats.totalInputs, 0) + } + + private func makeHarness( + storage providedStorage: ActivityBarMemoryStorage? = nil, + codingStats: ActivityBarCodingSessionStore? = nil, + hookInstallerPaths: ActivityBarHookInstallerPaths? = nil, + inputEventNotificationDelay: Duration = .milliseconds(750) + ) -> Harness { + let storage = providedStorage ?? ActivityBarMemoryStorage() + let inputMonitor = ActivityBarFakeInputMonitor() + let socketServer = ActivityBarFakeSocketServer() + let context = PluginRuntimeContext( + pluginID: ActivityBarConstants.pluginID, + storage: storage, + supportDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent("ActivityBarPluginTests-\(UUID().uuidString)") + ) + let controller = ActivityBarController( + context: context, + inputMonitor: inputMonitor, + socketServer: socketServer, + codingStats: codingStats, + hookInstallerPaths: hookInstallerPaths, + inputEventNotificationDelay: inputEventNotificationDelay + ) + let plugin = ActivityBarPlugin(context: context, controller: controller) + + return Harness( + plugin: plugin, + controller: controller, + context: context, + storage: storage, + inputMonitor: inputMonitor, + socketServer: socketServer + ) + } + + private struct Harness { + let plugin: ActivityBarPlugin + let controller: ActivityBarController + let context: PluginRuntimeContext + let storage: ActivityBarMemoryStorage + let inputMonitor: ActivityBarFakeInputMonitor + let socketServer: ActivityBarFakeSocketServer + } +} diff --git a/Plugins/ActivityBar/Tests/ActivityBarStatsStoreTests.swift b/Plugins/ActivityBar/Tests/ActivityBarStatsStoreTests.swift new file mode 100644 index 0000000..2bdce57 --- /dev/null +++ b/Plugins/ActivityBar/Tests/ActivityBarStatsStoreTests.swift @@ -0,0 +1,141 @@ +import XCTest +@testable import ActivityBarPlugin + +@MainActor +final class ActivityBarStatsStoreTests: XCTestCase { + private let inputDaysStorageKey = "activity-bar.input.days.v1" + + func testInputStatsAggregateByDayAndApp() { + let storage = ActivityBarMemoryStorage() + let store = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + store.incrementKeystroke(app: "Terminal") + store.incrementPointerClick(app: "Terminal") + store.incrementScroll(app: "Safari") + store.addScreenTime(65, app: "Terminal") + + XCTAssertEqual(store.today.date, "2026-05-18") + XCTAssertEqual(store.today.keystrokes, 1) + XCTAssertEqual(store.today.pointerClicks, 1) + XCTAssertEqual(store.today.scrollEvents, 1) + XCTAssertEqual(store.today.totalInputs, 3) + XCTAssertEqual(store.today.perApp["Terminal"]?.screenTimeSeconds, 65) + XCTAssertEqual(store.today.topApps.first?.name, "Terminal") + } + + func testInputStatsPersistThroughStorage() { + let storage = ActivityBarMemoryStorage() + + let store = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + store.incrementKeystroke(app: "Xcode") + store.flushPendingChanges() + + let reloaded = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + XCTAssertEqual(reloaded.today.keystrokes, 1) + XCTAssertEqual(reloaded.today.perApp["Xcode"]?.keystrokes, 1) + } + + func testInputStatsBatchPersistenceUntilFlush() { + let storage = ActivityBarMemoryStorage() + let store = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() }, + persistenceDelay: .seconds(60) + ) + + store.incrementKeystroke(app: "Terminal") + store.incrementPointerClick(app: "Terminal") + store.incrementScroll(app: "Safari") + + XCTAssertEqual(store.today.totalInputs, 3) + XCTAssertEqual(storage.setCallCount(forKey: "activity-bar.input.days.v1"), 0) + + store.flushPendingChanges() + + XCTAssertEqual(storage.setCallCount(forKey: "activity-bar.input.days.v1"), 1) + + let reloaded = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + XCTAssertEqual(reloaded.today.totalInputs, 3) + } + + func testResetTodayKeepsCurrentDateButClearsCounters() { + let storage = ActivityBarMemoryStorage() + let store = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + store.incrementKeystroke(app: "Terminal") + store.resetToday() + + XCTAssertEqual(store.today.date, "2026-05-18") + XCTAssertEqual(store.today.totalInputs, 0) + XCTAssertTrue(store.today.perApp.isEmpty) + } + + func testResetTodayPersistsClearedCountersAfterPreviousFlush() { + let storage = ActivityBarMemoryStorage() + let store = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() }, + persistenceDelay: .seconds(60) + ) + + store.incrementKeystroke(app: "Terminal") + store.flushPendingChanges() + store.resetToday() + + let reloaded = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + XCTAssertEqual(reloaded.today.date, "2026-05-18") + XCTAssertEqual(reloaded.today.totalInputs, 0) + XCTAssertTrue(reloaded.today.perApp.isEmpty) + } + + func testFlushPendingChangesOnlyWritesWhenStatsAreDirty() { + let storage = ActivityBarMemoryStorage() + let store = ActivityBarStatsStore( + storage: storage, + calendar: activityBarTestCalendar(), + dateProvider: { activityBarTestDate() } + ) + + store.flushPendingChanges() + + XCTAssertEqual(storage.setCallCount(forKey: inputDaysStorageKey), 0) + + store.incrementKeystroke(app: "Terminal") + store.flushPendingChanges() + + XCTAssertEqual(storage.setCallCount(forKey: inputDaysStorageKey), 1) + + store.flushPendingChanges() + + XCTAssertEqual(storage.setCallCount(forKey: inputDaysStorageKey), 1) + } +} diff --git a/Plugins/ActivityBar/Tests/ActivityBarTestSupport.swift b/Plugins/ActivityBar/Tests/ActivityBarTestSupport.swift new file mode 100644 index 0000000..7879931 --- /dev/null +++ b/Plugins/ActivityBar/Tests/ActivityBarTestSupport.swift @@ -0,0 +1,130 @@ +import Foundation +import MacToolsPluginKit +@testable import ActivityBarPlugin + +@MainActor +final class ActivityBarMemoryStorage: PluginStorage { + private var values: [String: Any] = [:] + private var setCounts: [String: Int] = [:] + + 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) { + setCounts[key, default: 0] += 1 + values[key] = value + } + + func setCallCount(forKey key: String) -> Int { + setCounts[key] ?? 0 + } + + func removeObject(forKey key: String) { + values.removeValue(forKey: key) + } + + func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) { + guard values[key] == nil, let value = values[legacyKey] else { + return + } + + values[key] = value + values.removeValue(forKey: legacyKey) + } +} + +@MainActor +final class ActivityBarFakeInputMonitor: ActivityBarInputMonitoring { + var status: ActivityBarInputMonitorStatus = .idle + var onEvent: ((ActivityBarInputEvent) -> Void)? + private(set) var startCallCount = 0 + private(set) var stopCallCount = 0 + + func start() { + startCallCount += 1 + status = .running + } + + func stop() { + stopCallCount += 1 + status = .idle + } + + func emit(_ event: ActivityBarInputEvent) { + onEvent?(event) + } +} + +final class ActivityBarFakeSocketServer: ActivityBarSocketServing { + var isRunning = false + var startError: Error? + private(set) var startCallCount = 0 + private(set) var stopCallCount = 0 + + func start() throws { + guard !isRunning else { + return + } + + startCallCount += 1 + if let startError { + throw startError + } + isRunning = true + } + + func stop() { + stopCallCount += 1 + isRunning = false + } +} + +func activityBarTestDate( + year: Int = 2026, + month: Int = 5, + day: Int = 18, + hour: Int = 9, + minute: Int = 0, + second: Int = 0 +) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar.date( + from: DateComponents( + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + ) + )! +} + +func activityBarTestCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar +} diff --git a/Plugins/ActivityBar/plugin.json b/Plugins/ActivityBar/plugin.json new file mode 100644 index 0000000..94898c4 --- /dev/null +++ b/Plugins/ActivityBar/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "activity-bar", + "displayName": "活动统计", + "summary": "统计输入、前台应用使用时长和 AI 编程活动", + "localizedMetadata": { + "ar": { + "displayName": "إحصاءات النشاط", + "summary": "تتبع الإدخال ووقت استخدام التطبيق الأمامي ونشاط ترميز الذكاء الاصطناعي." + }, + "de": { + "displayName": "Aktivitätsstatistik", + "summary": "Verfolgen Sie Eingaben, Nutzungsdauer der Vordergrund-App und KI-Codierungsaktivitäten." + }, + "en": { + "displayName": "Activity Stats", + "summary": "Track input, foreground app usage time, and AI coding activity." + }, + "es": { + "displayName": "Estadísticas de actividad", + "summary": "Seguimiento de la entrada, el tiempo de uso de la aplicación en primer plano y la actividad de codificación de IA." + }, + "fr": { + "displayName": "Statistiques d’activité", + "summary": "Suivez les entrées, la durée d'utilisation de l'application au premier plan et l'activité de codage de l'IA." + }, + "ja": { + "displayName": "アクティビティ統計", + "summary": "入力、フォアグラウンド アプリの使用時間、AI コーディング アクティビティを追跡します。" + }, + "ko": { + "displayName": "활동 통계", + "summary": "입력, 포그라운드 앱 사용 시간 및 AI 코딩 활동을 추적합니다." + }, + "pt": { + "displayName": "Estatísticas de atividade", + "summary": "Rastreie a entrada, o tempo de uso do aplicativo em primeiro plano e a atividade de codificação de IA." + }, + "ru": { + "displayName": "Статистика активности", + "summary": "Отслеживайте ввод данных, время использования приложений на переднем плане и активность ИИ-кодирования." + }, + "zh-Hans": { + "displayName": "活动统计", + "summary": "统计输入、前台应用使用时长和 AI 编程活动" + }, + "zh-Hant": { + "displayName": "活動統計", + "summary": "統計輸入、前臺應用使用時長和 AI 編程活動" + } + }, + "version": "1.0.15", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "ActivityBar.bundle", + "factoryClass": "ActivityBarPlugin.ActivityBarPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "ActivityBarPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": true, + "configuration": false + }, + "permissions": [], + "category": "monitoring" +} diff --git a/Plugins/ActivityBar/project.yml b/Plugins/ActivityBar/project.yml new file mode 100644 index 0000000..bb51d61 --- /dev/null +++ b/Plugins/ActivityBar/project.yml @@ -0,0 +1,3 @@ +settings: + base: + OTHER_LDFLAGS: -framework ApplicationServices -framework Charts diff --git a/Plugins/AppHotkey/Bundle/AppHotkeyPluginBundleEntrypoint.swift b/Plugins/AppHotkey/Bundle/AppHotkeyPluginBundleEntrypoint.swift new file mode 100644 index 0000000..63fc5ec --- /dev/null +++ b/Plugins/AppHotkey/Bundle/AppHotkeyPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import AppHotkeyPlugin + +private let appHotkeyPluginFactoryAnchor: Any.Type = AppHotkeyPluginFactory.self diff --git a/Plugins/AppHotkey/Resources/Localizable.xcstrings b/Plugins/AppHotkey/Resources/Localizable.xcstrings new file mode 100644 index 0000000..5742eb4 --- /dev/null +++ b/Plugins/AppHotkey/Resources/Localizable.xcstrings @@ -0,0 +1,1142 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مفاتيح التشغيل السريع للتطبيقات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Hotkeys" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App Hotkeys" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Teclas de acceso rápido a aplicaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Touches de raccourci d'application" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリのホットキー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 단축키" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Teclas de atalho do aplicativo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Горячие клавиши приложения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用快速鍵" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اربط مفاتيح التشغيل السريع العامة بالتطبيقات المستخدمة بشكل متكرر." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Globale Hotkeys an häufig verwendete Apps binden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Bind global hotkeys to frequently used apps." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vincular teclas de acceso rápido globales a aplicaciones de uso frecuente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Associez les raccourcis clavier globaux aux applications fréquemment utilisées." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル ホットキーを頻繁に使用するアプリにバインドします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자주 사용하는 앱에 전역 단축키를 바인딩합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Vincule teclas de atalho globais a aplicativos usados com frequência." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Привяжите глобальные горячие клавиши к часто используемым приложениям." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为常用应用绑定全局快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為常用應用綁定全局快速鍵" + } + } + } + }, + "panel.subtitle.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد روابط بعد. قم بتكوينها في الإعدادات." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Bindungen. Konfigurieren Sie sie in den Einstellungen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No bindings yet. Configure them in Settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay enlaces. Configúrelos en Configuración." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune liaison pour l'instant. Configurez-les dans Paramètres." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだバインディングがありません。 [設定] で設定します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 바인딩이 없습니다. 설정에서 구성하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma ligação ainda. Configure-os em Configurações." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Привязок пока нет. Настройте их в настройках." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无绑定,前往设置配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無綁定,前往設定配置" + } + } + } + }, + "panel.subtitle.enabledCountFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تمكين مفاتيح التشغيل السريع %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Hotkeys aktiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d hotkeys enabled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d teclas de acceso rápido habilitadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Touches de raccourci %d activées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d ホットキーが有効になりました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 단축키 활성화됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d teclas de atalho ativadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Горячие клавиши %d включены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个快捷键已启用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個快速鍵已啟用" + } + } + } + }, + "panel.subtitle.paused": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إيقاف مفاتيح التشغيل السريع مؤقتًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hotkeys pausiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hotkeys paused" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Teclas de acceso rápido pausadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Raccourcis clavier suspendus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホットキーが一時停止されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축키가 일시중지되었습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Teclas de atalho pausadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Горячие клавиши приостановлены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快捷键已暂停" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快速鍵已暫停" + } + } + } + }, + "settings.section.bindings": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ربط التطبيقات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Bindungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App Bindings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vínculos de aplicaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liaisons d'application" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリのバインディング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 바인딩" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Vinculações de aplicativos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Привязки приложения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用绑定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用綁定" + } + } + } + }, + "settings.add": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + } + } + }, + "settings.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقر فوق \"إضافة\" لاختيار تطبيق وتعيين مفتاح التشغيل السريع." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicken Sie auf „Hinzufügen“, um eine App auszuwählen und einen Hotkey zuzuweisen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Click Add to choose an app and assign a hotkey." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Haga clic en Agregar para elegir una aplicación y asignar una tecla de acceso rápido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez sur Ajouter pour choisir une application et attribuer un raccourci clavier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[追加] をクリックしてアプリを選択し、ホットキーを割り当てます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱을 선택하고 단축키를 할당하려면 추가를 클릭하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Clique em Adicionar para escolher um aplicativo e atribuir uma tecla de atalho." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите «Добавить», чтобы выбрать приложение и назначить горячую клавишу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击「添加」选择应用并绑定快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊「添加」選擇應用並綁定快速鍵" + } + } + } + }, + "settings.shortcutConflictFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتعارض مع \"%@\"" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konflikte mit „%@“" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Conflicts with \"%@\"" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conflictos con \"%@\"" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conflits avec \"%@\"" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「%@」と競合します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "\"%@\"과의 충돌" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conflitos com \"%@\"" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Конфликты с «%@»" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "与「%@」冲突" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "與「%@」衝突" + } + } + } + }, + "openPanel.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر التطبيق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App auswählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose App" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir aplicación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir une application" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリを選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 선택" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha o aplicativo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать приложение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇應用" + } + } + } + }, + "openPanel.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر التطبيق لربطه بمفتاح التشغيل السريع." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie die App aus, die an einen Hotkey gebunden werden soll." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose the app to bind to a hotkey." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija la aplicación para vincularla a una tecla de acceso rápido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez l'application à associer à un raccourci clavier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホットキーにバインドするアプリを選択します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축키에 바인딩할 앱을 선택하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha o aplicativo para vincular a uma tecla de atalho." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите приложение, которое нужно привязать к горячей клавише." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择要绑定快捷键的应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇要綁定快速鍵的應用" + } + } + } + }, + "settings.shortcutUnset": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير محدد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht festgelegt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not set" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No establecido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non défini" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não definido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не установлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + } + } + }, + "settings.pathUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسار التطبيق غير متاح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App-Pfad nicht verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App path unavailable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ruta de la aplicación no disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chemin de l'application indisponible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリのパスが利用できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 경로를 사용할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Caminho do aplicativo indisponível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Путь к приложению недоступен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用路径不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用路徑不可用" + } + } + } + }, + "settings.shortcutRecorderTitleFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ مفتاح التشغيل السريع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Hotkey" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ Hotkey" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ Tecla de acceso rápido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ Raccourci clavier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ ホットキー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 단축키" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ Tecla de atalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ Горячая клавиша" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 快速鍵" + } + } + } + }, + "settings.clearShortcut": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح مفتاح التشغيل السريع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hotkey löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear hotkey" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar tecla de acceso rápido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer le raccourci clavier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホットキーをクリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단축키 지우기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar tecla de atalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить горячую клавишу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除快速鍵" + } + } + } + }, + "settings.deleteBinding": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "احذف هذا الربط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Bindung löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this binding" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar este enlace" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer cette liaison" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このバインディングを削除します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 바인딩 삭제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluir esta vinculação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить эту привязку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除此绑定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除此綁定" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/AppHotkey/Sources/AppHotkeyManager.swift b/Plugins/AppHotkey/Sources/AppHotkeyManager.swift new file mode 100644 index 0000000..0792b83 --- /dev/null +++ b/Plugins/AppHotkey/Sources/AppHotkeyManager.swift @@ -0,0 +1,139 @@ +import Carbon +import Foundation +import MacToolsPluginKit + +/// Manages Carbon Event hotkey registration alongside `GlobalShortcutManager`. +/// Uses a dedicated OSType signature ("AHKY") to avoid Carbon ID collisions. +@MainActor +final class AppHotkeyManager { + private struct RegisteredHotKey { + let entryID: UUID + let binding: ShortcutBinding + let reference: EventHotKeyRef + let carbonID: UInt32 + } + + // "AHKY" = 0x4148_4B59 + private static let signature: OSType = 0x4148_4B59 + + var onTrigger: ((UUID) -> Void)? + + private var handlerRef: EventHandlerRef? + private var registeredHotKeys: [UUID: RegisteredHotKey] = [:] + private var idsByCarbon: [UInt32: UUID] = [:] + private var nextCarbonID: UInt32 = 1 + + init() { + installHandler() + } + + /// Resynchronizes registered hotkeys from the current entries using an incremental diff. + func sync(entries: [AppShortcutEntry]) { + let desired = Dictionary( + uniqueKeysWithValues: entries.compactMap { e -> (UUID, ShortcutBinding)? in + guard let s = e.shortcut else { return nil } + return (e.id, s) + } + ) + + for id in registeredHotKeys.keys where desired[id] == nil { + unregister(id: id) + } + + for (id, binding) in desired { + if let existing = registeredHotKeys[id], existing.binding == binding { continue } + unregister(id: id) + register(id: id, binding: binding) + } + } + + func unregisterAll() { + for id in Array(registeredHotKeys.keys) { unregister(id: id) } + } + + /// Temporarily unregisters an entry while recording; callers restore it by invoking `sync`. + func temporarilyDisable(id: UUID) { + unregister(id: id) + } + + // MARK: Private + + private func installHandler() { + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + // Match the host global-shortcut path by using the event dispatcher target, avoiding + // routing differences between Carbon targets. The handler still filters AppHotkey events + // through the dedicated signature. + InstallEventHandler( + GetEventDispatcherTarget(), + Self.hotKeyHandler, + 1, + &eventType, + UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), + &handlerRef + ) + } + + private func register(id: UUID, binding: ShortcutBinding) { + var ref: EventHotKeyRef? + let cid = nextCarbonID + nextCarbonID += 1 + + let hotKeyID = EventHotKeyID(signature: Self.signature, id: cid) + let status = RegisterEventHotKey( + UInt32(binding.keyCode), + binding.modifiers.carbonFlags, + hotKeyID, + GetEventDispatcherTarget(), + 0, + &ref + ) + guard status == noErr, let ref else { return } + + registeredHotKeys[id] = RegisteredHotKey( + entryID: id, binding: binding, reference: ref, carbonID: cid + ) + idsByCarbon[cid] = id + } + + private func unregister(id: UUID) { + guard let registered = registeredHotKeys.removeValue(forKey: id) else { return } + idsByCarbon.removeValue(forKey: registered.carbonID) + UnregisterEventHotKey(registered.reference) + } + + private func dispatch(carbonID: UInt32) { + guard let id = idsByCarbon[carbonID] else { return } + onTrigger?(id) + } + + private nonisolated static let hotKeyHandler: EventHandlerUPP = { _, event, userData in + guard let event, let userData else { return OSStatus(eventNotHandledErr) } + + var hotKeyID = EventHotKeyID(signature: 0, id: 0) + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard status == noErr else { return status } + + guard hotKeyID.signature == 0x4148_4B59 else { + return OSStatus(eventNotHandledErr) + } + + let manager = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + Task { @MainActor in + manager.dispatch(carbonID: hotKeyID.id) + } + return noErr + } +} diff --git a/Plugins/AppHotkey/Sources/AppHotkeyManagerView.swift b/Plugins/AppHotkey/Sources/AppHotkeyManagerView.swift new file mode 100644 index 0000000..0e4a563 --- /dev/null +++ b/Plugins/AppHotkey/Sources/AppHotkeyManagerView.swift @@ -0,0 +1,204 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import MacToolsPluginKit + +// MARK: - Manager View + +struct AppHotkeyManagerView: View { + @ObservedObject var store: AppHotkeyStore + let localization: PluginLocalization + let onUpdate: () -> Void + var onBeginRecording: ((UUID) -> Void)? = nil + var onEndRecording: ((UUID) -> Void)? = nil + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + bindingSection + } + } + + // MARK: Binding Section + + private var bindingSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack { + Label(localization.string("settings.section.bindings", defaultValue: "应用绑定"), systemImage: "keyboard") + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + Spacer() + Button(action: addApp) { + Label(localization.string("settings.add", defaultValue: "添加"), systemImage: "plus") + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if store.entries.isEmpty { + emptyView + } else { + entryList + } + } + } + + private var emptyView: some View { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "keyboard") + .font(.system(size: PluginSettingsTheme.Size.emptyStateIcon)) + .foregroundStyle(.secondary) + Text(localization.string("settings.empty", defaultValue: "点击「添加」选择应用并绑定快捷键")) + .font(PluginSettingsTheme.Typography.pageDescription) + .foregroundStyle(.secondary) + } + .padding(.vertical, PluginSettingsTheme.Spacing.pagePadding) + Spacer() + } + .pluginSettingsCardBackground(.host) + } + + private var entryList: some View { + VStack(spacing: 0) { + ForEach(store.entries) { entry in + AppShortcutEntryRow( + entry: entry, + localization: localization, + onClearShortcut: { + store.updateShortcut(id: entry.id, shortcut: nil) + onUpdate() + }, + onDelete: { + store.deleteEntry(id: entry.id) + onUpdate() + }, + onBeginRecording: { onBeginRecording?(entry.id) }, + onEndRecording: { onEndRecording?(entry.id) }, + onRecord: { binding in + if let conflict = store.conflictEntry(for: binding, excludingID: entry.id) { + return .rejected(localization.format( + "settings.shortcutConflictFormat", + defaultValue: "与「%@」冲突", + conflict.displayName + )) + } + store.updateShortcut(id: entry.id, shortcut: binding) + onUpdate() + return .accepted + } + ) + if entry.id != store.entries.last?.id { + PluginSettingsListDivider() + } + } + } + .pluginSettingsCardBackground(.host) + } + + // MARK: Actions + + private func addApp() { + let panel = NSOpenPanel() + panel.title = localization.string("openPanel.title", defaultValue: "选择应用") + panel.message = localization.string("openPanel.message", defaultValue: "选择要绑定快捷键的应用") + panel.allowedContentTypes = [.application] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = URL(fileURLWithPath: "/Applications") + + guard panel.runModal() == .OK, let url = panel.url else { return } + guard Bundle(url: url) != nil else { return } + + let displayName = url.deletingPathExtension().lastPathComponent + let entry = AppShortcutEntry(bundleURL: url, displayName: displayName) + store.addEntry(entry) + onUpdate() + } +} + +// MARK: - Entry Row + +private struct AppShortcutEntryRow: View { + let entry: AppShortcutEntry + let localization: PluginLocalization + let onClearShortcut: () -> Void + let onDelete: () -> Void + let onBeginRecording: () -> Void + let onEndRecording: () -> Void + let onRecord: (ShortcutBinding) -> PluginShortcutRecordingResult + + private var appIcon: NSImage { + guard let url = entry.bundleURL else { + return NSWorkspace.shared.icon(forFile: "/Applications") + } + return NSWorkspace.shared.icon(forFile: url.path(percentEncoded: false)) + } + + private var shortcutText: String { + ShortcutFormatter.displayString(for: entry.shortcut) + .replacingOccurrences( + of: "None", + with: localization.string("settings.shortcutUnset", defaultValue: "未设置") + ) + } + + private var subtitle: String { + guard let url = entry.bundleURL else { + return localization.string("settings.pathUnavailable", defaultValue: "应用路径不可用") + } + + return url.path(percentEncoded: false) + } + + var body: some View { + HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(nsImage: appIcon) + .resizable() + .frame(width: PluginSettingsTheme.Size.rowIcon, height: PluginSettingsTheme.Size.rowIcon) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(entry.displayName) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .lineLimit(1) + + Text(subtitle) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + + PluginShortcutRecorder( + title: localization.format( + "settings.shortcutRecorderTitleFormat", + defaultValue: "%@ 快捷键", + entry.displayName + ), + displayText: shortcutText, + onRecord: onRecord, + onBeginRecording: onBeginRecording, + onEndRecording: onEndRecording + ) + + if entry.shortcut != nil { + Button(action: onClearShortcut) { + Image(systemName: "xmark.circle.fill") + .pluginSettingsRowIconStyle(.secondary) + } + .buttonStyle(.plain) + .help(localization.string("settings.clearShortcut", defaultValue: "清除快捷键")) + } + + Button(action: onDelete) { + Image(systemName: "trash") + .pluginSettingsRowIconStyle(.red.opacity(0.8)) + } + .buttonStyle(.plain) + .help(localization.string("settings.deleteBinding", defaultValue: "删除此绑定")) + } + .pluginSettingsListRowPadding() + } +} diff --git a/Plugins/AppHotkey/Sources/AppHotkeyModels.swift b/Plugins/AppHotkey/Sources/AppHotkeyModels.swift new file mode 100644 index 0000000..0f78718 --- /dev/null +++ b/Plugins/AppHotkey/Sources/AppHotkeyModels.swift @@ -0,0 +1,25 @@ +import Foundation +import MacToolsPluginKit + +/// Stores one app-to-shortcut binding. +struct AppShortcutEntry: Codable, Identifiable, Equatable { + let id: UUID + /// The `.app` bundle's `file://` URL string. + var bundleURLString: String + var displayName: String + var shortcut: ShortcutBinding? + + var bundleURL: URL? { URL(string: bundleURLString) } + + init( + id: UUID = UUID(), + bundleURL: URL, + displayName: String, + shortcut: ShortcutBinding? = nil + ) { + self.id = id + self.bundleURLString = bundleURL.absoluteString + self.displayName = displayName + self.shortcut = shortcut + } +} diff --git a/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift b/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift new file mode 100644 index 0000000..b04aac9 --- /dev/null +++ b/Plugins/AppHotkey/Sources/AppHotkeyPlugin.swift @@ -0,0 +1,173 @@ +import AppKit +import Foundation +import SwiftUI +import MacToolsPluginKit + +// MARK: - Bundle Factory + +public final class AppHotkeyPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + AppHotkeyPluginProvider(context: context) + } +} + +@MainActor +private struct AppHotkeyPluginProvider: PluginProvider { + let context: PluginRuntimeContext + func makePlugins() -> [any MacToolsPlugin] { + [AppHotkeyPlugin(context: context)] + } +} + +// MARK: - Plugin + +@MainActor +final class AppHotkeyPlugin: MacToolsPlugin, PluginPrimaryPanel { + + // MARK: Metadata + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + // MARK: Callbacks + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + // MARK: Private + + private let store: AppHotkeyStore + private let hotkeyManager: AppHotkeyManager + private let storage: PluginStorage + private let localization: PluginLocalization + private var isEnabled: Bool + + // MARK: Init + + init(context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "app-hotkey")) { + self.localization = PluginLocalization(bundle: context.resourceBundle) + self.storage = context.storage + self.store = AppHotkeyStore(storage: context.storage) + self.hotkeyManager = AppHotkeyManager() + self.metadata = PluginMetadata( + id: "app-hotkey", + title: localization.string("metadata.title", defaultValue: "应用快捷键"), + iconName: "keyboard", + iconTint: Color(nsColor: .systemYellow), + order: 65, + defaultDescription: localization.string("metadata.description", defaultValue: "为常用应用绑定全局快捷键") + ) + // Enabled by default; only an explicit user pause stores `false`. + self.isEnabled = context.storage.object(forKey: "isEnabled") == nil + ? true + : context.storage.bool(forKey: "isEnabled") + + hotkeyManager.onTrigger = { [weak self] id in + self?.launch(entryID: id) + } + } + + // MARK: MacToolsPlugin + + func activate(context: PluginRuntimeContext) { + syncHotkeys() + } + + func deactivate(reason: PluginDeactivationReason) { + if reason.requiresStateCleanup { + hotkeyManager.unregisterAll() + } + } + + func refresh() {} + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + // Hotkeys are managed by this plugin instead of the host shortcut system. + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + var configuration: PluginConfiguration? { + PluginConfiguration(description: metadata.defaultDescription) { [self] _ in + AppHotkeyManagerView( + store: self.store, + localization: self.localization, + onUpdate: { [weak self] in + self?.syncHotkeys() + self?.onStateChange?() + }, + onBeginRecording: { [weak self] id in + self?.hotkeyManager.temporarilyDisable(id: id) + }, + onEndRecording: { [weak self] _ in + self?.syncHotkeys() + } + ) + } + } + + // MARK: PluginPrimaryPanel + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: panelSubtitle, + isOn: isEnabled, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setSwitch(value): + isEnabled = value + storage.set(value, forKey: "isEnabled") + syncHotkeys() + onStateChange?() + default: + break + } + } + + // MARK: Private + + private var panelSubtitle: String { + let count = store.entries.filter { $0.shortcut != nil }.count + guard count > 0 else { + return localization.string("panel.subtitle.empty", defaultValue: "暂无绑定,前往设置配置") + } + return isEnabled + ? localization.format("panel.subtitle.enabledCountFormat", defaultValue: "%d 个快捷键已启用", count) + : localization.string("panel.subtitle.paused", defaultValue: "快捷键已暂停") + } + + private func syncHotkeys() { + hotkeyManager.sync(entries: isEnabled ? store.entries : []) + } + + /// Hides the target app when it is frontmost; otherwise opens or activates it. + private func launch(entryID: UUID) { + guard let entry = store.entries.first(where: { $0.id == entryID }), + let bundleURL = entry.bundleURL + else { return } + + let bundleIdentifier = Bundle(url: bundleURL)?.bundleIdentifier + + if let bundleIdentifier, + let frontmost = NSWorkspace.shared.frontmostApplication, + frontmost.bundleIdentifier == bundleIdentifier { + frontmost.hide() + } else { + let config = NSWorkspace.OpenConfiguration() + config.activates = true + NSWorkspace.shared.openApplication(at: bundleURL, configuration: config) { _, _ in } + } + } +} diff --git a/Plugins/AppHotkey/Sources/AppHotkeyStore.swift b/Plugins/AppHotkey/Sources/AppHotkeyStore.swift new file mode 100644 index 0000000..7538188 --- /dev/null +++ b/Plugins/AppHotkey/Sources/AppHotkeyStore.swift @@ -0,0 +1,51 @@ +import Foundation +import MacToolsPluginKit + +@MainActor +final class AppHotkeyStore: ObservableObject { + private enum Keys { + static let entries = "entries" + } + + @Published private(set) var entries: [AppShortcutEntry] + + private let storage: PluginStorage + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(storage: PluginStorage) { + self.storage = storage + if let data = storage.data(forKey: Keys.entries), + let loaded = try? JSONDecoder().decode([AppShortcutEntry].self, from: data) { + self.entries = loaded + } else { + self.entries = [] + } + } + + func addEntry(_ entry: AppShortcutEntry) { + entries.append(entry) + persist() + } + + func updateShortcut(id: UUID, shortcut: ShortcutBinding?) { + guard let idx = entries.firstIndex(where: { $0.id == id }) else { return } + entries[idx].shortcut = shortcut + persist() + } + + func deleteEntry(id: UUID) { + entries.removeAll { $0.id == id } + persist() + } + + /// Returns the existing entry that conflicts with the shortcut, excluding the current entry. + func conflictEntry(for shortcut: ShortcutBinding, excludingID: UUID? = nil) -> AppShortcutEntry? { + entries.first { $0.id != excludingID && $0.shortcut == shortcut } + } + + private func persist() { + guard let data = try? encoder.encode(entries) else { return } + storage.set(data, forKey: Keys.entries) + } +} diff --git a/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift b/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift new file mode 100644 index 0000000..8d802b8 --- /dev/null +++ b/Plugins/AppHotkey/Tests/AppHotkeyStoreTests.swift @@ -0,0 +1,120 @@ +import XCTest +import MacToolsPluginKit +@testable import AppHotkeyPlugin + +@MainActor +final class AppHotkeyStoreTests: XCTestCase { + func testAddUpdateDeleteAndPersistEntries() { + let storage = InMemoryPluginStorage() + let store = AppHotkeyStore(storage: storage) + let entry = AppShortcutEntry( + bundleURL: URL(fileURLWithPath: "/Applications/Safari.app"), + displayName: "Safari" + ) + let binding = ShortcutBinding(keyCode: 0, modifiers: [.command, .option]) + + store.addEntry(entry) + store.updateShortcut(id: entry.id, shortcut: binding) + + let reloaded = AppHotkeyStore(storage: storage) + XCTAssertEqual(reloaded.entries.count, 1) + XCTAssertEqual(reloaded.entries.first?.displayName, "Safari") + XCTAssertEqual(reloaded.entries.first?.shortcut, binding) + + reloaded.deleteEntry(id: entry.id) + XCTAssertTrue(AppHotkeyStore(storage: storage).entries.isEmpty) + } + + func testConflictDetectionIgnoresExcludedEntry() { + let store = AppHotkeyStore(storage: InMemoryPluginStorage()) + let binding = ShortcutBinding(keyCode: 0, modifiers: [.command, .control]) + let first = AppShortcutEntry( + bundleURL: URL(fileURLWithPath: "/Applications/A.app"), + displayName: "A", + shortcut: binding + ) + let second = AppShortcutEntry( + bundleURL: URL(fileURLWithPath: "/Applications/B.app"), + displayName: "B" + ) + + store.addEntry(first) + store.addEntry(second) + + XCTAssertEqual(store.conflictEntry(for: binding, excludingID: second.id)?.id, first.id) + XCTAssertNil(store.conflictEntry(for: binding, excludingID: first.id)) + } + + func testShortcutEntryCodableRoundTrip() throws { + let entry = AppShortcutEntry( + bundleURL: URL(fileURLWithPath: "/Applications/Xcode.app"), + displayName: "Xcode", + shortcut: ShortcutBinding(keyCode: 2, modifiers: [.command, .option]) + ) + + let decoded = try JSONDecoder().decode(AppShortcutEntry.self, from: JSONEncoder().encode(entry)) + + XCTAssertEqual(decoded, entry) + } +} + +@MainActor +final class AppHotkeyPluginTests: XCTestCase { + func testDefaultStateAndMetadata() { + let plugin = makePlugin() + + XCTAssertEqual(plugin.metadata.id, "app-hotkey") + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "暂无绑定,前往设置配置") + } + + func testSubtitleCountsOnlyBoundEntriesAndReflectsDisabledState() { + let storage = InMemoryPluginStorage() + let store = AppHotkeyStore(storage: storage) + store.addEntry(AppShortcutEntry( + bundleURL: URL(fileURLWithPath: "/Applications/Safari.app"), + displayName: "Safari", + shortcut: ShortcutBinding(keyCode: 0, modifiers: [.command]) + )) + store.addEntry(AppShortcutEntry( + bundleURL: URL(fileURLWithPath: "/Applications/Xcode.app"), + displayName: "Xcode" + )) + let plugin = makePlugin(storage: storage) + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "1 个快捷键已启用") + + plugin.handleAction(.setSwitch(false)) + + XCTAssertFalse(plugin.primaryPanelState.isOn) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "快捷键已暂停") + XCTAssertFalse(storage.bool(forKey: "isEnabled")) + } + + private func makePlugin() -> AppHotkeyPlugin { + makePlugin(storage: InMemoryPluginStorage()) + } + + private func makePlugin(storage: InMemoryPluginStorage) -> AppHotkeyPlugin { + AppHotkeyPlugin(context: PluginRuntimeContext(pluginID: "app-hotkey", storage: storage)) + } +} + +@MainActor +private final class InMemoryPluginStorage: PluginStorage { + private var store: [String: Any] = [:] + + func object(forKey key: String) -> Any? { store[key] } + func data(forKey key: String) -> Data? { store[key] as? Data } + func string(forKey key: String) -> String? { store[key] as? String } + func stringArray(forKey key: String) -> [String]? { store[key] as? [String] } + func integer(forKey key: String) -> Int { store[key] as? Int ?? 0 } + func bool(forKey key: String) -> Bool { store[key] as? Bool ?? false } + func set(_ value: Any?, forKey key: String) { store[key] = value } + func removeObject(forKey key: String) { store.removeValue(forKey: key) } + func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) { + guard store[key] == nil, let value = store[legacyKey] else { return } + store[key] = value + store.removeValue(forKey: legacyKey) + } +} diff --git a/Plugins/AppHotkey/plugin.json b/Plugins/AppHotkey/plugin.json new file mode 100644 index 0000000..2131735 --- /dev/null +++ b/Plugins/AppHotkey/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "app-hotkey", + "displayName": "应用快捷键", + "summary": "为常用应用绑定全局快捷键,快速打开或切换到前台", + "localizedMetadata": { + "ar": { + "displayName": "اختصارات التطبيقات", + "summary": "قم بربط الاختصارات العالمية بالتطبيقات الشائعة للتشغيل أو التبديل السريع." + }, + "de": { + "displayName": "App-Kurzbefehle", + "summary": "Binden Sie globale Verknüpfungen an gängige Apps, um sie schnell zu starten oder zu wechseln." + }, + "en": { + "displayName": "App Hotkeys", + "summary": "Bind global shortcuts to common apps for quick launch or switching." + }, + "es": { + "displayName": "Atajos de apps", + "summary": "Vincule accesos directos globales a aplicaciones comunes para iniciar o cambiar rápidamente." + }, + "fr": { + "displayName": "Raccourcis d’apps", + "summary": "Liez des raccourcis globaux aux applications courantes pour un lancement ou une commutation rapide." + }, + "ja": { + "displayName": "アプリショートカット", + "summary": "グローバル ショートカットを一般的なアプリにバインドして、すばやく起動したり切り替えたりできます。" + }, + "ko": { + "displayName": "앱 단축키", + "summary": "빠른 실행 또는 전환을 위해 일반 앱에 전역 바로가기를 바인딩합니다." + }, + "pt": { + "displayName": "Atalhos de apps", + "summary": "Vincule atalhos globais a aplicativos comuns para inicialização ou troca rápida." + }, + "ru": { + "displayName": "Горячие клавиши приложений", + "summary": "Привяжите глобальные ярлыки к общим приложениям для быстрого запуска или переключения." + }, + "zh-Hans": { + "displayName": "应用快捷键", + "summary": "为常用应用绑定全局快捷键,快速打开或切换到前台" + }, + "zh-Hant": { + "displayName": "應用程式快速鍵", + "summary": "為常用應用綁定全局快速鍵,快速打開或切換到前臺" + } + }, + "version": "1.0.8", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "AppHotkey.bundle", + "factoryClass": "AppHotkeyPlugin.AppHotkeyPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "AppHotkeyPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "productivity" +} diff --git a/Plugins/Appearance/Bundle/AppearancePluginBundleEntrypoint.swift b/Plugins/Appearance/Bundle/AppearancePluginBundleEntrypoint.swift new file mode 100644 index 0000000..5244e1b --- /dev/null +++ b/Plugins/Appearance/Bundle/AppearancePluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import AppearancePlugin + +private let appearancePluginFactoryAnchor: Any.Type = AppearancePluginFactory.self diff --git a/Plugins/Appearance/Resources/Localizable.xcstrings b/Plugins/Appearance/Resources/Localizable.xcstrings new file mode 100644 index 0000000..1436b97 --- /dev/null +++ b/Plugins/Appearance/Resources/Localizable.xcstrings @@ -0,0 +1,290 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوضع الداكن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dunkler Modus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dark Mode" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Modo oscuro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mode sombre" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダークモード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다크 모드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Modo escuro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Темный режим" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "深色模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "深色模式" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التبديل بين مظهر النظام الفاتح والداكن." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wechseln Sie zwischen heller und dunkler Systemdarstellung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch between light and dark system appearance." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambia entre la apariencia del sistema clara y oscura." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Basculez entre l'apparence claire et sombre du système." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムの外観を明暗で切り替えます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "밝은 시스템 모양과 어두운 시스템 모양 사이를 전환합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alternar entre a aparência clara e escura do sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переключение между светлым и темным внешним видом системы." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换系统亮色与深色外观" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換系統亮色與深色外觀" + } + } + } + }, + "panel.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "켜기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開啟" + } + } + } + }, + "panel.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado" + } + }, + "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 new file mode 100644 index 0000000..3342c6a --- /dev/null +++ b/Plugins/Appearance/Sources/AppearancePlugin.swift @@ -0,0 +1,144 @@ +import AppKit +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class AppearancePluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + AppearancePluginProvider(context: context) + } +} + +@MainActor +private struct AppearancePluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [AppearancePlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class AppearancePlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "AppearancePlugin") + private let localization: PluginLocalization + private var isDarkMode: Bool = false + private nonisolated(unsafe) var themeObserver: NSObjectProtocol? + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + self.metadata = PluginMetadata( + id: "appearance", + title: localization.string("metadata.title", defaultValue: "深色模式"), + iconName: "circle.lefthalf.filled", + iconTint: Color(nsColor: .systemIndigo), + order: 30, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "切换系统亮色与深色外观" + ) + ) + isDarkMode = Self.readSystemDarkMode() + observeSystemAppearanceChanges() + } + + deinit { + if let observer = themeObserver { + DistributedNotificationCenter.default().removeObserver(observer) + } + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: isDarkMode + ? localization.string("panel.subtitle.enabled", defaultValue: "已开启") + : localization.string("panel.subtitle.disabled", defaultValue: "已关闭"), + isOn: isDarkMode, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + func refresh() { + let current = Self.readSystemDarkMode() + if current != isDarkMode { + isDarkMode = current + onStateChange?() + } + } + + func handleAction(_ action: PluginPanelAction) { + guard case let .setSwitch(enable) = action else { return } + setDarkMode(enable) + } + + // MARK: - Private + + private static func readSystemDarkMode() -> Bool { + let style = UserDefaults(suiteName: ".GlobalPreferences")?.string(forKey: "AppleInterfaceStyle") + return style == "Dark" + } + + private func setDarkMode(_ enable: Bool) { + let script = """ + tell application "System Events" + tell appearance preferences + set dark mode to \(enable ? "true" : "false") + end tell + end tell + """ + let appleScript = NSAppleScript(source: script) + var error: NSDictionary? + appleScript?.executeAndReturnError(&error) + if let error { + logger.error("Failed to set dark mode: \(error)") + } else { + isDarkMode = enable + onStateChange?() + } + } + + private func observeSystemAppearanceChanges() { + themeObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + let current = Self.readSystemDarkMode() + if current != self.isDarkMode { + self.isDarkMode = current + self.onStateChange?() + } + } + } + } +} diff --git a/Plugins/Appearance/plugin.json b/Plugins/Appearance/plugin.json new file mode 100644 index 0000000..a6a5483 --- /dev/null +++ b/Plugins/Appearance/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "appearance", + "displayName": "深色模式", + "summary": "切换系统亮色与深色外观", + "localizedMetadata": { + "ar": { + "displayName": "الوضع الداكن", + "summary": "تبديل النظام بين المظهر الفاتح والداكن." + }, + "de": { + "displayName": "Dunkelmodus", + "summary": "Schalten Sie das System zwischen heller und dunkler Darstellung um." + }, + "en": { + "displayName": "Dark Mode", + "summary": "Switch the system between light and dark appearance." + }, + "es": { + "displayName": "Modo oscuro", + "summary": "Cambie el sistema entre apariencia clara y oscura." + }, + "fr": { + "displayName": "Mode sombre", + "summary": "Basculez le système entre l’apparence claire et sombre." + }, + "ja": { + "displayName": "ダークモード", + "summary": "システムの外観を明暗で切り替えます。" + }, + "ko": { + "displayName": "다크 모드", + "summary": "밝은 화면과 어두운 화면 간에 시스템을 전환합니다." + }, + "pt": { + "displayName": "Modo escuro", + "summary": "Alterne o sistema entre aparência clara e escura." + }, + "ru": { + "displayName": "Тёмный режим", + "summary": "Переключение системы между светлым и темным внешним видом." + }, + "zh-Hans": { + "displayName": "深色模式", + "summary": "切换系统亮色与深色外观" + }, + "zh-Hant": { + "displayName": "深色模式", + "summary": "切換系統亮色與深色外觀" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "Appearance.bundle", + "factoryClass": "AppearancePlugin.AppearancePluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "AppearancePlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/AutoHideDock/Bundle/AutoHideDockPluginBundleEntrypoint.swift b/Plugins/AutoHideDock/Bundle/AutoHideDockPluginBundleEntrypoint.swift new file mode 100644 index 0000000..b74019a --- /dev/null +++ b/Plugins/AutoHideDock/Bundle/AutoHideDockPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import AutoHideDockPlugin + +private let autoHideDockPluginFactoryAnchor: Any.Type = AutoHideDockPluginFactory.self diff --git a/Plugins/AutoHideDock/Resources/Localizable.xcstrings b/Plugins/AutoHideDock/Resources/Localizable.xcstrings new file mode 100644 index 0000000..8311457 --- /dev/null +++ b/Plugins/AutoHideDock/Resources/Localizable.xcstrings @@ -0,0 +1,361 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء تلقائي Dock" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch ausblenden Dock" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Hide Dock" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar automáticamente Dock" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer automatiquement Dock" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動非表示 Dock" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 숨기기 Dock" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ocultar automaticamente Dock" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматическое скрытие Dock" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动隐藏程序坞" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動隱藏Dock" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء Dock تلقائيًا للحصول على سطح مكتب أكثر نظافة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisches Ausblenden des Dock für einen aufgeräumteren Desktop." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-hide the Dock for a cleaner desktop." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Oculta automáticamente el Dock para tener un escritorio más limpio." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer automatiquement le Dock pour un bureau plus propre." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デスクトップをすっきりさせるために、Dock を自動的に非表示にします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 깔끔한 데스크탑을 위해 Dock을 자동으로 숨깁니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Oculte automaticamente Dock para deixar a área de trabalho mais limpa." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматическое скрытие Dock для более чистого рабочего стола." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动隐藏程序坞,提供更干净的桌面环境" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動隱藏Dock,提供更乾淨的桌面環境" + } + } + } + }, + "panel.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "켜기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開啟" + } + } + } + }, + "panel.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關閉" + } + } + } + }, + "error.toggleFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تبديل Dock الإخفاء التلقائي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisches Ausblenden von Dock konnte nicht umgeschaltet werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to toggle Dock auto-hide." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo alternar la ocultación automática de Dock." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'activation du masquage automatique de Dock." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dock の自動非表示を切り替えることができませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dock 자동 숨기기를 전환하지 못했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao ativar a ocultação automática de Dock." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось включить автоматическое скрытие Dock." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换 Dock 自动隐藏失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換 Dock 自動隱藏失敗" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift b/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift new file mode 100644 index 0000000..739444a --- /dev/null +++ b/Plugins/AutoHideDock/Sources/AutoHideDockPlugin.swift @@ -0,0 +1,167 @@ +import AppKit +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +protocol DockCommandRunning { + func setDockAutohide(_ isEnabled: Bool) throws +} + +struct ProcessDockCommandRunner: DockCommandRunning { + private let localization: PluginLocalization + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + } + + func setDockAutohide(_ isEnabled: Bool) throws { + let script = """ + tell application "System Events" + tell dock preferences + set autohide to \(isEnabled ? "true" : "false") + end tell + end tell + """ + + let appleScript = NSAppleScript(source: script) + var error: NSDictionary? + appleScript?.executeAndReturnError(&error) + + if let error { + let message = (error[NSAppleScript.errorMessage] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + throw NSError( + domain: "AutoHideDockPlugin", + code: (error[NSAppleScript.errorNumber] as? Int) ?? 1, + userInfo: [ + NSLocalizedDescriptionKey: message?.isEmpty == false + ? message! + : localization.string( + "error.toggleFailed", + defaultValue: "切换 Dock 自动隐藏失败" + ) + ] + ) + } + } +} + +public final class AutoHideDockPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + AutoHideDockPluginProvider(context: context) + } +} + +@MainActor +private struct AutoHideDockPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [AutoHideDockPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class AutoHideDockPlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "AutoHideDockPlugin") + private let commandRunner: any DockCommandRunning + private let stateReader: () -> Bool + private let localization: PluginLocalization + + private var isDockHidden: Bool + private var lastErrorMessage: String? + + init( + commandRunner: (any DockCommandRunning)? = nil, + stateReader: @escaping () -> Bool = { AutoHideDockPlugin.readDockAutohideState() }, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.commandRunner = commandRunner ?? ProcessDockCommandRunner(localization: localization) + self.stateReader = stateReader + self.metadata = PluginMetadata( + id: "auto-hide-dock", + title: localization.string("metadata.title", defaultValue: "自动隐藏程序坞"), + iconName: "rectangle.bottomthird.inset.filled", + iconTint: Color(nsColor: .systemBlue), + order: 45, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "自动隐藏程序坞,提供更干净的桌面环境" + ) + ) + self.isDockHidden = stateReader() + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: isDockHidden + ? localization.string("panel.subtitle.enabled", defaultValue: "已开启") + : localization.string("panel.subtitle.disabled", defaultValue: "已关闭"), + isOn: isDockHidden, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func refresh() { + let latestState = stateReader() + if latestState != isDockHidden { + isDockHidden = latestState + onStateChange?() + } + } + + func handleAction(_ action: PluginPanelAction) { + guard case let .setSwitch(isEnabled) = action else { + return + } + + setDockHidden(isEnabled) + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + private func setDockHidden(_ isEnabled: Bool) { + do { + try commandRunner.setDockAutohide(isEnabled) + isDockHidden = isEnabled + lastErrorMessage = nil + onStateChange?() + } catch { + logger.error("Failed to update Dock auto-hide: \(error.localizedDescription, privacy: .public)") + lastErrorMessage = error.localizedDescription + refresh() + onStateChange?() + } + } + + private nonisolated static func readDockAutohideState() -> Bool { + let defaults = UserDefaults(suiteName: "com.apple.dock") + return defaults?.object(forKey: "autohide") as? Bool ?? false + } +} diff --git a/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift b/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift new file mode 100644 index 0000000..7148176 --- /dev/null +++ b/Plugins/AutoHideDock/Tests/AutoHideDockPluginTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import AutoHideDockPlugin + +@MainActor +final class AutoHideDockPluginTests: XCTestCase { + func testMetadataIdentifiesAutoHideDockPlugin() { + let plugin = AutoHideDockPlugin( + commandRunner: MockDockCommandRunner(), + stateReader: { false } + ) + + XCTAssertEqual(plugin.metadata.id, "auto-hide-dock") + XCTAssertEqual(plugin.metadata.title, "自动隐藏程序坞") + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .switch) + } + + func testInitialStateReflectsStateReader() { + let plugin = AutoHideDockPlugin( + commandRunner: MockDockCommandRunner(), + stateReader: { true } + ) + + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") + } + + func testSwitchOnUpdatesDockState() { + let runner = MockDockCommandRunner() + let plugin = AutoHideDockPlugin( + commandRunner: runner, + stateReader: { false } + ) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertEqual(runner.setDockAutohideCalls, [true]) + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertNil(plugin.primaryPanelState.errorMessage) + } + + func testSwitchFailureKeepsPreviousStateAndSetsError() { + let runner = MockDockCommandRunner() + runner.shouldFailSet = true + let plugin = AutoHideDockPlugin( + commandRunner: runner, + stateReader: { false } + ) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertFalse(plugin.primaryPanelState.isOn) + XCTAssertNotNil(plugin.primaryPanelState.errorMessage) + } +} + +private final class MockDockCommandRunner: DockCommandRunning { + var shouldFailSet = false + var setDockAutohideCalls: [Bool] = [] + + func setDockAutohide(_ isEnabled: Bool) throws { + if shouldFailSet { + throw NSError( + domain: "AutoHideDockPluginTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "set failed"] + ) + } + + setDockAutohideCalls.append(isEnabled) + } +} diff --git a/Plugins/AutoHideDock/plugin.json b/Plugins/AutoHideDock/plugin.json new file mode 100644 index 0000000..5728742 --- /dev/null +++ b/Plugins/AutoHideDock/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "auto-hide-dock", + "displayName": "自动隐藏程序坞", + "summary": "自动隐藏程序坞,提供更干净的桌面环境", + "localizedMetadata": { + "ar": { + "displayName": "إخفاء Dock تلقائيًا", + "summary": "قم بإخفاء Dock تلقائيًا للحصول على سطح مكتب أكثر نظافة." + }, + "de": { + "displayName": "Dock automatisch ausblenden", + "summary": "Blenden Sie Dock automatisch aus, um den Desktop übersichtlicher zu gestalten." + }, + "en": { + "displayName": "Auto-hide Dock", + "summary": "Hide the Dock automatically for a cleaner desktop." + }, + "es": { + "displayName": "Ocultar Dock automáticamente", + "summary": "Oculte Dock automáticamente para tener un escritorio más limpio." + }, + "fr": { + "displayName": "Masquer le Dock automatiquement", + "summary": "Masquez automatiquement le Dock pour un bureau plus propre." + }, + "ja": { + "displayName": "Dockを自動的に隠す", + "summary": "デスクトップをすっきりさせるために、Dock を自動的に非表示にします。" + }, + "ko": { + "displayName": "Dock 자동 숨기기", + "summary": "더 깔끔한 데스크탑을 위해 Dock을 자동으로 숨깁니다." + }, + "pt": { + "displayName": "Ocultar Dock automaticamente", + "summary": "Oculte o Dock automaticamente para uma área de trabalho mais limpa." + }, + "ru": { + "displayName": "Автоскрытие Dock", + "summary": "Скройте Dock автоматически, чтобы рабочий стол стал чище." + }, + "zh-Hans": { + "displayName": "自动隐藏程序坞", + "summary": "自动隐藏程序坞,提供更干净的桌面环境" + }, + "zh-Hant": { + "displayName": "自動隱藏 Dock", + "summary": "自動隱藏Dock,提供更乾淨的桌面環境" + } + }, + "version": "1.0.9", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "AutoHideDock.bundle", + "factoryClass": "AutoHideDockPlugin.AutoHideDockPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "AutoHideDockPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/AutoHideMenuBar/Bundle/AutoHideMenuBarPluginBundleEntrypoint.swift b/Plugins/AutoHideMenuBar/Bundle/AutoHideMenuBarPluginBundleEntrypoint.swift new file mode 100644 index 0000000..a7135a0 --- /dev/null +++ b/Plugins/AutoHideMenuBar/Bundle/AutoHideMenuBarPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import AutoHideMenuBarPlugin + +private let autoHideMenuBarPluginFactoryAnchor: Any.Type = AutoHideMenuBarPluginFactory.self diff --git a/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings b/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings new file mode 100644 index 0000000..e909451 --- /dev/null +++ b/Plugins/AutoHideMenuBar/Resources/Localizable.xcstrings @@ -0,0 +1,361 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء شريط القوائم تلقائيًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Menüleiste automatisch ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-Hide Menu Bar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar barra de menú automáticamente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer automatiquement la barre de menus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニュー バーを自動的に非表示にする" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메뉴바 자동 숨기기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ocultar barra de menu automaticamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматическое скрытие строки меню" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动隐藏菜单栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動隱藏選單列" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء شريط القائمة تلقائيًا لتوفير مساحة أكبر على الشاشة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Menüleiste automatisch ausblenden für mehr Platz auf dem Bildschirm." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-hide the menu bar for more screen space." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar automáticamente la barra de menú para obtener más espacio en la pantalla." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer automatiquement la barre de menus pour plus d'espace sur l'écran." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面スペースを増やすためにメニュー バーを自動的に非表示にします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 많은 화면 공간을 위해 메뉴 표시줄을 자동으로 숨깁니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ocultar automaticamente a barra de menu para obter mais espaço na tela." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически скрыть строку меню, чтобы освободить место на экране." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动隐藏菜单栏,提供更完整的屏幕显示空间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動隱藏選單列,提供更完整的螢幕顯示空間" + } + } + } + }, + "panel.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "켜기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開啟" + } + } + } + }, + "panel.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關閉" + } + } + } + }, + "error.toggleFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تبديل الإخفاء التلقائي لشريط القائمة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das automatische Ausblenden der Menüleiste konnte nicht umgeschaltet werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to toggle menu bar auto-hide." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo alternar la ocultación automática de la barra de menú." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du masquage automatique de la barre de menus." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メニュー バーの自動非表示を切り替えることができませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메뉴 표시줄 자동 숨기기를 전환하지 못했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao alternar a ocultação automática da barra de menus." + } + }, + "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 new file mode 100644 index 0000000..f47327a --- /dev/null +++ b/Plugins/AutoHideMenuBar/Sources/AutoHideMenuBarPlugin.swift @@ -0,0 +1,195 @@ +import AppKit +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +protocol MenuBarCommandRunning { + func setMenuBarAutohide(_ isEnabled: Bool) throws +} + +struct ProcessMenuBarCommandRunner: MenuBarCommandRunning { + private let localization: PluginLocalization + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + } + + func setMenuBarAutohide(_ isEnabled: Bool) throws { + let script = """ + tell application "System Events" + tell dock preferences + set autohide menu bar to \(isEnabled ? "true" : "false") + end tell + end tell + """ + + let appleScript = NSAppleScript(source: script) + var error: NSDictionary? + appleScript?.executeAndReturnError(&error) + + if let error { + let message = (error[NSAppleScript.errorMessage] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + throw NSError( + domain: "AutoHideMenuBarPlugin", + code: (error[NSAppleScript.errorNumber] as? Int) ?? 1, + userInfo: [ + NSLocalizedDescriptionKey: message?.isEmpty == false + ? message! + : localization.string( + "error.toggleFailed", + defaultValue: "切换菜单栏自动隐藏失败" + ) + ] + ) + } + } +} + +public final class AutoHideMenuBarPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + AutoHideMenuBarPluginProvider(context: context) + } +} + +@MainActor +private struct AutoHideMenuBarPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [AutoHideMenuBarPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class AutoHideMenuBarPlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "AutoHideMenuBarPlugin") + private let commandRunner: any MenuBarCommandRunning + private let stateReader: () -> Bool + private let localization: PluginLocalization + + private var isMenuBarHidden: Bool + private var lastErrorMessage: String? + + init( + commandRunner: (any MenuBarCommandRunning)? = nil, + stateReader: @escaping () -> Bool = { AutoHideMenuBarPlugin.readMenuBarAutohideState() }, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.commandRunner = commandRunner ?? ProcessMenuBarCommandRunner(localization: localization) + self.stateReader = stateReader + self.metadata = PluginMetadata( + id: "auto-hide-menu-bar", + title: localization.string("metadata.title", defaultValue: "自动隐藏菜单栏"), + iconName: "menubar.rectangle", + iconTint: Color(nsColor: .systemIndigo), + order: 42, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "自动隐藏菜单栏,提供更完整的屏幕显示空间" + ) + ) + self.isMenuBarHidden = stateReader() + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: isMenuBarHidden + ? localization.string("panel.subtitle.enabled", defaultValue: "已开启") + : localization.string("panel.subtitle.disabled", defaultValue: "已关闭"), + isOn: isMenuBarHidden, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func refresh() { + let latestState = stateReader() + if latestState != isMenuBarHidden { + isMenuBarHidden = latestState + onStateChange?() + } + } + + func handleAction(_ action: PluginPanelAction) { + guard case let .setSwitch(isEnabled) = action else { + return + } + + setMenuBarHidden(isEnabled) + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + private func setMenuBarHidden(_ isEnabled: Bool) { + do { + try commandRunner.setMenuBarAutohide(isEnabled) + isMenuBarHidden = isEnabled + lastErrorMessage = nil + onStateChange?() + } catch { + logger.error("Failed to update menu bar auto-hide: \(error.localizedDescription, privacy: .public)") + lastErrorMessage = error.localizedDescription + refresh() + onStateChange?() + } + } + + nonisolated static func readMenuBarAutohideState( + globalDefaults: UserDefaults = .standard, + dockDefaults: UserDefaults? = UserDefaults(suiteName: "com.apple.dock") + ) -> Bool { + resolvedMenuBarAutohideState( + globalValue: globalDefaults.object(forKey: "_HIHideMenuBar"), + dockValue: dockDefaults?.object(forKey: "autohide-menubar") + ) + } + + nonisolated static func resolvedMenuBarAutohideState(globalValue: Any?, dockValue: Any?) -> Bool { + if let value = boolValue(from: globalValue) { + return value + } + + if let value = boolValue(from: dockValue) { + return value + } + + return false + } + + private nonisolated static func boolValue(from value: Any?) -> Bool? { + switch value { + case let value as Bool: + value + case let value as NSNumber: + value.boolValue + default: + nil + } + } +} diff --git a/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift b/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift new file mode 100644 index 0000000..0ffbe30 --- /dev/null +++ b/Plugins/AutoHideMenuBar/Tests/AutoHideMenuBarPluginTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import AutoHideMenuBarPlugin + +@MainActor +final class AutoHideMenuBarPluginTests: XCTestCase { + func testMetadataIdentifiesAutoHideMenuBarPlugin() { + let plugin = AutoHideMenuBarPlugin( + commandRunner: MockMenuBarCommandRunner(), + stateReader: { false } + ) + + XCTAssertEqual(plugin.metadata.id, "auto-hide-menu-bar") + XCTAssertEqual(plugin.metadata.title, "自动隐藏菜单栏") + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .switch) + } + + func testInitialStateReflectsStateReader() { + let plugin = AutoHideMenuBarPlugin( + commandRunner: MockMenuBarCommandRunner(), + stateReader: { true } + ) + + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") + } + + func testSwitchOnUpdatesMenuBarState() { + let runner = MockMenuBarCommandRunner() + let plugin = AutoHideMenuBarPlugin( + commandRunner: runner, + stateReader: { false } + ) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertEqual(runner.setMenuBarAutohideCalls, [true]) + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertNil(plugin.primaryPanelState.errorMessage) + } + + func testSwitchFailureKeepsPreviousStateAndSetsError() { + let runner = MockMenuBarCommandRunner() + runner.shouldFailSet = true + let plugin = AutoHideMenuBarPlugin( + commandRunner: runner, + stateReader: { false } + ) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertFalse(plugin.primaryPanelState.isOn) + XCTAssertNotNil(plugin.primaryPanelState.errorMessage) + } + + func testRefreshUpdatesStateWhenChangedExternally() { + var externalState = false + let plugin = AutoHideMenuBarPlugin( + commandRunner: MockMenuBarCommandRunner(), + stateReader: { externalState } + ) + + XCTAssertFalse(plugin.primaryPanelState.isOn) + + var stateChangeCount = 0 + plugin.onStateChange = { stateChangeCount += 1 } + + externalState = true + plugin.refresh() + + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertEqual(stateChangeCount, 1) + } + + func testStateReaderUsesGlobalMenuBarAutohideKey() { + let isEnabled = AutoHideMenuBarPlugin.resolvedMenuBarAutohideState( + globalValue: true, + dockValue: false + ) + + XCTAssertTrue(isEnabled) + } + + func testStateReaderFallsBackToLegacyDockKey() { + let isEnabled = AutoHideMenuBarPlugin.resolvedMenuBarAutohideState( + globalValue: nil, + dockValue: true + ) + + XCTAssertTrue(isEnabled) + } +} + +final class MockMenuBarCommandRunner: MenuBarCommandRunning { + var shouldFailSet = false + var setMenuBarAutohideCalls: [Bool] = [] + + func setMenuBarAutohide(_ isEnabled: Bool) throws { + if shouldFailSet { + throw NSError( + domain: "AutoHideMenuBarPluginTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "set failed"] + ) + } + + setMenuBarAutohideCalls.append(isEnabled) + } +} diff --git a/Plugins/AutoHideMenuBar/plugin.json b/Plugins/AutoHideMenuBar/plugin.json new file mode 100644 index 0000000..24e7ed1 --- /dev/null +++ b/Plugins/AutoHideMenuBar/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "auto-hide-menu-bar", + "displayName": "自动隐藏菜单栏", + "summary": "自动隐藏菜单栏,提供更完整的屏幕显示空间", + "localizedMetadata": { + "ar": { + "displayName": "إخفاء شريط القوائم تلقائيًا", + "summary": "إخفاء شريط القائمة تلقائيًا لتوفير مساحة أكبر على الشاشة." + }, + "de": { + "displayName": "Menüleiste automatisch ausblenden", + "summary": "Blenden Sie die Menüleiste automatisch aus, um mehr Platz auf dem Bildschirm zu schaffen." + }, + "en": { + "displayName": "Auto-hide Menu Bar", + "summary": "Hide the menu bar automatically for more screen space." + }, + "es": { + "displayName": "Ocultar barra de menús automáticamente", + "summary": "Oculte la barra de menú automáticamente para obtener más espacio en la pantalla." + }, + "fr": { + "displayName": "Masquer la barre des menus automatiquement", + "summary": "Masquez automatiquement la barre de menus pour plus d'espace sur l'écran." + }, + "ja": { + "displayName": "メニューバーを自動的に隠す", + "summary": "画面スペースを増やすために、メニュー バーを自動的に非表示にします。" + }, + "ko": { + "displayName": "메뉴 막대 자동 숨기기", + "summary": "더 많은 화면 공간을 위해 메뉴 표시줄을 자동으로 숨깁니다." + }, + "pt": { + "displayName": "Ocultar barra de menus automaticamente", + "summary": "Oculte a barra de menu automaticamente para obter mais espaço na tela." + }, + "ru": { + "displayName": "Автоскрытие строки меню", + "summary": "Автоматически скрывайте строку меню, чтобы освободить больше места на экране." + }, + "zh-Hans": { + "displayName": "自动隐藏菜单栏", + "summary": "自动隐藏菜单栏,提供更完整的屏幕显示空间" + }, + "zh-Hant": { + "displayName": "自動隱藏選單列", + "summary": "自動隱藏選單列,提供更完整的螢幕顯示空間" + } + }, + "version": "1.0.6", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "AutoHideMenuBar.bundle", + "factoryClass": "AutoHideMenuBarPlugin.AutoHideMenuBarPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "AutoHideMenuBarPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/BatteryChargeLimit/Bundle/BatteryChargeLimitPluginBundleEntrypoint.swift b/Plugins/BatteryChargeLimit/Bundle/BatteryChargeLimitPluginBundleEntrypoint.swift new file mode 100644 index 0000000..f78445d --- /dev/null +++ b/Plugins/BatteryChargeLimit/Bundle/BatteryChargeLimitPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import BatteryChargeLimitPlugin + +private let batteryChargeLimitPluginFactoryAnchor: Any.Type = BatteryChargeLimitPluginFactory.self diff --git a/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings b/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings new file mode 100644 index 0000000..cd2a631 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Resources/Localizable.xcstrings @@ -0,0 +1,2846 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "error.helperInstall.authorizationScriptUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على إنشاء البرنامج النصي للتفويض." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Autorisierungsskript konnte nicht erstellt werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to create the authorization script." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede crear el script de autorización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de créer le script d'autorisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認可スクリプトを作成できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증 스크립트를 생성할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível criar o script de autorização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно создать скрипт авторизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法创建授权脚本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法創建授權腳本" + } + } + } + }, + "error.helperInstall.missingAfterInstall": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يزال يتعذر العثور على المكون بعد التثبيت." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Komponente kann nach der Installation immer noch nicht gefunden werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The component still cannot be found after installation." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no se puede encontrar el componente después de la instalación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le composant est toujours introuvable après l'installation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール後もコンポーネントが見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치 후에도 구성 요소를 찾을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O componente ainda não foi encontrado após a instalação." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Компонент по-прежнему не найден после установки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装后仍无法找到组件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝後仍無法找到組件" + } + } + } + }, + "error.helperInstallFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تثبيت مكون التحكم في البطارية: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batteriesteuerungskomponente konnte nicht installiert werden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to install battery control component: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al instalar el componente de control de batería: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'installation du composant de contrôle de la batterie : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー制御コンポーネントのインストールに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 제어 구성요소 설치 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao instalar o componente de controle da bateria: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось установить компонент управления аккумулятором: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装电池控制组件失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝電池控制組件失敗:%@" + } + } + } + }, + "error.helperNotFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مكون التحكم في البطارية المجمع. أعد تثبيت المكوّن الإضافي Battery Charge Limit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die mitgelieferte Batteriesteuerungskomponente wurde nicht gefunden. Installieren Sie das Plugin „Battery Charge Limit“ neu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The bundled battery control component was not found. Reinstall the Battery Charge Limit plugin." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró el componente de control de batería incluido. Vuelva a instalar el complemento Límite de carga de la batería." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le composant de contrôle de batterie fourni est introuvable. Réinstallez le plug-in Battery Charge Limit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バンドルされているバッテリー制御コンポーネントが見つかりませんでした。 Battery Charge Limit プラグインを再インストールします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "번들 배터리 제어 구성 요소를 찾을 수 없습니다. 배터리 충전 제한 플러그인을 다시 설치하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O componente de controle da bateria incluído não foi encontrado. Reinstale o plug-in Battery Charge Limit." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Входящий в комплект поставки компонент управления аккумулятором не найден. Переустановите плагин ограничения заряда батареи." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到内置电池控制组件。请重新安装电池充电上限插件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未找到內置電池控制組件。請重新安裝電池充電上限外掛。" + } + } + } + }, + "error.helperVerificationFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التحقق من مكون التحكم في البطارية. أعد تثبيت المكوّن الإضافي Battery Charge Limit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfung der Batteriesteuerungskomponente fehlgeschlagen. Installieren Sie das Plugin „Battery Charge Limit“ neu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery control component verification failed. Reinstall the Battery Charge Limit plugin." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en la verificación del componente de control de la batería. Vuelva a instalar el complemento Límite de carga de la batería." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La vérification des composants de contrôle de la batterie a échoué. Réinstallez le plug-in Battery Charge Limit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー制御コンポーネントの検証に失敗しました。 Battery Charge Limit プラグインを再インストールします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 제어 구성 요소 확인에 실패했습니다. 배터리 충전 제한 플러그인을 다시 설치하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na verificação do componente de controle da bateria. Reinstale o plug-in Battery Charge Limit." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка компонента управления аккумулятором не удалась. Переустановите плагин ограничения заряда батареи." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池控制组件校验失败。请重新安装电池充电上限插件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電池控制組件校驗失敗。請重新安裝電池充電上限外掛。" + } + } + } + }, + "error.noSupportedSMCKey": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يعرض برنامج Mac الثابت هذا مفتاحًا للتحكم في الشحن قابلاً للكتابة. ربما قام نظام التشغيل macOS بتغيير الدعم في هذا الإصدار." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Mac-Firmware stellt keinen beschreibbaren Ladekontrollschlüssel zur Verfügung. Möglicherweise hat macOS in dieser Version die Unterstützung geändert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This Mac firmware does not expose a writable charge control key. macOS may have changed support in this version." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este firmware de Mac no expone una clave de control de carga grabable. Es posible que macOS haya cambiado la compatibilidad en esta versión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce micrologiciel Mac n'expose pas de clé de contrôle de charge inscriptible. macOS a peut-être changé de prise en charge dans cette version." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac ファームウェアは書き込み可能な充電制御キーを公開しません。 macOS は、このバージョンでのサポートが変更されている可能性があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac 펌웨어는 쓰기 가능한 충전 제어 키를 노출하지 않습니다. macOS가 이 버전에서 지원을 변경했을 수 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este firmware Mac não expõe uma chave de controle de carga gravável. O macOS pode ter alterado o suporte nesta versão." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эта прошивка Mac не предоставляет записываемый ключ управления зарядом. Поддержка macOS в этой версии могла быть изменена." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前 Mac 固件未提供可写的充电控制键。可能是 macOS 已升级到不支持的版本。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前 Mac 固件未提供可寫的充電控制鍵。可能是 macOS 已升級到不支持的版本。" + } + } + } + }, + "error.write.inhibitCharging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على إيقاف الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladevorgang konnte nicht gestoppt werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to stop charging" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede detener la carga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'arrêter la charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電を停止できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전을 중지할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível interromper o carregamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно остановить зарядку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法停止充电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法停止充電" + } + } + } + }, + "error.write.resumeCharging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على استئناف الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladevorgang kann nicht fortgesetzt werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to resume charging" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede reanudar la carga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de reprendre le chargement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電を再開できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전을 재개할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível retomar o carregamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно возобновить зарядку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法恢复充电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法恢復充電" + } + } + } + }, + "error.write.toggleDischarge": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على تبديل وضع التفريغ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entlademodus kann nicht umgeschaltet werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to switch discharge mode" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede cambiar el modo de descarga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de changer de mode de décharge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "放電モードを切り替えられません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방전 모드를 전환할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível alternar o modo de descarga" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно переключить режим разрядки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法切换放电模式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法切換放電模式" + } + } + } + }, + "error.writeFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت كتابة حالة التحكم في البطارية: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batteriekontrollstatus konnte nicht geschrieben werden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to write battery control state: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al escribir el estado de control de la batería: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'écriture de l'état de contrôle de la batterie : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー制御状態の書き込みに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 제어 상태를 쓰지 못했습니다: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao gravar o estado de controle da bateria: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось записать состояние управления батареей: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "写入充电控制失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寫入充電控制失敗:%@" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحد من شحن البطارية إلى المستوى المختار." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Begrenzen Sie die Akkuladung auf ein ausgewähltes Niveau." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Limit battery charging to a chosen level." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limite la carga de la batería a un nivel elegido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Limiter la charge de la batterie à un niveau choisi." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリーの充電を選択したレベルに制限します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 충전을 선택한 수준으로 제한하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limite o carregamento da bateria a um nível escolhido." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ограничить зарядку аккумулятора до выбранного уровня." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "限制电池充电至指定上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "限制電池充電至指定上限" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حد شحن البطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akkuladegrenze" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery Charge Limit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Límite de carga de la batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Limite de charge de la batterie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー充電制限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 충전 제한" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limite de carga da bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предел заряда аккумулятора" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池充电上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電池充電上限" + } + } + } + }, + "panel.action.disable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعطيل حد الرسوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit deaktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable Charge Limit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar límite de cargos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver la limite de charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電制限を無効にする" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 한도 비활성화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desativar limite de cobrança" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить ограничение заряда" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停用充电上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用充電上限" + } + } + } + }, + "panel.action.dischargeToLimit": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التفريغ إلى %d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entlassung an %d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Discharge to %d%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descarga a %d%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déchargement à %d%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d%% まで放電" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d%%로 하차" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Descarga para %d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выписка в %d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "强制放电至 %d%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強制放電至 %d%%" + } + } + } + }, + "panel.action.enable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمكين حد الرسوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Charge Limit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilitar límite de cargos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer la limite de charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電制限を有効にする" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 한도 활성화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativar limite de cobrança" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить лимит заряда" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用充电上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用充電上限" + } + } + } + }, + "panel.action.missingHelper": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مكون التحكم في البطارية مفقود" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batteriesteuerungskomponente fehlt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery control component missing" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Falta el componente de control de batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Composant de contrôle de la batterie manquant" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー制御コンポーネントがありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 제어 구성요소 누락" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Componente de controle da bateria ausente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отсутствует компонент управления аккумулятором" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电池控制组件缺失" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電池控制組件缺失" + } + } + } + }, + "panel.action.settings": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإعدادات..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configuración…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Paramètres…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Configurações…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定…" + } + } + } + }, + "panel.action.startCharging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابدأ الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladevorgang starten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Start Charging" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comenzar a cargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commencer à charger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電を開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 시작" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Começar a carregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начать зарядку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始充电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開始充電" + } + } + } + }, + "panel.action.stopCharging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أوقف الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufladen beenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop Charging" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dejar de cargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter de charger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電を停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 중지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Parar de carregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить зарядку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止充电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止充電" + } + } + } + }, + "panel.action.stopDischarging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف التفريغ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entladen stoppen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop Discharging" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detener la descarga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter la décharge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "放電を停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방전 중지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Parar de descarregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить разрядку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止放电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止放電" + } + } + } + }, + "panel.section.limit": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حد الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gebührenlimit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge Limit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Límite de cargo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Limite de frais" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャージ制限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 한도" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limite de cobrança" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лимит заряда" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电上限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電上限" + } + } + } + }, + "panel.subtitle.charging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشحن · %d%% → %d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufladen · %d%% → %d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging · %d%% → %d%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando · %d%% → %d%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge · %d%% → %d%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電中 · %d%% → %d%%" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 중 · %d%% → %d%%" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregando · %d%% → %d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка · %d%% → %d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电中 · %d%% → %d%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電中 · %d%% → %d%%" + } + } + } + }, + "panel.subtitle.chargingStopped": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توقف الشحن · %d%% / %d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladevorgang gestoppt · %d%% / %d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging stopped · %d%% / %d%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carga detenida · %d%% / %d%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge arrêtée · %d%% / %d%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電が停止しました · %d%% / %d%%" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 중지됨 · %d%% / %d%%" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregamento interrompido · %d%% / %d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка остановлена · %d%% / %d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已停止充电 · %d%% / %d%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已停止充電 · %d%% / %d%%" + } + } + } + }, + "panel.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف · %d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus · %d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off · %d%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado · %d%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé · %d%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ · %d%%" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기 · %d%%" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado · %d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл. · %d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未启用 · %d%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未啟用 · %d%%" + } + } + } + }, + "panel.subtitle.discharging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التفريغ · %d%% → %d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entladen · %d%% → %d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Discharging · %d%% → %d%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descarga · %d%% → %d%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Décharge · %d%% → %d%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "放電中 · %d%% → %d%%" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "방전 · %d%% → %d%%" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Descarregando · %d%% → %d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрядка · %d%% → %d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "放电中 · %d%% → %d%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "放電中 · %d%% → %d%%" + } + } + } + }, + "panel.subtitle.limitReached": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الوصول إلى الحد · %d%% / %d%%" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Limit erreicht · %d%% / %d%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Limit reached · %d%% / %d%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Límite alcanzado · %d%% / %d%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Limite atteinte · %d%% / %d%%" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "制限に達しました · %d%% / %d%%" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한도 도달 · %d%% / %d%%" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limite atingido · %d%% / %d%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Достигнут лимит · %d%% / %d%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已达上限 · %d%% / %d%%" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已達上限 · %d%% / %d%%" + } + } + } + }, + "panel.subtitle.noBattery": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف أي بطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Akku erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No battery detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectó batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune batterie détectée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリーが検出されません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리가 감지되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma bateria detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Батарея не обнаружена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到电池" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到電池" + } + } + } + }, + "settings.behavior.noAutoResume.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عندما تنخفض طاقة البطارية إلى ما دون الحد المسموح به، لن يتم استئناف الشحن تلقائيًا. انقر فوق \"بدء الشحن\" في شريط القائمة عندما تريد المتابعة. وهذا يختلف عن شحن البطارية المحسّن لنظام التشغيل macOS، والذي يحافظ على الشحن المتتابع بالقرب من الحد الأقصى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn der Akku unter den Grenzwert fällt, wird der Ladevorgang nicht automatisch fortgesetzt. Klicken Sie in der Menüleiste auf „Aufladen starten“, wenn Sie fortfahren möchten. Dies unterscheidet sich von macOS Optimized Battery Charging, das die Erhaltungsladung nahe am Limit hält." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "When the battery drops below the limit, charging will not resume automatically. Click \"Start Charging\" in the menu bar when you want to continue. This differs from macOS Optimized Battery Charging, which keeps trickle charging near the limit." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuando la batería cae por debajo del límite, la carga no se reanudará automáticamente. Haga clic en \"Comenzar a cargar\" en la barra de menú cuando desee continuar. Esto difiere de la carga de batería optimizada de macOS, que mantiene la carga lenta cerca del límite." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lorsque la batterie descend en dessous de la limite, la charge ne reprend pas automatiquement. Cliquez sur « Démarrer le chargement » dans la barre de menu lorsque vous souhaitez continuer. Cela diffère du chargement optimisé de la batterie macOS, qui maintient la charge de maintien proche de la limite." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー残量が制限値を下回った場合、充電は自動的に再開されません。続行する場合は、メニューバーの「充電開始」をクリックしてください。これは、トリクル充電を限界近くに保つ macOS の最適化されたバッテリー充電とは異なります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리가 한도 이하로 떨어지면 충전이 자동으로 재개되지 않습니다. 계속하려면 메뉴 표시줄에서 \"충전 시작\"을 클릭하세요. 이는 한계에 가깝게 세류 충전을 유지하는 macOS 최적화 배터리 충전과 다릅니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Quando a bateria cair abaixo do limite, o carregamento não será retomado automaticamente. Clique em “Iniciar carregamento” na barra de menu quando quiser continuar. Isso é diferente do carregamento otimizado da bateria do macOS, que mantém o carregamento lento próximo ao limite." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Когда уровень заряда батареи упадет ниже предела, зарядка не возобновится автоматически. Нажмите «Начать зарядку» в строке меню, если хотите продолжить. Это отличается от оптимизированной зарядки аккумулятора macOS, которая поддерживает подзарядку на пределе." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电量低于上限时不会自动充电,需要在菜单栏点击「开始充电」才会继续。这与系统自带的「优化电池充电」不同——系统会持续微充电以贴近上限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電量低於上限時不會自動充電,需要在選單列點擊「開始充電」纔會繼續。這與系統自帶的「優化電池充電」不同——系統會持續微充電以貼近上限。" + } + } + } + }, + "settings.behavior.noAutoResume.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عدم الاستئناف تلقائيًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht automatisch fortsetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Do Not Resume Automatically" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No reanudar automáticamente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ne pas reprendre automatiquement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動的に再開しない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동으로 재개하지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não retomar automaticamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не возобновлять автоматически" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不自动恢复充电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不自動恢復充電" + } + } + } + }, + "settings.behavior.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سلوك الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladeverhalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging Behavior" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comportamiento de carga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comportement de charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電動作" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 동작" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Comportamento de carregamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение зарядки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电行为" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電行為" + } + } + } + }, + "settings.compatibility.controlMethod": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "طريقة التحكم في الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladekontrollmethode" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge Control Method" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Método de control de carga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Méthode de contrôle des frais" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電制御方法" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 제어 방법" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Método de controle de cobrança" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Метод контроля заряда" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电控制方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電控制方式" + } + } + } + }, + "settings.compatibility.forceDischarge": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفريغ القوة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erzwungene Entladung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Force Discharge" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descarga de fuerza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Décharge forcée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "強制排出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "강제 방출" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Descarga Forçada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Принудительный разряд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "强制放电" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "強制放電" + } + } + } + }, + "settings.compatibility.forceDischarge.supported": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدعوم (CH0I)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unterstützt (CH0I)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Supported (CH0I)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Compatible (CH0I)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supporté (CH0I)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポート (CH0I)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원됨(CH0I)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Compatível (CH0I)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поддерживается (CH0I)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "支持(CH0I)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "支持(CH0I)" + } + } + } + }, + "settings.compatibility.intelLimit.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يدعم جهاز Mac هذا BCLM فقط. عندما تنخفض البطارية إلى ما دون الحد المسموح به، قد يستمر النظام في إعادة شحنها إلى الحد المسموح به. لا يمكن ضمان سلوك \"عدم الاستئناف تلقائيًا\" على أجهزة Intel Mac." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Mac unterstützt nur BCLM. Wenn der Akku unter den Grenzwert fällt, kann das System ihn möglicherweise trotzdem bis zum Grenzwert aufladen. Das Verhalten „Nicht automatisch fortsetzen“ kann auf Intel-Macs nicht garantiert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This Mac only supports BCLM. When the battery drops below the limit, the system may still charge it back to the limit. The \"do not resume automatically\" behavior cannot be guaranteed on Intel Macs." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta Mac solo es compatible con BCLM. Cuando la batería cae por debajo del límite, el sistema aún puede cargarla nuevamente hasta el límite. El comportamiento \"no reanudar automáticamente\" no se puede garantizar en Mac Intel." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce Mac prend uniquement en charge BCLM. Lorsque la batterie descend en dessous de la limite, le système peut toujours la recharger jusqu'à la limite. Le comportement « ne pas reprendre automatiquement » ne peut pas être garanti sur les Mac Intel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac は BCLM のみをサポートしています。バッテリーが制限値を下回った場合でも、システムはバッテリーを制限値まで充電し直すことがあります。 「自動的に再開しない」動作は、Intel Mac では保証できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac은 BCLM만 지원합니다. 배터리가 한도 아래로 떨어지더라도 시스템은 배터리를 한도까지 다시 충전할 수 있습니다. Intel Mac에서는 \"자동으로 다시 시작하지 않음\" 동작이 보장되지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este Mac suporta apenas BCLM. Quando a bateria cai abaixo do limite, o sistema ainda pode carregá-la até o limite. O comportamento \"não retomar automaticamente\" não pode ser garantido em Macs Intel." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Этот Mac поддерживает только BCLM. Когда уровень заряда батареи опускается ниже предела, система все равно может зарядить ее до предела. Поведение «не возобновлять автоматически» не может быть гарантировано на компьютерах Intel Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前 Mac 仅支持 BCLM,电量低于上限时仍可能被系统自动充至上限。「不自动恢复」语义在 Intel Mac 上无法保证。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前 Mac 僅支持 BCLM,電量低於上限時仍可能被系統自動充至上限。「不自動恢復」語義在 Intel Mac 上無法保證。" + } + } + } + }, + "settings.compatibility.intelLimit.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدود Intel Mac" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Intel Mac-Einschränkung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Intel Mac Limitation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limitación de Intel Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Limitation Intel Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Intel Mac の制限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Intel Mac 제한" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limitação do Intel Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ограничение Intel Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Intel Mac 限制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Intel Mac 限制" + } + } + } + }, + "settings.compatibility.noSMCKey": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف مفتاح التحكم في الشحن SMC المتاح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein verfügbarer SMC-Ladekontrollschlüssel erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No available SMC charge control key detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectó ninguna clave de control de carga SMC disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune clé de contrôle de charge SMC disponible détectée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能な SMC 充電制御キーが検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 SMC 충전 제어 키가 감지되지 않았습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma chave de controle de carga SMC disponível foi detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не обнаружен доступный ключ управления зарядкой SMC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到可用的 SMC 充电控制键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到可用的 SMC 充電控制鍵" + } + } + } + }, + "settings.compatibility.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توافق الأجهزة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hardwarekompatibilität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hardware Compatibility" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Compatibilidad de hardware" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compatibilité matérielle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ハードウェアの互換性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하드웨어 호환성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Compatibilidade de hardware" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аппаратная совместимость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "硬件兼容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "硬件兼容" + } + } + } + }, + "settings.limit.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتوقف الشحن تلقائيًا عند هذا المستوى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Ladevorgang stoppt automatisch auf diesem Niveau." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging stops automatically at this level." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La carga se detiene automáticamente en este nivel." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La charge s'arrête automatiquement à ce niveau." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このレベルになると充電は自動的に停止します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 수준에서는 충전이 자동으로 중지됩니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O carregamento para automaticamente neste nível." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "На этом уровне зарядка автоматически прекращается." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "达到此电量后自动停止充电。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "達到此電量後自動停止充電。" + } + } + } + }, + "settings.limit.target": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المستوى المستهدف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zielebene" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Target Level" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nivel objetivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Niveau cible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターゲットレベル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목표 수준" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nível alvo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Целевой уровень" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目標電量" + } + } + } + }, + "settings.limit.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حد الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gebührenlimit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge Limit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Límite de cargo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Limite de frais" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャージ制限" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 한도" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limite de cobrança" + } + }, + "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/SMCHelper/README.md b/Plugins/BatteryChargeLimit/SMCHelper/README.md new file mode 100644 index 0000000..4b5dbf4 --- /dev/null +++ b/Plugins/BatteryChargeLimit/SMCHelper/README.md @@ -0,0 +1,14 @@ +This directory holds the source for the bundled Battery SMC helper. + +The helper binary is built as `mactools-battery-smc-helper` and copied into +`BatteryChargeLimit.bundle/Contents/Resources/SMCHelper/` by the generated +Xcode project. The plugin installs it to `/Library/PrivilegedHelperTools` with +root ownership and setuid permissions on first use. + +The helper exposes these subcommands: + + probe Output which charge-control SMC keys are writable + inhibit [] Stop charging (prefers BCLM soft ceiling; falls back to CHTE or CH0B+CH0C) + resume Clear inhibit keys and stop force-discharge + discharge on|off Toggle CH0I (force-discharge while plugged in) + read Print the current value of a 1-byte SMC key diff --git a/Plugins/BatteryChargeLimit/SMCHelper/Sources/main.swift b/Plugins/BatteryChargeLimit/SMCHelper/Sources/main.swift new file mode 100644 index 0000000..f8b3f3e --- /dev/null +++ b/Plugins/BatteryChargeLimit/SMCHelper/Sources/main.swift @@ -0,0 +1,585 @@ +import Foundation +import IOKit + +// MARK: - SMC Types + +private typealias SMCBytes = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 +) + +private struct SMCKeyInfo { + var dataSize: UInt32 = 0 + var dataType: UInt32 = 0 + var dataAttributes: UInt8 = 0 +} + +private struct SMCVers { + var major: UInt8 = 0 + var minor: UInt8 = 0 + var build: UInt8 = 0 + var reserved: UInt8 = 0 + var release: UInt16 = 0 +} + +private struct SMCPLimit { + var version: UInt16 = 0 + var length: UInt16 = 0 + var cpuPLimit: UInt32 = 0 + var gpuPLimit: UInt32 = 0 + var memPLimit: UInt32 = 0 +} + +private struct SMCParam { + var key: UInt32 = 0 + var vers = SMCVers() + var pLimit = SMCPLimit() + var keyInfo = SMCKeyInfo() + var padding: UInt16 = 0 + var result: UInt8 = 0 + var status: UInt8 = 0 + var data8: UInt8 = 0 + var data32: UInt32 = 0 + var bytes: SMCBytes = ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + ) +} + +private struct SMCValue { + var dataSize: UInt32 + var dataType: UInt32 + var bytes: [UInt8] +} + +private let kernelIndexSMC: UInt32 = 2 +private let smcCmdReadBytes: UInt8 = 5 +private let smcCmdWriteBytes: UInt8 = 6 +private let smcCmdReadKeyInfo: UInt8 = 9 + +private let typeUI8 = fourCC("ui8 ") +private let typeHEX_ = fourCC("hex_") +private let typeFlag = fourCC("flag") + +// MARK: - Errors + +private enum SMCHelperError: LocalizedError { + case serviceNotFound + case openFailed(kern_return_t) + case keyInfoFailed(String, kern_return_t) + case invalidKeyInfo(String) + case readFailed(String, kern_return_t) + case writeFailed(String, kern_return_t) + case writeVerificationFailed(String, expected: [UInt8], actual: [UInt8]) + case noWritableInhibitKey + case invalidArguments + + var errorDescription: String? { + switch self { + case .serviceNotFound: + return "AppleSMC service not found" + case .openFailed(let code): + return "Failed to open SMC connection: \(String(format: "%08x", code))" + case .keyInfoFailed(let key, let code): + return "Failed to read key info for \(key): \(String(format: "%08x", code))" + case .invalidKeyInfo(let key): + return "Invalid key info for \(key)" + case .readFailed(let key, let code): + return "Failed to read \(key): \(String(format: "%08x", code))" + case .writeFailed(let key, let code): + return "Failed to write \(key): \(String(format: "%08x", code))" + case .writeVerificationFailed(let key, let expected, let actual): + return "Failed to verify \(key): expected \(hexString(expected)), read \(hexString(actual))" + case .noWritableInhibitKey: + return "No supported charge-inhibit SMC key is writable on this Mac" + case .invalidArguments: + return "Invalid arguments" + } + } +} + +// MARK: - SMC Connection + +private final class SMCConnection { + private var connection: io_connect_t = 0 + private var keyInfoCache: [UInt32: SMCKeyInfo] = [:] + + init() throws { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC")) + guard service != 0 else { + throw SMCHelperError.serviceNotFound + } + defer { IOObjectRelease(service) } + + let result = IOServiceOpen(service, mach_task_self_, 0, &connection) + guard result == kIOReturnSuccess else { + throw SMCHelperError.openFailed(result) + } + } + + deinit { + if connection != 0 { + IOServiceClose(connection) + connection = 0 + } + } + + func readValue(key: String) throws -> SMCValue { + let keyCode = fourCC(key) + let info = try keyInfo(for: key, keyCode: keyCode) + + var input = SMCParam() + input.key = keyCode + input.keyInfo.dataSize = info.dataSize + input.data8 = smcCmdReadBytes + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + guard result == kIOReturnSuccess, output.result == 0 else { + throw SMCHelperError.readFailed(key, result) + } + + return SMCValue( + dataSize: info.dataSize, + dataType: info.dataType, + bytes: byteArray(output.bytes) + ) + } + + func writeValue(key: String, value: SMCValue) throws { + var bytes = value.bytes + if bytes.count < 32 { + bytes.append(contentsOf: Array(repeating: 0, count: 32 - bytes.count)) + } + + var input = SMCParam() + input.key = fourCC(key) + input.keyInfo.dataSize = value.dataSize + input.data8 = smcCmdWriteBytes + input.bytes = bytesTuple(bytes) + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + guard result == kIOReturnSuccess, output.result == 0 else { + throw SMCHelperError.writeFailed(key, result) + } + } + + func hasKey(_ key: String) -> Bool { + let keyCode = fourCC(key) + if keyInfoCache[keyCode] != nil { return true } + + var input = SMCParam() + input.key = keyCode + input.data8 = smcCmdReadKeyInfo + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + return result == kIOReturnSuccess && output.result == 0 && output.keyInfo.dataSize > 0 + } + + private func keyInfo(for key: String, keyCode: UInt32) throws -> SMCKeyInfo { + if let cached = keyInfoCache[keyCode] { + return cached + } + + var input = SMCParam() + input.key = keyCode + input.data8 = smcCmdReadKeyInfo + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + guard result == kIOReturnSuccess, output.result == 0 else { + throw SMCHelperError.keyInfoFailed(key, result) + } + guard output.keyInfo.dataSize > 0, output.keyInfo.dataSize <= 32 else { + throw SMCHelperError.invalidKeyInfo(key) + } + + keyInfoCache[keyCode] = output.keyInfo + return output.keyInfo + } +} + +// MARK: - Charge Inhibit Logic + +/// Charge-inhibit keys that are written as a paired set on Apple Silicon. +/// Both must flip together; community-standard pattern from AlDente / batt. +private let appleSiliconInhibitKeys = ["CH0B", "CH0C"] +/// Newer charging-control key seen on macOS 26 ("Tahoe") firmware. +private let tahoeChargingKey = "CHTE" +/// Newer adapter-control key seen on macOS 26 ("Tahoe") firmware. +private let tahoeAdapterKey = "CHIE" +/// Intel-only persistent charge ceiling key. +private let intelCeilingKey = "BCLM" +/// Force-discharge key (drains battery even while plugged in). +private let forceDischargeKey = "CH0I" +/// Secondary adapter-control key found on legacy force-discharge firmware. +private let secondaryForceDischargeKey = "CH0J" +/// MagSafe LED color hint (optional cosmetic). +private let magSafeLEDKey = "ACLC" +private let writeMaxAttempts = 3 +private let writeInitialBackoff: TimeInterval = 0.05 +private let writeVerifyReads = 6 +private let writeVerifyInterval: TimeInterval = 0.2 + +private struct Capabilities { + var hasCHTE: Bool + var hasCH0BC: Bool + var hasBCLM: Bool + var hasCH0I: Bool + + var canInhibit: Bool { hasCHTE || hasCH0BC || hasBCLM } +} + +private func probeCapabilities(connection: SMCConnection) -> Capabilities { + Capabilities( + hasCHTE: connection.hasKey(tahoeChargingKey), + hasCH0BC: appleSiliconInhibitKeys.allSatisfy { connection.hasKey($0) }, + hasBCLM: connection.hasKey(intelCeilingKey), + hasCH0I: connection.hasKey(forceDischargeKey) + ) +} + +/// Write bytes to an SMC key and verify the firmware applied the value. +/// Some Macs report the old value for a short window after a successful write. +private func writeBytes(_ bytes: [UInt8], key: String, connection: SMCConnection) throws { + let original = try connection.readValue(key: key) + let dataSize = max(1, Int(original.dataSize)) + guard bytes.count <= dataSize else { + throw SMCHelperError.invalidKeyInfo(key) + } + + let payload = bytes + Array(repeating: UInt8(0), count: dataSize - bytes.count) + if Array(original.bytes.prefix(dataSize)) == payload { + return + } + + var writeValue = original + writeValue.bytes = payload + Array(repeating: UInt8(0), count: max(0, 32 - payload.count)) + writeValue.dataSize = UInt32(dataSize) + var actual: [UInt8] = [] + var lastError: Error? + var retryBackoff = writeInitialBackoff + + for writeAttempt in 1...writeMaxAttempts { + do { + try connection.writeValue(key: key, value: writeValue) + + for verifyAttempt in 1...writeVerifyReads { + let readBack = try connection.readValue(key: key) + actual = Array(readBack.bytes.prefix(dataSize)) + if actual == payload { + return + } + if verifyAttempt < writeVerifyReads { + Thread.sleep(forTimeInterval: writeVerifyInterval) + } + } + + lastError = SMCHelperError.writeVerificationFailed(key, expected: payload, actual: actual) + } catch { + lastError = error + } + + if writeAttempt < writeMaxAttempts, retryBackoff > 0 { + Thread.sleep(forTimeInterval: retryBackoff) + retryBackoff *= 2 + } + } + + throw lastError ?? SMCHelperError.writeVerificationFailed(key, expected: payload, actual: actual) +} + +/// Write a single byte to a 1-byte SMC key. +private func writeByte(_ byte: UInt8, key: String, connection: SMCConnection) throws { + try writeBytes([byte], key: key, connection: connection) +} + +/// Older helper builds incorrectly used CHIE as a charging-control key. CHIE is +/// adapter control on Tahoe firmware, so clear it whenever we touch charging +/// state to avoid carrying forward a stale adapter setting after upgrade. +@discardableResult +private func clearStaleAdapterControl(connection: SMCConnection) -> Bool { + guard connection.hasKey(tahoeAdapterKey) else { return false } + return (try? writeByte(0x00, key: tahoeAdapterKey, connection: connection)) != nil +} + +private func writeForceDischarge(_ on: Bool, connection: SMCConnection) throws { + let byte: UInt8 = on ? 0x01 : 0x00 + let keys = [forceDischargeKey] + + (connection.hasKey(secondaryForceDischargeKey) ? [secondaryForceDischargeKey] : []) + var lastError: Error? + + for key in keys { + do { + try writeByte(byte, key: key, connection: connection) + } catch { + lastError = error + } + } + + if let lastError { + throw lastError + } +} + +/// Inhibit charging across all supported key families on this Mac. +/// Prefer BCLM when a limit is supplied because it behaves like a soft ceiling +/// and avoids hard adapter renegotiation on USB-C display power chains. +/// If no BCLM ceiling is available, fall back to Apple Silicon hard-inhibit +/// keys or Intel's blocking BCLM value for older firmware. +private func inhibitCharging(limit: Int?, connection: SMCConnection) throws { + clearStaleAdapterControl(connection: connection) + + let caps = probeCapabilities(connection: connection) + guard caps.canInhibit else { throw SMCHelperError.noWritableInhibitKey } + + if let limit, caps.hasBCLM { + let bclmValue = UInt8(clamping: max(0, min(100, limit))) + if (try? writeByte(bclmValue, key: intelCeilingKey, connection: connection)) != nil { + return + } + } + + var anyOK = false + if caps.hasCHTE { + if (try? writeBytes([0x01, 0x00, 0x00, 0x00], key: tahoeChargingKey, connection: connection)) != nil { + anyOK = true + } + } + if caps.hasCH0BC { + var allPairOK = true + for key in appleSiliconInhibitKeys { + if (try? writeByte(0x02, key: key, connection: connection)) == nil { + allPairOK = false + } + } + if allPairOK { anyOK = true } + } + if caps.hasBCLM { + let bclmValue = UInt8(clamping: max(0, min(100, limit ?? 0))) + if (try? writeByte(bclmValue, key: intelCeilingKey, connection: connection)) != nil { + anyOK = true + } + } + if !anyOK { + throw SMCHelperError.noWritableInhibitKey + } +} + +/// Resume charging — clear all charge-inhibit keys. +private func resumeCharging(connection: SMCConnection) throws { + let caps = probeCapabilities(connection: connection) + + var anyOK = clearStaleAdapterControl(connection: connection) + if caps.hasCHTE { + if (try? writeBytes([0x00, 0x00, 0x00, 0x00], key: tahoeChargingKey, connection: connection)) != nil { + anyOK = true + } + } + if caps.hasCH0BC { + var allPairOK = true + for key in appleSiliconInhibitKeys { + if (try? writeByte(0x00, key: key, connection: connection)) == nil { + allPairOK = false + } + } + if allPairOK { anyOK = true } + } + if caps.hasBCLM { + if (try? writeByte(100, key: intelCeilingKey, connection: connection)) != nil { + anyOK = true + } + } + // Stop any force-discharge as part of resume. + if caps.hasCH0I { + _ = try? writeForceDischarge(false, connection: connection) + } + if !anyOK { + throw SMCHelperError.noWritableInhibitKey + } +} + +private func setForceDischarge(_ on: Bool, connection: SMCConnection) throws { + let caps = probeCapabilities(connection: connection) + guard caps.hasCH0I else { throw SMCHelperError.noWritableInhibitKey } + try writeForceDischarge(on, connection: connection) +} + +// MARK: - JSON Output Helpers + +private func printProbe(connection: SMCConnection) { + let caps = probeCapabilities(connection: connection) + let lines = [ + "{", + " \"CHTE\": \(caps.hasCHTE),", + " \"CH0B_CH0C\": \(caps.hasCH0BC),", + " \"BCLM\": \(caps.hasBCLM),", + " \"CH0I\": \(caps.hasCH0I)", + "}" + ] + print(lines.joined(separator: "\n")) +} + +private func printRead(key: String, connection: SMCConnection) throws { + let value = try connection.readValue(key: key) + let hex = hexString(Array(value.bytes.prefix(Int(value.dataSize)))) + print("Key: \(key)") + print("Type: \(fourCCString(value.dataType))") + print("Size: \(value.dataSize)") + print("Bytes: \(hex)") + if value.dataSize >= 1 { + print("Byte0: \(value.bytes[0])") + } +} + +// MARK: - Utilities + +private func hexString(_ bytes: [UInt8]) -> String { + bytes.map { String(format: "%02x", $0) }.joined() +} + +private func fourCC(_ value: String) -> UInt32 { + var result: UInt32 = 0 + for (index, byte) in value.utf8.prefix(4).enumerated() { + result |= UInt32(byte) << (8 * (3 - index)) + } + return result +} + +private func fourCCString(_ value: UInt32) -> String { + let bytes = [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff) + ] + return String(bytes: bytes, encoding: .ascii) ?? "\(value)" +} + +private func byteArray(_ bytes: SMCBytes) -> [UInt8] { + [ + bytes.0, bytes.1, bytes.2, bytes.3, + bytes.4, bytes.5, bytes.6, bytes.7, + bytes.8, bytes.9, bytes.10, bytes.11, + bytes.12, bytes.13, bytes.14, bytes.15, + bytes.16, bytes.17, bytes.18, bytes.19, + bytes.20, bytes.21, bytes.22, bytes.23, + bytes.24, bytes.25, bytes.26, bytes.27, + bytes.28, bytes.29, bytes.30, bytes.31 + ] +} + +private func bytesTuple(_ bytes: [UInt8]) -> SMCBytes { + ( + bytes[safe: 0], bytes[safe: 1], bytes[safe: 2], bytes[safe: 3], + bytes[safe: 4], bytes[safe: 5], bytes[safe: 6], bytes[safe: 7], + bytes[safe: 8], bytes[safe: 9], bytes[safe: 10], bytes[safe: 11], + bytes[safe: 12], bytes[safe: 13], bytes[safe: 14], bytes[safe: 15], + bytes[safe: 16], bytes[safe: 17], bytes[safe: 18], bytes[safe: 19], + bytes[safe: 20], bytes[safe: 21], bytes[safe: 22], bytes[safe: 23], + bytes[safe: 24], bytes[safe: 25], bytes[safe: 26], bytes[safe: 27], + bytes[safe: 28], bytes[safe: 29], bytes[safe: 30], bytes[safe: 31] + ) +} + +private extension Array where Element == UInt8 { + subscript(safe index: Int) -> UInt8 { + indices.contains(index) ? self[index] : 0 + } +} + +// MARK: - Entry Point + +private func usage() { + let program = (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "mactools-battery-smc-helper" + print("MacTools Battery SMC Helper") + print("Usage:") + print(" \(program) probe") + print(" \(program) inhibit []") + print(" \(program) resume") + print(" \(program) discharge on|off") + print(" \(program) read ") +} + +private func run() throws { + let arguments = CommandLine.arguments + guard arguments.count >= 2 else { + usage() + throw SMCHelperError.invalidArguments + } + + let connection = try SMCConnection() + + switch arguments[1] { + case "probe": + printProbe(connection: connection) + + case "inhibit": + let limit: Int? = arguments.count >= 3 ? Int(arguments[2]) : nil + try inhibitCharging(limit: limit, connection: connection) + + case "resume": + try resumeCharging(connection: connection) + + case "discharge": + guard arguments.count >= 3 else { throw SMCHelperError.invalidArguments } + switch arguments[2] { + case "on": try setForceDischarge(true, connection: connection) + case "off": try setForceDischarge(false, connection: connection) + default: throw SMCHelperError.invalidArguments + } + + case "read": + guard arguments.count >= 3 else { throw SMCHelperError.invalidArguments } + try printRead(key: arguments[2], connection: connection) + + default: + usage() + throw SMCHelperError.invalidArguments + } +} + +do { + try run() +} catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitLocalization.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitLocalization.swift new file mode 100644 index 0000000..f3c569d --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitLocalization.swift @@ -0,0 +1,6 @@ +import Foundation +import MacToolsPluginKit + +enum BatteryChargeLimitLocalization { + static let fallback = PluginLocalization(bundle: .main) +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitLog.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitLog.swift new file mode 100644 index 0000000..7046400 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitLog.swift @@ -0,0 +1,7 @@ +import OSLog + +enum BatteryChargeLimitLog { + static let plugin = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "BatteryChargeLimitPlugin") + static let reader = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "BatteryChargeLimitReader") + static let writer = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "BatteryChargeLimitWriter") +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitModels.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitModels.swift new file mode 100644 index 0000000..d7b031a --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitModels.swift @@ -0,0 +1,183 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - Charge Mode +// +// The user-facing mode. Once enabled, the plugin always sits in one of these +// three modes. Mode transitions are explicit (user action) except for the +// auto-fallback from `.charging` / `.discharging` back to `.holdAtLimit` when +// the battery reaches the configured limit. + +enum BatteryChargeMode: String, Codable, Equatable { + /// Charging is inhibited at the SMC level. This is the default whenever + /// the plugin is enabled. Crucially: even if the battery is BELOW the + /// limit, charging stays inhibited until the user explicitly resumes. + case holdAtLimit + /// User explicitly resumed charging. The plugin will auto-transition back + /// to `.holdAtLimit` once the battery reaches the configured limit. + case charging + /// Force-discharge via CH0I. The plugin will auto-transition back to + /// `.holdAtLimit` once the battery falls to (or below) the configured limit. + case discharging +} + +// MARK: - Limit Range + +enum BatteryChargeLimits { + static let minimumPercent = 20 + static let maximumPercent = 100 + static let defaultPercent = 80 + static let percentStep = 1 +} + +// MARK: - SMC Capabilities (reported by helper `probe`) + +struct BatterySMCCapabilities: Equatable { + var hasCHTE: Bool + var hasCH0BC: Bool + var hasBCLM: Bool + var hasCH0I: Bool + + /// True when we have at least one writable charge-inhibit key family. + var canInhibit: Bool { hasCHTE || hasCH0BC || hasBCLM } + /// True when force-discharge (CH0I) is available on this hardware. + var canForceDischarge: Bool { hasCH0I } + /// True when the only inhibit path is Intel's BCLM (soft ceiling that + /// auto-resumes once battery drops below limit). The plugin surfaces a + /// caveat in the UI in this case. + var isBCLMOnly: Bool { hasBCLM && !hasCHTE && !hasCH0BC } + + static let none = BatterySMCCapabilities( + hasCHTE: false, hasCH0BC: false, hasBCLM: false, hasCH0I: false + ) +} + +// MARK: - Battery Snapshot + +enum BatteryPowerState: Equatable { + /// No internal battery (e.g., Mac mini / Mac Studio). Plugin is hidden. + case unavailable + /// Battery is currently charging (kIOPSIsChargingKey == true). + case charging + /// Battery is full and AC-connected. + case charged + /// On adapter but not actively charging — either at limit or inhibited. + case acPower + /// Running on battery (no adapter). + case unplugged + /// Unknown/error. + case unknown +} + +struct BatterySnapshot: Equatable { + var isAvailable: Bool + /// Battery level as a percentage (0–100). nil when unavailable. + var levelPercent: Int? + var state: BatteryPowerState + /// True when the AC adapter is connected (drawing external power). + var isOnAdapter: Bool + + var hasBattery: Bool { isAvailable } + + static let empty = BatterySnapshot( + isAvailable: false, + levelPercent: nil, + state: .unavailable, + isOnAdapter: false + ) +} + +// MARK: - Write Errors + +enum BatteryChargeWriteError: Error, LocalizedError, Equatable { + case helperNotFound + case helperInstallFailed(BatteryChargeHelperInstallFailure) + case helperVerificationFailed + case noSupportedSMCKey + case writeFailed(BatteryChargeWriteFailure) + + var errorDescription: String? { + localizedDescription(localization: BatteryChargeLimitLocalization.fallback) + } + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case .helperNotFound: + return localization.string( + "error.helperNotFound", + defaultValue: "未找到内置电池控制组件。请重新安装电池充电上限插件。" + ) + case .helperInstallFailed(let failure): + return localization.format( + "error.helperInstallFailed", + defaultValue: "安装电池控制组件失败:%@", + failure.localizedDescription(localization: localization) + ) + case .helperVerificationFailed: + return localization.string( + "error.helperVerificationFailed", + defaultValue: "电池控制组件校验失败。请重新安装电池充电上限插件。" + ) + case .noSupportedSMCKey: + return localization.string( + "error.noSupportedSMCKey", + defaultValue: "当前 Mac 固件未提供可写的充电控制键。可能是 macOS 已升级到不支持的版本。" + ) + case .writeFailed(let failure): + return localization.format( + "error.writeFailed", + defaultValue: "写入充电控制失败:%@", + failure.localizedDescription(localization: localization) + ) + } + } +} + +enum BatteryChargeHelperInstallFailure: Equatable { + case missingAfterInstall + case authorizationScriptUnavailable + case systemMessage(String) + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case .missingAfterInstall: + localization.string("error.helperInstall.missingAfterInstall", defaultValue: "安装后仍无法找到组件") + case .authorizationScriptUnavailable: + localization.string("error.helperInstall.authorizationScriptUnavailable", defaultValue: "无法创建授权脚本") + case .systemMessage(let message): + message + } + } +} + +enum BatteryChargeWriteFailure: Equatable { + case inhibitCharging + case resumeCharging + case toggleDischarge + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case .inhibitCharging: + localization.string("error.write.inhibitCharging", defaultValue: "无法停止充电") + case .resumeCharging: + localization.string("error.write.resumeCharging", defaultValue: "无法恢复充电") + case .toggleDischarge: + localization.string("error.write.toggleDischarge", defaultValue: "无法切换放电模式") + } + } +} + +// MARK: - SMC Protocols (for dependency injection / tests) + +protocol BatteryChargeLimitReading: AnyObject { + @MainActor func readSnapshot() -> BatterySnapshot +} + +protocol BatteryChargeLimitWriting: AnyObject { + @MainActor var isHelperAvailable: Bool { get } + @MainActor var isInstalledHelperAvailable: Bool { get } + @MainActor func probeCapabilities() -> BatterySMCCapabilities + @MainActor @discardableResult func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError? + @MainActor @discardableResult func resumeCharging() -> BatteryChargeWriteError? + @MainActor @discardableResult func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError? +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift new file mode 100644 index 0000000..6568794 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitPlugin.swift @@ -0,0 +1,664 @@ +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +// MARK: - Bundle Factory + +public final class BatteryChargeLimitPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + BatteryChargeLimitPluginProvider(context: context) + } +} + +@MainActor +private struct BatteryChargeLimitPluginProvider: PluginProvider { + let context: PluginRuntimeContext + func makePlugins() -> [any MacToolsPlugin] { + [ + BatteryChargeLimitPlugin( + context: context, + localization: PluginLocalization(bundle: context.resourceBundle) + ), + ] + } +} + +// MARK: - Control IDs + +private enum ControlID { + static let enableAction = "battery-enable-action" + static let limitSlider = "battery-limit-slider" + static let chargeAction = "battery-charge-action" + static let dischargeAction = "battery-discharge-action" + static let manageSettings = "battery-manage-settings" + static let missingHelper = "battery-missing-helper" +} + +// MARK: - Plugin + +@MainActor +final class BatteryChargeLimitPlugin: MacToolsPlugin, PluginPrimaryPanel { + + // MARK: Metadata + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + // MARK: Callbacks + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + // MARK: State + + let store: BatteryChargeLimitStore + private let localization: PluginLocalization + private let reader: any BatteryChargeLimitReading + private let writer: any BatteryChargeLimitWriting + + private var isExpanded = false + private var batterySnapshot: BatterySnapshot = .empty + private var capabilities: BatterySMCCapabilities = .none + private var lastErrorMessage: String? + private var requiresSMCCleanup = false + private var monitoringTask: Task? + private var sleepObserver: (any NSObjectProtocol)? + private var wakeObserver: (any NSObjectProtocol)? + + // MARK: Init + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "battery-charge-limit"), + reader: any BatteryChargeLimitReading = BatteryChargeLimitReader(), + writer: (any BatteryChargeLimitWriting)? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.metadata = PluginMetadata( + id: "battery-charge-limit", + title: localization.string("metadata.title", defaultValue: "电池充电上限"), + iconName: "battery.100.bolt", + iconTint: Color(nsColor: .systemGreen), + order: 48, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "限制电池充电至指定上限" + ) + ) + self.store = BatteryChargeLimitStore(storage: context.storage) + self.reader = reader + self.writer = writer ?? BatteryChargeLimitWriter(resourceBundle: context.resourceBundle) + } + + // MARK: - Lifecycle + + func activate(context: PluginRuntimeContext) { + batterySnapshot = reader.readSnapshot() + + // Re-assert the persisted mode after app restart. SMC keys can be + // reset by firmware across sleep/hibernation, so on launch we + // re-apply whatever the user last had configured. + if store.isEnabled { + startActiveMonitoring() + applyCurrentMode(reason: "activate") + } + } + + func deactivate(reason: PluginDeactivationReason) { + stopActiveMonitoring() + if reason.requiresStateCleanup { + restoreUnrestrictedChargingIfNeeded(reason: String(describing: reason), requireInstalledHelper: true) + } + } + + func refresh() { + batterySnapshot = reader.readSnapshot() + evaluateAutoTransitions() + onStateChange?() + } + + // MARK: - PluginPrimaryPanel + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: panelSubtitle, + isOn: store.isEnabled, + isExpanded: isExpanded, + isEnabled: batterySnapshot.hasBattery, + isVisible: batterySnapshot.hasBattery, + detail: isExpanded ? buildDetail() : nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + var configuration: PluginConfiguration? { + PluginConfiguration(description: metadata.defaultDescription) { [self] _ in + BatteryChargeLimitSettingsView( + store: self.store, + capabilities: self.capabilities, + snapshot: self.batterySnapshot, + localization: self.localization + ) + } + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(expanded): + isExpanded = expanded + if !expanded { lastErrorMessage = nil } + onStateChange?() + + case let .setSlider(controlID, value, phase): + guard controlID == ControlID.limitSlider, phase == .ended else { return } + handleLimitChange(Int(value)) + + case let .invokeAction(controlID): + handleInvokeAction(controlID) + + case .setSwitch, .setSelection, .setNavigationSelection, .clearNavigationSelection, .setDate: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + // MARK: - User Actions + + private func handleEnableToggle(_ value: Bool) { + store.setEnabled(value) + if value { + // Probe capabilities lazily — the helper install prompt happens + // on first call. Surface a clear error if the hardware can't be + // inhibited. + capabilities = writer.probeCapabilities() + if !capabilities.canInhibit && writer.isHelperAvailable { + lastErrorMessage = localizedDescription(for: .noSupportedSMCKey) + store.setEnabled(false) + onStateChange?() + return + } + // Enabling always starts in holdAtLimit — the core behavior is + // "don't auto-charge; user must explicitly resume." + store.setMode(.holdAtLimit) + startActiveMonitoring() + applyCurrentMode(reason: "user-enable") + } else { + store.setMode(.holdAtLimit) + _ = restoreUnrestrictedCharging(reason: "user-disable", requireInstalledHelper: false) + stopActiveMonitoring() + lastErrorMessage = nil + } + onStateChange?() + } + + private func handleLimitChange(_ percent: Int) { + store.setLimitPercent(percent) + // Changing the limit always returns to holdAtLimit. This matches the + // user's design: the act of setting a limit means "stop charging at + // this level; don't auto-resume." + if store.isEnabled { + store.setMode(.holdAtLimit) + applyCurrentMode(reason: "limit-change") + } + onStateChange?() + } + + private func handleInvokeAction(_ controlID: String) { + switch controlID { + case ControlID.enableAction: + handleEnableToggle(!store.isEnabled) + + case ControlID.chargeAction: + handleChargeActionTap() + + case ControlID.dischargeAction: + handleDischargeActionTap() + + case ControlID.manageSettings: + // The host intercepts this action and opens the plugin's settings + // configuration page. No-op here. + break + + default: + break + } + } + + private func handleChargeActionTap() { + guard store.isEnabled else { return } + switch store.mode { + case .holdAtLimit: + // User explicitly asks to start charging. Move to .charging; the + // monitoring loop will revert to .holdAtLimit when the battery + // reaches the limit. + store.setMode(.charging) + applyCurrentMode(reason: "user-resume") + case .charging: + // User asks to stop charging — return to .holdAtLimit. + store.setMode(.holdAtLimit) + applyCurrentMode(reason: "user-stop-charging") + case .discharging: + // Treat as "stop discharging and hold at current level." + store.setMode(.holdAtLimit) + applyCurrentMode(reason: "user-stop-discharge-via-charge") + } + onStateChange?() + } + + private func handleDischargeActionTap() { + guard store.isEnabled, capabilities.canForceDischarge else { return } + switch store.mode { + case .discharging: + store.setMode(.holdAtLimit) + applyCurrentMode(reason: "user-stop-discharge") + default: + store.setMode(.discharging) + applyCurrentMode(reason: "user-start-discharge") + } + onStateChange?() + } + + // MARK: - State Application + + private func applyCurrentMode(reason: String) { + guard store.isEnabled else { + _ = restoreUnrestrictedCharging(reason: reason, requireInstalledHelper: false) + return + } + + switch store.mode { + case .holdAtLimit: + _ = setForceDischarge(false) + if let err = inhibitCharging(limitPercent: store.limitPercent) { + lastErrorMessage = localizedDescription(for: err) + BatteryChargeLimitLog.plugin.error("inhibit failed (\(reason, privacy: .public)): \(self.localizedDescription(for: err), privacy: .public)") + } else { + lastErrorMessage = nil + } + + case .charging: + if let err = restoreUnrestrictedCharging(reason: reason, requireInstalledHelper: false) { + lastErrorMessage = localizedDescription(for: err) + BatteryChargeLimitLog.plugin.error("resume failed (\(reason, privacy: .public)): \(self.localizedDescription(for: err), privacy: .public)") + } else { + lastErrorMessage = nil + } + + case .discharging: + // Force-discharge implies the inhibit keys must also be set so + // the adapter doesn't fight us by charging back up. + if let err = inhibitCharging(limitPercent: store.limitPercent) { + BatteryChargeLimitLog.plugin.error("inhibit-for-discharge failed: \(self.localizedDescription(for: err), privacy: .public)") + } + if let err = setForceDischarge(true) { + lastErrorMessage = localizedDescription(for: err) + BatteryChargeLimitLog.plugin.error("force-discharge failed (\(reason, privacy: .public)): \(self.localizedDescription(for: err), privacy: .public)") + } else { + lastErrorMessage = nil + } + } + onStateChange?() + } + + /// Automatic mode transitions driven by battery level changes. + /// Crucially, we DO NOT transition out of `.holdAtLimit` here — the user's + /// design choice is that "below limit, charging stays off until manual resume." + private func evaluateAutoTransitions() { + guard store.isEnabled, let level = batterySnapshot.levelPercent else { return } + + switch store.mode { + case .charging where level >= store.limitPercent: + store.setMode(.holdAtLimit) + applyCurrentMode(reason: "auto-reached-limit") + + case .discharging where level <= store.limitPercent: + store.setMode(.holdAtLimit) + applyCurrentMode(reason: "auto-discharged-to-limit") + + case .holdAtLimit: + // Re-assert the inhibit periodically — firmware can reset SMC + // keys across sleep, adapter unplug/replug, and rare hibernation + // events. Cheap to re-issue. + if batterySnapshot.state == .charging { + applyCurrentMode(reason: "re-assert-inhibit") + } + + default: + break + } + } + + // MARK: - Monitoring + + private func startActiveMonitoring() { + startMonitoring() + registerSleepWakeObservers() + } + + private func stopActiveMonitoring() { + unregisterSleepWakeObservers() + stopMonitoring() + } + + private func startMonitoring() { + guard monitoringTask == nil else { return } + monitoringTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + self?.batterySnapshot = self?.reader.readSnapshot() ?? .empty + self?.evaluateAutoTransitions() + self?.onStateChange?() + try? await Task.sleep(for: .seconds(5)) + } + } + } + + private func stopMonitoring() { + monitoringTask?.cancel() + monitoringTask = nil + } + + private func registerSleepWakeObservers() { + guard sleepObserver == nil, wakeObserver == nil else { return } + let center = NSWorkspace.shared.notificationCenter + sleepObserver = center.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.handleSystemWillSleep() } + } + wakeObserver = center.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.handleSystemDidWake() } + } + } + + private func unregisterSleepWakeObservers() { + let center = NSWorkspace.shared.notificationCenter + if let obs = sleepObserver { center.removeObserver(obs) } + if let obs = wakeObserver { center.removeObserver(obs) } + sleepObserver = nil + wakeObserver = nil + } + + private func handleSystemWillSleep() { + // Keep the inhibit in place on sleep so the Mac doesn't quietly + // charge past the limit while the user is away. + guard store.isEnabled else { return } + BatteryChargeLimitLog.plugin.info("System will sleep — current mode: \(String(describing: self.store.mode), privacy: .public)") + } + + private func handleSystemDidWake() { + guard store.isEnabled else { return } + batterySnapshot = reader.readSnapshot() + applyCurrentMode(reason: "did-wake") + BatteryChargeLimitLog.plugin.info("System did wake — re-asserted mode: \(String(describing: self.store.mode), privacy: .public)") + } + + // MARK: - Panel Builder + + private var panelSubtitle: String { + guard batterySnapshot.hasBattery else { + return localization.string("panel.subtitle.noBattery", defaultValue: "未检测到电池") + } + let level = batterySnapshot.levelPercent ?? 0 + + if !store.isEnabled { + return localization.format("panel.subtitle.disabled", defaultValue: "未启用 · %d%%", level) + } + + let limit = store.limitPercent + switch store.mode { + case .holdAtLimit: + if level >= limit { + return localization.format( + "panel.subtitle.limitReached", + defaultValue: "已达上限 · %d%% / %d%%", + level, + limit + ) + } + return localization.format( + "panel.subtitle.chargingStopped", + defaultValue: "已停止充电 · %d%% / %d%%", + level, + limit + ) + case .charging: + return localization.format("panel.subtitle.charging", defaultValue: "充电中 · %d%% → %d%%", level, limit) + case .discharging: + return localization.format("panel.subtitle.discharging", defaultValue: "放电中 · %d%% → %d%%", level, limit) + } + } + + private func buildDetail() -> PluginPanelDetail { + var controls: [PluginPanelControl] = [] + + // 1. Enable/disable toggle as the first row. + let enableTitle = store.isEnabled + ? localization.string("panel.action.disable", defaultValue: "停用充电上限") + : localization.string("panel.action.enable", defaultValue: "启用充电上限") + let enableIcon = store.isEnabled ? "checkmark.circle.fill" : "circle" + controls.append(PluginPanelControl( + id: ControlID.enableAction, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: enableTitle, + actionIconSystemName: enableIcon, + isEnabled: writer.isHelperAvailable + )) + + if store.isEnabled { + // 2. Limit slider — shown only when enabled + controls.append(PluginPanelControl( + id: ControlID.limitSlider, + kind: .slider, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: localization.string("panel.section.limit", defaultValue: "充电上限"), + sliderValue: Double(store.limitPercent), + sliderBounds: Double(BatteryChargeLimits.minimumPercent)...Double(BatteryChargeLimits.maximumPercent), + sliderStep: Double(BatteryChargeLimits.percentStep), + valueLabel: "\(store.limitPercent)%", + isEnabled: true + )) + + // 3. Charge/stop button — context-sensitive title and icon + let chargeTitle: String + let chargeIcon: String + switch store.mode { + case .holdAtLimit: + chargeTitle = localization.string("panel.action.startCharging", defaultValue: "开始充电") + chargeIcon = "bolt.fill" + case .charging: + chargeTitle = localization.string("panel.action.stopCharging", defaultValue: "停止充电") + chargeIcon = "bolt.slash.fill" + case .discharging: + chargeTitle = localization.string("panel.action.stopDischarging", defaultValue: "停止放电") + chargeIcon = "stop.fill" + } + controls.append(PluginPanelControl( + id: ControlID.chargeAction, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: chargeTitle, + actionIconSystemName: chargeIcon, + showsLeadingDivider: true, + isEnabled: true + )) + + // 4. Force-discharge button — only when supported AND battery is + // currently above the limit (otherwise it's a no-op). + if capabilities.canForceDischarge, + let level = batterySnapshot.levelPercent, + level > store.limitPercent + { + let title = store.mode == .discharging + ? localization.string("panel.action.stopDischarging", defaultValue: "停止放电") + : localization.format( + "panel.action.dischargeToLimit", + defaultValue: "强制放电至 %d%%", + store.limitPercent + ) + controls.append(PluginPanelControl( + id: ControlID.dischargeAction, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: title, + actionIconSystemName: "minus.circle", + isEnabled: true + )) + } + } + + // 5. Open settings page + controls.append(PluginPanelControl( + id: ControlID.manageSettings, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.settings", defaultValue: "设置…"), + actionIconSystemName: "slider.horizontal.3", + actionBehavior: .dismissBeforeHandling, + showsLeadingDivider: true, + isEnabled: true + )) + + // 6. Missing helper warning + if !writer.isHelperAvailable { + controls.append(PluginPanelControl( + id: ControlID.missingHelper, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.missingHelper", defaultValue: "电池控制组件缺失"), + actionIconSystemName: "exclamationmark.triangle", + actionBehavior: .dismissBeforeHandling, + showsLeadingDivider: true, + isEnabled: false + )) + } + + return PluginPanelDetail(primaryControls: controls, secondaryPanel: nil) + } + + private func localizedDescription(for error: BatteryChargeWriteError) -> String { + error.localizedDescription(localization: localization) + } + + @discardableResult + private func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError? { + let error = writer.inhibitCharging(limitPercent: limitPercent) + markCleanupRequiredIfNeeded(after: error) + return error + } + + @discardableResult + private func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError? { + let error = writer.setForceDischarge(on) + if on { + markCleanupRequiredIfNeeded(after: error) + } + return error + } + + private func markCleanupRequiredIfNeeded(after error: BatteryChargeWriteError?) { + if error == nil || error?.mayHaveChangedSMCState == true { + requiresSMCCleanup = true + } + } + + private func restoreUnrestrictedChargingIfNeeded(reason: String, requireInstalledHelper: Bool) { + guard requiresSMCCleanup else { + BatteryChargeLimitLog.plugin.info("Skipped SMC charge cleanup for \(reason, privacy: .public); no active charge-control state") + return + } + + _ = restoreUnrestrictedCharging(reason: reason, requireInstalledHelper: requireInstalledHelper) + } + + @discardableResult + private func restoreUnrestrictedCharging( + reason: String, + requireInstalledHelper: Bool + ) -> BatteryChargeWriteError? { + guard !requireInstalledHelper || writer.isInstalledHelperAvailable else { + BatteryChargeLimitLog.plugin.info("Skipped SMC charge cleanup for \(reason, privacy: .public); helper is not installed") + return nil + } + + let dischargeError = writer.setForceDischarge(false) + let resumeError = writer.resumeCharging() + + if resumeError == nil && dischargeError == nil { + requiresSMCCleanup = false + BatteryChargeLimitLog.plugin.info("Cleared SMC charge-control state for \(reason, privacy: .public)") + return nil + } else { + BatteryChargeLimitLog.plugin.error("Failed to fully clear SMC charge-control state for \(reason, privacy: .public)") + return dischargeError ?? resumeError + } + } +} + +private extension BatteryChargeWriteError { + var mayHaveChangedSMCState: Bool { + if case .writeFailed = self { + return true + } + + return false + } +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitReader.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitReader.swift new file mode 100644 index 0000000..5005831 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitReader.swift @@ -0,0 +1,65 @@ +import Foundation +import IOKit +import IOKit.ps + +/// Reads the current battery snapshot from IOPS. +/// Mirrors the approach used by `SystemStatusSampler.collectBattery()` but is +/// scoped to only the fields this plugin cares about (level, charging state, +/// adapter presence), so it stays decoupled from the SystemStatus plugin. +@MainActor +final class BatteryChargeLimitReader: BatteryChargeLimitReading { + + init() {} + + func readSnapshot() -> BatterySnapshot { + guard + let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let sources = IOPSCopyPowerSourcesList(info)?.takeRetainedValue() as? [CFTypeRef] + else { + return .empty + } + + var fallback: [String: Any]? + var battery: [String: Any]? + for source in sources { + guard let desc = IOPSGetPowerSourceDescription(info, source)?.takeUnretainedValue() as? [String: Any] else { continue } + fallback = fallback ?? desc + if desc[kIOPSTypeKey] as? String == kIOPSInternalBatteryType { + battery = desc + break + } + } + + guard let description = battery ?? fallback else { + return .empty + } + + let maxCapacity = max(description[kIOPSMaxCapacityKey] as? Int ?? 100, 1) + let currentCapacity = min(max(description[kIOPSCurrentCapacityKey] as? Int ?? 0, 0), maxCapacity) + let levelPercent = Int(round(Double(currentCapacity) / Double(maxCapacity) * 100.0)) + let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false + let isCharged = description[kIOPSIsChargedKey] as? Bool ?? false + let powerSource = description[kIOPSPowerSourceStateKey] as? String ?? "" + let isOnAdapter = powerSource == kIOPSACPowerValue + + let state: BatteryPowerState + if isCharged || levelPercent >= 100 { + state = .charged + } else if isCharging { + state = .charging + } else if isOnAdapter { + state = .acPower + } else if powerSource == kIOPSBatteryPowerValue { + state = .unplugged + } else { + state = .unknown + } + + return BatterySnapshot( + isAvailable: true, + levelPercent: levelPercent, + state: state, + isOnAdapter: isOnAdapter + ) + } +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitSettingsView.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitSettingsView.swift new file mode 100644 index 0000000..2399dd8 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitSettingsView.swift @@ -0,0 +1,184 @@ +import SwiftUI +import MacToolsPluginKit + +// MARK: - BatteryChargeLimitSettingsView +// +// Custom configuration view shown in the plugin's settings page. Surfaces: +// 1. A larger limit slider with explanation of the "no auto-resume" semantics +// 2. SMC capability diagnostic (which inhibit path the helper found) +// 3. A note about how this differs from macOS Optimized Battery Charging + +struct BatteryChargeLimitSettingsView: View { + @ObservedObject var store: BatteryChargeLimitStore + var capabilities: BatterySMCCapabilities + var snapshot: BatterySnapshot + let localization: PluginLocalization + + @State private var sliderValue: Double + + init( + store: BatteryChargeLimitStore, + capabilities: BatterySMCCapabilities, + snapshot: BatterySnapshot, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.store = store + self.capabilities = capabilities + self.snapshot = snapshot + self.localization = localization + _sliderValue = State(initialValue: Double(store.limitPercent)) + } + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + limitSection + behaviorSection + compatibilitySection + } + .onChange(of: store.limitPercent) { _, newValue in + sliderValue = Double(newValue) + } + } + + // MARK: - Sections + + private var limitSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader(title: localization.string("settings.limit.title", defaultValue: "充电上限"), icon: "battery.75") + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.controlCluster) { + HStack(alignment: .firstTextBaseline) { + Text(localization.string("settings.limit.target", defaultValue: "目标电量")) + .font(PluginSettingsTheme.Typography.rowTitle) + Spacer() + Text("\(Int(sliderValue))%") + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .frame(minWidth: 36, alignment: .trailing) + } + + Slider( + value: $sliderValue, + in: Double(BatteryChargeLimits.minimumPercent)...Double(BatteryChargeLimits.maximumPercent), + step: Double(BatteryChargeLimits.percentStep), + onEditingChanged: { editing in + if !editing { + store.setLimitPercent(Int(sliderValue)) + } + } + ) + .controlSize(.small) + + Text(localization.string("settings.limit.description", defaultValue: "达到此电量后自动停止充电。")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + .pluginSettingsListRowPadding(interactive: true) + .pluginSettingsCardBackground(.host) + } + } + + private var behaviorSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader( + title: localization.string("settings.behavior.title", defaultValue: "充电行为"), + icon: "bolt.badge.checkmark" + ) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("settings.behavior.noAutoResume.title", defaultValue: "不自动恢复充电")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text( + localization.string( + "settings.behavior.noAutoResume.description", + defaultValue: "电量低于上限时不会自动充电,需要在菜单栏点击「开始充电」才会继续。这与系统自带的「优化电池充电」不同——系统会持续微充电以贴近上限。" + ) + ) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .pluginSettingsListRowPadding() + .pluginSettingsCardBackground(.host) + } + } + + private var compatibilitySection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader(title: localization.string("settings.compatibility.title", defaultValue: "硬件兼容"), icon: "cpu") + + VStack(alignment: .leading, spacing: 0) { + compatibilityRow( + title: localization.string("settings.compatibility.controlMethod", defaultValue: "充电控制方式"), + detail: capabilityDescription + ) + + if capabilities.canForceDischarge { + PluginSettingsListDivider() + compatibilityRow( + title: localization.string("settings.compatibility.forceDischarge", defaultValue: "强制放电"), + detail: localization.string("settings.compatibility.forceDischarge.supported", defaultValue: "支持(CH0I)") + ) + } + + if capabilities.isBCLMOnly { + PluginSettingsListDivider() + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Label( + localization.string("settings.compatibility.intelLimit.title", defaultValue: "Intel Mac 限制"), + systemImage: "exclamationmark.triangle" + ) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .foregroundStyle(.orange) + Text( + localization.string( + "settings.compatibility.intelLimit.description", + defaultValue: "当前 Mac 仅支持 BCLM,电量低于上限时仍可能被系统自动充至上限。「不自动恢复」语义在 Intel Mac 上无法保证。" + ) + ) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .pluginSettingsListRowPadding() + } + } + .pluginSettingsCardBackground(.host) + } + } + + // MARK: - Helpers + + private func sectionHeader(title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + } + + private func compatibilityRow(title: String, detail: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Spacer() + Text(detail) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + .pluginSettingsListRowPadding() + } + + private var capabilityDescription: String { + if capabilities.hasCHTE { + return "CHTE (macOS 26+)" + } + if capabilities.hasCH0BC { + return "CH0B + CH0C (Apple Silicon)" + } + if capabilities.hasBCLM { + return "BCLM (Intel)" + } + return localization.string("settings.compatibility.noSMCKey", defaultValue: "未检测到可用的 SMC 充电控制键") + } +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift new file mode 100644 index 0000000..65b3ae3 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitStore.swift @@ -0,0 +1,68 @@ +import Foundation +import MacToolsPluginKit + +/// Persists user-controlled state for the battery charge-limit plugin: +/// whether it's enabled, the charge ceiling, and the last mode. +@MainActor +final class BatteryChargeLimitStore: ObservableObject { + private enum Key { + static let isEnabled = "is-enabled" + static let limitPercent = "limit-percent" + static let mode = "mode" + } + + private let storage: PluginStorage + + @Published private(set) var isEnabled: Bool + @Published private(set) var limitPercent: Int + @Published private(set) var mode: BatteryChargeMode + + init(storage: PluginStorage) { + self.storage = storage + + // isEnabled — default off; user must opt in. We store the inverse of + // "explicitly disabled" so the first launch is a clean disabled state. + let storedIsEnabled = storage.object(forKey: Key.isEnabled) as? Bool + self.isEnabled = storedIsEnabled ?? false + + // limitPercent — clamp to the supported range. + let storedLimit = storage.object(forKey: Key.limitPercent) as? Int + let initial = storedLimit ?? BatteryChargeLimits.defaultPercent + self.limitPercent = max( + BatteryChargeLimits.minimumPercent, + min(BatteryChargeLimits.maximumPercent, initial) + ) + + // mode — default to .holdAtLimit (the plugin's main mode). + if let raw = storage.string(forKey: Key.mode), + let parsed = BatteryChargeMode(rawValue: raw) { + self.mode = parsed + } else { + self.mode = .holdAtLimit + } + } + + // MARK: - Mutators + + func setEnabled(_ value: Bool) { + guard isEnabled != value else { return } + isEnabled = value + storage.set(value, forKey: Key.isEnabled) + } + + func setLimitPercent(_ value: Int) { + let clamped = max( + BatteryChargeLimits.minimumPercent, + min(BatteryChargeLimits.maximumPercent, value) + ) + guard limitPercent != clamped else { return } + limitPercent = clamped + storage.set(clamped, forKey: Key.limitPercent) + } + + func setMode(_ value: BatteryChargeMode) { + guard mode != value else { return } + mode = value + storage.set(value.rawValue, forKey: Key.mode) + } +} diff --git a/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitWriter.swift b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitWriter.swift new file mode 100644 index 0000000..9fd6913 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Sources/BatteryChargeLimitWriter.swift @@ -0,0 +1,281 @@ +import Foundation + +/// Writes battery charge-control SMC values via the bundled privileged helper. +/// Follows the same setuid-helper + AppleScript install pattern used by +/// `FanControlSMCWriter`, with helper-side handling of CHTE / CH0B+CH0C / +/// BCLM key probing and CH0I force-discharge. +@MainActor +final class BatteryChargeLimitWriter: BatteryChargeLimitWriting { + private enum Helper { + static let bundledName = "mactools-battery-smc-helper" + static let bundledSubdirectory = "SMCHelper" + static let installPath = "/Library/PrivilegedHelperTools/cc.ggbond.mactools.battery-charge-limit.smc-helper" + static let installDirectory = "/Library/PrivilegedHelperTools" + } + + private let fileManager: FileManager + private let resourceBundle: Bundle + + private var resolvedHelperPath: String? + private var cachedCapabilities: BatterySMCCapabilities? + + init(resourceBundle: Bundle = .main, fileManager: FileManager = .default) { + self.resourceBundle = resourceBundle + self.fileManager = fileManager + } + + // MARK: - Public API + + var isHelperAvailable: Bool { bundledHelperURL != nil } + + var isInstalledHelperAvailable: Bool { + guard let bundledHelperURL else { + return false + } + + return installedHelperPath(matching: bundledHelperURL) != nil + } + + func probeCapabilities() -> BatterySMCCapabilities { + if let cached = cachedCapabilities { return cached } + + let resolvedPath: String + switch helperPath() { + case .success(let p): resolvedPath = p + case .failure: return .none + } + + let output = runHelperCapturingOutput(path: resolvedPath, args: ["probe"]) + guard let json = output, let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] + else { + return .none + } + + let caps = BatterySMCCapabilities( + hasCHTE: dict["CHTE"] ?? false, + hasCH0BC: dict["CH0B_CH0C"] ?? false, + hasBCLM: dict["BCLM"] ?? false, + hasCH0I: dict["CH0I"] ?? false + ) + cachedCapabilities = caps + return caps + } + + @discardableResult + func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError? { + let path: String + switch helperPath() { + case .success(let p): path = p + case .failure(let e): return e + } + let clamped = max(BatteryChargeLimits.minimumPercent, min(BatteryChargeLimits.maximumPercent, limitPercent)) + if !runHelper(path: path, args: ["inhibit", "\(clamped)"]) { + return .writeFailed(.inhibitCharging) + } + return nil + } + + @discardableResult + func resumeCharging() -> BatteryChargeWriteError? { + let path: String + switch helperPath() { + case .success(let p): path = p + case .failure(let e): return e + } + if !runHelper(path: path, args: ["resume"]) { + return .writeFailed(.resumeCharging) + } + return nil + } + + @discardableResult + func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError? { + let path: String + switch helperPath() { + case .success(let p): path = p + case .failure(let e): return e + } + if !runHelper(path: path, args: ["discharge", on ? "on" : "off"]) { + return .writeFailed(.toggleDischarge) + } + return nil + } + + // MARK: - Helper install / verification + + private var bundledHelperURL: URL? { + resourceBundle.url( + forResource: Helper.bundledName, + withExtension: nil, + subdirectory: Helper.bundledSubdirectory + ) + } + + private func helperPath() -> Result { + guard let bundledHelperURL else { return .failure(.helperNotFound) } + guard verifyBundleContainingBundledHelper(bundledHelperURL) else { + return .failure(.helperVerificationFailed) + } + if let installed = installedHelperPath(matching: bundledHelperURL) { + return .success(installed) + } + if let err = installBundledHelper(from: bundledHelperURL) { + return .failure(err) + } + guard let installed = installedHelperPath(matching: bundledHelperURL) else { + return .failure(.helperInstallFailed(.missingAfterInstall)) + } + return .success(installed) + } + + private func installedHelperPath(matching bundledHelperURL: URL) -> String? { + if let cached = resolvedHelperPath, + isExecutable(at: cached), + installedHelperMatchesBundled(installedPath: cached, bundledURL: bundledHelperURL) { + return cached + } + guard isExecutable(at: Helper.installPath), + installedHelperMatchesBundled(installedPath: Helper.installPath, bundledURL: bundledHelperURL) + else { + return nil + } + resolvedHelperPath = Helper.installPath + return Helper.installPath + } + + private func isExecutable(at path: String) -> Bool { + fileManager.isExecutableFile(atPath: path) + } + + private func installedHelperMatchesBundled(installedPath: String, bundledURL: URL) -> Bool { + guard let installedAttrs = try? fileManager.attributesOfItem(atPath: installedPath), + let installedSize = installedAttrs[.size] as? NSNumber, + let installedModifiedAt = installedAttrs[.modificationDate] as? Date, + let bundledAttrs = try? fileManager.attributesOfItem(atPath: bundledURL.path), + let bundledSize = bundledAttrs[.size] as? NSNumber, + let bundledModifiedAt = bundledAttrs[.modificationDate] as? Date, + let installedData = try? Data(contentsOf: URL(fileURLWithPath: installedPath)), + let bundledData = try? Data(contentsOf: bundledURL) + else { + return false + } + guard installedSize == bundledSize else { return false } + return installedModifiedAt.timeIntervalSince(bundledModifiedAt) >= -1 + && installedData == bundledData + } + + private func verifyBundleContainingBundledHelper(_ helperURL: URL) -> Bool { + var current = helperURL.deletingLastPathComponent() + while current.path != "/" { + if current.pathExtension == "bundle" { + return verifyCodeSignature(at: current) + } + current.deleteLastPathComponent() + } + return false + } + + private func verifyCodeSignature(at url: URL) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + task.arguments = ["--verify", "--strict", "--deep", url.path] + task.standardOutput = Pipe() + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + BatteryChargeLimitLog.writer.error("codesign verification failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func installBundledHelper(from sourceURL: URL) -> BatteryChargeWriteError? { + guard isExecutable(at: sourceURL.path) else { return .helperNotFound } + + let command = [ + "/bin/mkdir -p \(shellQuoted(Helper.installDirectory))", + "/usr/bin/install -o root -g wheel -m 4755 \(shellQuoted(sourceURL.path)) \(shellQuoted(Helper.installPath))", + "/usr/bin/touch -r \(shellQuoted(sourceURL.path)) \(shellQuoted(Helper.installPath))", + "/bin/chmod 4755 \(shellQuoted(Helper.installPath))" + ].joined(separator: " && ") + + let script = "do shell script \"\(appleScriptEscaped(command))\" with administrator privileges" + var appleScriptError: NSDictionary? + guard let scriptObject = NSAppleScript(source: script) else { + return .helperInstallFailed(.authorizationScriptUnavailable) + } + + _ = scriptObject.executeAndReturnError(&appleScriptError) + if let appleScriptError { + let message = appleScriptError["NSAppleScriptErrorMessage"] as? String + ?? appleScriptError.description + BatteryChargeLimitLog.writer.error("Battery SMC helper install failed: \(message, privacy: .public)") + return .helperInstallFailed(.systemMessage(message)) + } + + resolvedHelperPath = nil + return nil + } + + @discardableResult + private func runHelper(path: String, args: [String]) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = args + task.environment = ["LANG": "C"] + let errorPipe = Pipe() + task.standardError = errorPipe + + do { + try task.run() + task.waitUntilExit() + if task.terminationStatus == 0 { return true } + + let data = errorPipe.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + BatteryChargeLimitLog.writer.error( + "battery-smc-helper failed with status \(task.terminationStatus): \(message ?? "unknown", privacy: .public)" + ) + } catch { + BatteryChargeLimitLog.writer.error("battery-smc-helper launch failed: \(error.localizedDescription, privacy: .public)") + } + + return false + } + + private func runHelperCapturingOutput(path: String, args: [String]) -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = args + task.environment = ["LANG": "C"] + let outputPipe = Pipe() + task.standardOutput = outputPipe + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + guard task.terminationStatus == 0 else { return nil } + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + BatteryChargeLimitLog.writer.error("battery-smc-helper probe failed: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } + + private func appleScriptEscaped(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift b/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift new file mode 100644 index 0000000..6959f48 --- /dev/null +++ b/Plugins/BatteryChargeLimit/Tests/BatteryChargeLimitPluginTests.swift @@ -0,0 +1,383 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import BatteryChargeLimitPlugin + +// MARK: - Mocks + +@MainActor +private final class MockBatteryReader: BatteryChargeLimitReading { + var snapshot: BatterySnapshot + private(set) var readCount = 0 + + init(snapshot: BatterySnapshot = .empty) { + self.snapshot = snapshot + } + + func readSnapshot() -> BatterySnapshot { + readCount += 1 + return snapshot + } +} + +@MainActor +private final class MockBatteryWriter: BatteryChargeLimitWriting { + var isHelperAvailable: Bool + var isInstalledHelperAvailable: Bool + var capabilities: BatterySMCCapabilities + var inhibitCalls: [Int] = [] + var resumeCalls: Int = 0 + var dischargeCalls: [Bool] = [] + var nextError: BatteryChargeWriteError? + + init( + isHelperAvailable: Bool = true, + isInstalledHelperAvailable: Bool = true, + capabilities: BatterySMCCapabilities = BatterySMCCapabilities( + hasCHTE: false, hasCH0BC: true, hasBCLM: false, hasCH0I: true + ) + ) { + self.isHelperAvailable = isHelperAvailable + self.isInstalledHelperAvailable = isInstalledHelperAvailable + self.capabilities = capabilities + } + + func probeCapabilities() -> BatterySMCCapabilities { capabilities } + + @discardableResult + func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError? { + inhibitCalls.append(limitPercent) + return nextError + } + + @discardableResult + func resumeCharging() -> BatteryChargeWriteError? { + resumeCalls += 1 + return nextError + } + + @discardableResult + func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError? { + dischargeCalls.append(on) + return nextError + } +} + +// MARK: - Tests + +@MainActor +final class BatteryChargeLimitPluginTests: XCTestCase { + + // MARK: Metadata + + func testMetadataIdentifiesBatteryChargeLimitPlugin() { + let plugin = makePlugin() + + XCTAssertEqual(plugin.metadata.id, "battery-charge-limit") + XCTAssertEqual(plugin.metadata.title, "电池充电上限") + } + + func testControlStyleIsDisclosure() { + let plugin = makePlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .disclosure) + } + + // MARK: Visibility + + func testPanelHiddenWhenNoBattery() { + let plugin = makePlugin(reader: MockBatteryReader(snapshot: .empty)) + + XCTAssertFalse(plugin.primaryPanelState.isVisible) + } + + func testPanelVisibleWhenBatteryPresent() { + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 65))) + plugin.refresh() + + XCTAssertTrue(plugin.primaryPanelState.isVisible) + } + + // MARK: Enable / Disable + + func testEnableTogglePersistsAndInhibitsCharging() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + + XCTAssertTrue(plugin.store.isEnabled) + XCTAssertEqual(writer.inhibitCalls, [BatteryChargeLimits.defaultPercent]) + } + + func testDisableTogglePersistsAndResumesCharging() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + + XCTAssertFalse(plugin.store.isEnabled) + XCTAssertGreaterThanOrEqual(writer.resumeCalls, 1) + } + + func testEnableWithUnsupportedHardwareSurfacesError() { + let writer = MockBatteryWriter(capabilities: .none) + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + + XCTAssertFalse(plugin.store.isEnabled) + XCTAssertNotNil(plugin.primaryPanelState.errorMessage) + } + + func testActivateWhenDisabledReadsOnceWithoutStartingMonitoring() async { + let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60)) + let plugin = makePlugin(reader: reader) + + plugin.activate(context: PluginRuntimeContext(pluginID: "battery-charge-limit")) + await Task.yield() + try? await Task.sleep(for: .milliseconds(20)) + + XCTAssertEqual(reader.readCount, 1) + } + + func testActivateWhenEnabledStartsMonitoring() async { + let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60)) + let plugin = makePlugin(reader: reader) + plugin.store.setEnabled(true) + + plugin.activate(context: PluginRuntimeContext(pluginID: "battery-charge-limit")) + await Task.yield() + try? await Task.sleep(for: .milliseconds(20)) + + XCTAssertGreaterThanOrEqual(reader.readCount, 2) + plugin.deactivate(reason: .updating) + } + + func testDeactivateWithoutEnablingDoesNotWriteSMC() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.resumeCalls, 0) + XCTAssertTrue(writer.dischargeCalls.isEmpty) + } + + func testDeactivateWhenEnabledRestoresUnrestrictedCharging() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + writer.resumeCalls = 0 + writer.dischargeCalls = [] + + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.resumeCalls, 1) + XCTAssertEqual(writer.dischargeCalls, [false]) + } + + func testDeactivateWhenEnabledButHelperNotInstalledSkipsCleanup() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + writer.resumeCalls = 0 + writer.dischargeCalls = [] + writer.isInstalledHelperAvailable = false + + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.resumeCalls, 0) + XCTAssertTrue(writer.dischargeCalls.isEmpty) + } + + func testDeactivateWhileChargingDoesNotRepeatCleanup() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + plugin.handleAction(.invokeAction(controlID: "battery-charge-action")) + writer.resumeCalls = 0 + writer.dischargeCalls = [] + + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.resumeCalls, 0) + XCTAssertTrue(writer.dischargeCalls.isEmpty) + } + + func testDeactivateAfterFailedEnableDoesNotRetryCleanup() { + let writer = MockBatteryWriter(capabilities: .none) + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.resumeCalls, 0) + XCTAssertTrue(writer.dischargeCalls.isEmpty) + } + + // MARK: Limit Changes + + func testLimitSliderUpdatesPersistedLimitOnEnd() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + + plugin.handleAction(.setSlider(controlID: "battery-limit-slider", value: 70, phase: .ended)) + + XCTAssertEqual(plugin.store.limitPercent, 70) + // Limit change re-applies holdAtLimit using the new value. + XCTAssertTrue(writer.inhibitCalls.contains(70)) + } + + func testLimitSliderChangedPhaseDoesNotPersist() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + + plugin.handleAction(.setSlider(controlID: "battery-limit-slider", value: 70, phase: .changed)) + + XCTAssertEqual(plugin.store.limitPercent, BatteryChargeLimits.defaultPercent) + } + + // MARK: Mode Transitions + + func testStartChargingResumesAndTransitionsToCharging() { + let writer = MockBatteryWriter() + let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + writer.resumeCalls = 0 + + plugin.handleAction(.invokeAction(controlID: "battery-charge-action")) + + XCTAssertEqual(plugin.store.mode, .charging) + XCTAssertGreaterThanOrEqual(writer.resumeCalls, 1) + } + + func testReachingLimitWhileChargingTransitionsBackToHold() { + let writer = MockBatteryWriter() + let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60)) + let plugin = makePlugin(reader: reader, writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + plugin.handleAction(.invokeAction(controlID: "battery-charge-action")) + XCTAssertEqual(plugin.store.mode, .charging) + + // Simulate the battery reaching the configured limit. + reader.snapshot = makeSnapshot(level: BatteryChargeLimits.defaultPercent) + plugin.refresh() + + XCTAssertEqual(plugin.store.mode, .holdAtLimit) + } + + func testHoldAtLimitDoesNotAutoResumeBelowLimit() { + let writer = MockBatteryWriter() + let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60)) + let plugin = makePlugin(reader: reader, writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + writer.resumeCalls = 0 + + // Battery drops further while in holdAtLimit — mode must NOT transition + // back to .charging on its own. This is the core behavior contract. + reader.snapshot = makeSnapshot(level: 50) + plugin.refresh() + reader.snapshot = makeSnapshot(level: 40) + plugin.refresh() + + XCTAssertEqual(plugin.store.mode, .holdAtLimit) + XCTAssertEqual(writer.resumeCalls, 0, "Plugin must not call resumeCharging() while in holdAtLimit") + } + + func testForceDischargeStartsWhenSupportedAndAboveLimit() { + let writer = MockBatteryWriter() + let reader = MockBatteryReader(snapshot: makeSnapshot(level: 90)) + let plugin = makePlugin(reader: reader, writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + + plugin.handleAction(.invokeAction(controlID: "battery-discharge-action")) + + XCTAssertEqual(plugin.store.mode, .discharging) + XCTAssertTrue(writer.dischargeCalls.contains(true)) + } + + func testForceDischargeStopsWhenReachingLimit() { + let writer = MockBatteryWriter() + let reader = MockBatteryReader(snapshot: makeSnapshot(level: 90)) + let plugin = makePlugin(reader: reader, writer: writer) + plugin.refresh() + plugin.handleAction(.invokeAction(controlID: "battery-enable-action")) + plugin.handleAction(.invokeAction(controlID: "battery-discharge-action")) + + reader.snapshot = makeSnapshot(level: BatteryChargeLimits.defaultPercent) + plugin.refresh() + + XCTAssertEqual(plugin.store.mode, .holdAtLimit) + } + + // MARK: Permissions + + func testPermissionRequirementsIsEmpty() { + let plugin = makePlugin() + + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } + + func testSettingsSectionsIsEmpty() { + let plugin = makePlugin() + + XCTAssertTrue(plugin.settingsSections.isEmpty) + } + + func testShortcutDefinitionsIsEmpty() { + let plugin = makePlugin() + + XCTAssertTrue(plugin.shortcutDefinitions.isEmpty) + } + + // MARK: Host Integration + + func testPluginHostIncludesBatteryChargeLimitPlugin() { + let host = makePluginHostForTests(plugins: [makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)))]) + + XCTAssertTrue(host.featureManagementItems.contains { $0.id == "battery-charge-limit" }) + } + + // MARK: - Helpers + + private func makePlugin( + reader: MockBatteryReader? = nil, + writer: MockBatteryWriter? = nil + ) -> BatteryChargeLimitPlugin { + let suiteName = "BatteryChargeLimitPluginTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let storage = UserDefaultsPluginStorage(pluginID: "battery-charge-limit", userDefaults: defaults) + let context = PluginRuntimeContext(pluginID: "battery-charge-limit", storage: storage) + return BatteryChargeLimitPlugin( + context: context, + reader: reader ?? MockBatteryReader(), + writer: writer ?? MockBatteryWriter() + ) + } + + private func makeSnapshot(level: Int, state: BatteryPowerState = .acPower, isOnAdapter: Bool = true) -> BatterySnapshot { + BatterySnapshot( + isAvailable: true, + levelPercent: level, + state: state, + isOnAdapter: isOnAdapter + ) + } +} diff --git a/Plugins/BatteryChargeLimit/plugin.json b/Plugins/BatteryChargeLimit/plugin.json new file mode 100644 index 0000000..d1159cf --- /dev/null +++ b/Plugins/BatteryChargeLimit/plugin.json @@ -0,0 +1,72 @@ +{ + "id": "battery-charge-limit", + "displayName": "电池充电上限", + "summary": "设置电池充电上限,达到上限后停止充电;不自动恢复,由用户决定何时继续充电", + "localizedMetadata": { + "ar": { + "displayName": "حد شحن البطارية", + "summary": "تعيين حد شحن البطارية وإيقاف الشحن عند الوصول إليه؛ يتم استئناف الشحن فقط عندما تختار ذلك." + }, + "de": { + "displayName": "Batterieladelimit", + "summary": "Legen Sie ein Batterieladelimit fest und stoppen Sie den Ladevorgang, wenn es erreicht ist. Der Ladevorgang wird erst fortgesetzt, wenn Sie dies wünschen." + }, + "en": { + "displayName": "Battery Charge Limit", + "summary": "Set a battery charge limit and stop charging when it is reached; charging resumes only when you choose." + }, + "es": { + "displayName": "Límite de carga de batería", + "summary": "Establezca un límite de carga de la batería y detenga la carga cuando lo alcance; la carga se reanuda solo cuando usted elige." + }, + "fr": { + "displayName": "Limite de charge de la batterie", + "summary": "Fixez une limite de charge de la batterie et arrêtez la charge lorsqu'elle est atteinte ; la charge ne reprend que lorsque vous le souhaitez." + }, + "ja": { + "displayName": "バッテリー充電上限", + "summary": "バッテリー充電制限を設定し、その制限に達したら充電を停止します。選択した場合にのみ充電が再開されます。" + }, + "ko": { + "displayName": "배터리 충전 한도", + "summary": "배터리 충전 한도를 설정하고 한도에 도달하면 충전을 중지합니다. 선택한 경우에만 충전이 재개됩니다." + }, + "pt": { + "displayName": "Limite de carga da bateria", + "summary": "Defina um limite de carga da bateria e pare de carregar quando ele for atingido; o carregamento é retomado somente quando você escolher." + }, + "ru": { + "displayName": "Лимит заряда батареи", + "summary": "Установите лимит заряда аккумулятора и прекращайте зарядку при его достижении; зарядка возобновляется только тогда, когда вы выберете." + }, + "zh-Hans": { + "displayName": "电池充电上限", + "summary": "设置电池充电上限,达到上限后停止充电;不自动恢复,由用户决定何时继续充电" + }, + "zh-Hant": { + "displayName": "電池充電上限", + "summary": "設定電池充電上限,達到上限後停止充電;不自動恢復,由用戶決定何時繼續充電" + } + }, + "version": "1.0.5", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "BatteryChargeLimit.bundle", + "factoryClass": "BatteryChargeLimitPlugin.BatteryChargeLimitPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "BatteryChargeLimitPlugin" + }, + "package": { + "signPaths": [ + "BatteryChargeLimit.bundle/Contents/Resources/SMCHelper/mactools-battery-smc-helper" + ] + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "system" +} diff --git a/Plugins/BatteryChargeLimit/project.yml b/Plugins/BatteryChargeLimit/project.yml new file mode 100644 index 0000000..fae9bd5 --- /dev/null +++ b/Plugins/BatteryChargeLimit/project.yml @@ -0,0 +1,22 @@ +settings: + base: + OTHER_LDFLAGS: -framework IOKit + +targets: + mactools-battery-smc-helper: + type: tool + platform: macOS + deploymentTarget: '14.0' + bundleResourcePath: SMCHelper + sources: + - path: SMCHelper/Sources + settings: + base: + PRODUCT_NAME: mactools-battery-smc-helper + PRODUCT_BUNDLE_IDENTIFIER: "$(BUNDLE_IDENTIFIER_PREFIX).mactools.plugins.battery-charge-limit.smc-helper" + GENERATE_INFOPLIST_FILE: true + SWIFT_VERSION: 6.0 + MACOSX_DEPLOYMENT_TARGET: '14.0' + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" + OTHER_LDFLAGS: -framework IOKit diff --git a/Plugins/Calendar/Bundle/CalendarPluginBundleEntrypoint.swift b/Plugins/Calendar/Bundle/CalendarPluginBundleEntrypoint.swift new file mode 100644 index 0000000..00434f4 --- /dev/null +++ b/Plugins/Calendar/Bundle/CalendarPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import CalendarPlugin + +private let calendarPluginFactoryAnchor: Any.Type = CalendarPluginFactory.self diff --git a/Plugins/Calendar/CalendarPluginResources/ChinaHolidayOverrides.json b/Plugins/Calendar/CalendarPluginResources/ChinaHolidayOverrides.json new file mode 100644 index 0000000..f4175ea --- /dev/null +++ b/Plugins/Calendar/CalendarPluginResources/ChinaHolidayOverrides.json @@ -0,0 +1,118 @@ +{ + "2024": { + "0101": 2, + "0204": 1, + "0210": 2, + "0211": 2, + "0212": 2, + "0213": 2, + "0214": 2, + "0215": 2, + "0216": 2, + "0217": 2, + "0218": 1, + "0404": 2, + "0405": 2, + "0406": 2, + "0407": 1, + "0428": 1, + "0501": 2, + "0502": 2, + "0503": 2, + "0504": 2, + "0505": 2, + "0511": 1, + "0608": 2, + "0609": 2, + "0610": 2, + "0914": 1, + "0915": 2, + "0916": 2, + "0917": 2, + "0929": 1, + "1001": 2, + "1002": 2, + "1003": 2, + "1004": 2, + "1005": 2, + "1006": 2, + "1007": 2, + "1012": 1 + }, + "2025": { + "0101": 2, + "0126": 1, + "0128": 2, + "0129": 2, + "0130": 2, + "0131": 2, + "0201": 2, + "0202": 2, + "0203": 2, + "0204": 2, + "0208": 1, + "0404": 2, + "0405": 2, + "0406": 2, + "0427": 1, + "0501": 2, + "0502": 2, + "0503": 2, + "0504": 2, + "0505": 2, + "0531": 2, + "0601": 2, + "0602": 2, + "0928": 1, + "1001": 2, + "1002": 2, + "1003": 2, + "1004": 2, + "1005": 2, + "1006": 2, + "1007": 2, + "1008": 2, + "1011": 1 + }, + "2026": { + "0101": 2, + "0102": 2, + "0103": 2, + "0104": 1, + "0214": 1, + "0215": 2, + "0216": 2, + "0217": 2, + "0218": 2, + "0219": 2, + "0220": 2, + "0221": 2, + "0222": 2, + "0223": 2, + "0228": 1, + "0404": 2, + "0405": 2, + "0406": 2, + "0501": 2, + "0502": 2, + "0503": 2, + "0504": 2, + "0505": 2, + "0509": 1, + "0619": 2, + "0620": 2, + "0621": 2, + "0920": 1, + "0925": 2, + "0926": 2, + "0927": 2, + "1001": 2, + "1002": 2, + "1003": 2, + "1004": 2, + "1005": 2, + "1006": 2, + "1007": 2, + "1010": 1 + } +} diff --git a/Plugins/Calendar/Resources/Localizable.xcstrings b/Plugins/Calendar/Resources/Localizable.xcstrings new file mode 100644 index 0000000..9b014b7 --- /dev/null +++ b/Plugins/Calendar/Resources/Localizable.xcstrings @@ -0,0 +1,6467 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "accessibility.eventCount": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d الأحداث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Ereignisse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d events" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d eventos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d événements" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d イベント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 이벤트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d eventos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d события" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个日程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個行程" + } + } + } + }, + "accessibility.today": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heute" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aujourd'hui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сегодня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + } + } + }, + "authorization.denied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم منح حق الوصول إلى التقويم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalenderzugriff wurde nicht gewährt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar access has not been granted" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se ha concedido el acceso al calendario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'accès au calendrier n'a pas été accordé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーへのアクセスが許可されていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캘린더 액세스 권한이 부여되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O acesso ao calendário não foi concedido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступ к календарю не предоставлен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未获得日历访问权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未獲得日曆訪問權限" + } + } + } + }, + "authorization.restricted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوصول إلى التقويم مقيد من قبل النظام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Zugriff auf den Kalender ist vom System eingeschränkt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar access is restricted by the system" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El acceso al calendario está restringido por el sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'accès au calendrier est restreint par le système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーへのアクセスはシステムによって制限されています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캘린더 접근이 시스템에 의해 제한되어 있습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O acesso ao calendário é restrito pelo sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступ к календарю ограничен системой" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统限制了日历访问" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統限制了日曆訪問" + } + } + } + }, + "authorization.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يسمح هذا النظام بقراءة التقاويم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses System erlaubt das Lesen von Kalendern nicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This system does not allow reading calendars" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este sistema no permite leer calendarios" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce système ne permet pas de lire les calendriers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このシステムではカレンダーを読み取ることができません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 시스템은 달력 읽기를 허용하지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este sistema não permite a leitura de calendários" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эта система не позволяет читать календари" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前系统不允许读取日历" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前系統不允許讀取日曆" + } + } + } + }, + "authorization.writeOnly": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُسمح فقط بالوصول إلى الكتابة في التقويم، لذا لا يمكن قراءة الأحداث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es ist nur Schreibzugriff auf den Kalender zulässig, sodass Ereignisse nicht gelesen werden können" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only calendar write access is allowed, so events cannot be read" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo se permite el acceso de escritura al calendario, por lo que los eventos no se pueden leer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seul l'accès en écriture à l'agenda est autorisé, les événements ne peuvent donc pas être lus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーへの書き込みアクセスのみが許可されるため、イベントを読み取ることはできません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캘린더 쓰기 권한만 허용되어 일정을 읽을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apenas o acesso de gravação do calendário é permitido, portanto os eventos não podem ser lidos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешен только доступ к календарю для записи, поэтому события нельзя читать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅允许写入日历,无法读取日程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅允許寫入日曆,無法讀取行程" + } + } + } + }, + "day.holiday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عطلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Feiertag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Holiday" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vacaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vacances" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "休日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Feriado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Праздник" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "休息日" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "休息日" + } + } + } + }, + "day.weekend": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عطلة نهاية الأسبوع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wochenende" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Weekend" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fin de semana" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Week-end" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "週末" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주말" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fim de semana" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выходные" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "周末" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "週末" + } + } + } + }, + "day.workday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يوم العمل المعدل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angepasster Arbeitstag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjusted workday" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Jornada laboral ajustada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Journée de travail ajustée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "調整労働日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조정된 근무일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Jornada de trabalho ajustada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скорректированный рабочий день" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调休工作日" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "調休工作日" + } + } + } + }, + "event.allDay": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "طوال اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Den ganzen Tag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All day" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todo el día" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toute la journée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一日中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "하루 종일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O dia todo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Весь день" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全天" + } + } + } + }, + "event.moreCount": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d أحداث أخرى" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d weitere Ereignisse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d more events" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d más eventos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d autres événements" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 件以上のイベント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 추가 이벤트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d mais eventos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d больше событий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还有 %d 个日程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "還有 %d 個行程" + } + } + } + }, + "event.untitled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدث بلا عنوان" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbenanntes Ereignis" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled event" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Evento sin título" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Événement sans titre" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無題のイベント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목 없는 이벤트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Evento sem título" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Событие без названия" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未命名日程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未命名行程" + } + } + } + }, + "header.next.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشهر القادم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächsten Monat" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Next month" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El próximo mes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mois prochain" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "来月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 달" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "No próximo mês" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В следующем месяце" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下个月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下個月" + } + } + } + }, + "header.previous.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشهر السابق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vormonat" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous month" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mes anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mois précédent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지난달" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mês anterior" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущий месяц" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上个月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上個月" + } + } + } + }, + "header.today.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Heute" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aujourd'hui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сегодня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + } + } + }, + "header.today.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "العودة إلى اليوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück zum heutigen Tag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to today" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volver a hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retour à aujourd'hui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日に戻る" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘로 돌아가서" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "De volta a hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Назад в сегодняшний день" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "回到今天" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "回到今天" + } + } + } + }, + "holiday.badge.holiday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "H" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "H" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "H" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "H" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "高" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "H" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ч" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "休" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "休" + } + } + } + }, + "holiday.badge.workday": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "W" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "W" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "W" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "W" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "幅" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "C" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "班" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "班" + } + } + } + }, + "list.separator.comma": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "، " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "," + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "," + } + } + } + }, + "list.separator.dot": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " · " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " · " + } + } + } + }, + "lunar.day.1": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د1" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D1" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初一" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初一" + } + } + } + }, + "lunar.day.10": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د10" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D10" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д10" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初十" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初十" + } + } + } + }, + "lunar.day.11": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د11" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D11" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д11" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十一" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十一" + } + } + } + }, + "lunar.day.12": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د12" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D12" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д12" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十二" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十二" + } + } + } + }, + "lunar.day.13": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د13" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D13" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д13" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十三" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十三" + } + } + } + }, + "lunar.day.14": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د14" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D14" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д14" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十四" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十四" + } + } + } + }, + "lunar.day.15": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د15" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D15" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д15" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十五" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十五" + } + } + } + }, + "lunar.day.16": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د16" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D16" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д16" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十六" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十六" + } + } + } + }, + "lunar.day.17": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د17" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D17" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D17" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D17" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D17" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D17" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "17일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D17" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д17" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十七" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十七" + } + } + } + }, + "lunar.day.18": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د18" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D18" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д18" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十八" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十八" + } + } + } + }, + "lunar.day.19": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د19" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D19" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D19" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D19" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D19" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D19" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "19일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D19" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д19" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十九" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十九" + } + } + } + }, + "lunar.day.2": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د2" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D2" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д2" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初二" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初二" + } + } + } + }, + "lunar.day.20": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د20" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D20" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д20" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "二十" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "二十" + } + } + } + }, + "lunar.day.21": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د21" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D21" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д21" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿一" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿一" + } + } + } + }, + "lunar.day.22": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د22" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D22" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д22" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿二" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿二" + } + } + } + }, + "lunar.day.23": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د23" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D23" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д23" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿三" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿三" + } + } + } + }, + "lunar.day.24": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د24" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D24" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д24" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿四" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿四" + } + } + } + }, + "lunar.day.25": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د25" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D25" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д25" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿五" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿五" + } + } + } + }, + "lunar.day.26": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د26" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D26" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D26" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D26" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D26" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D26" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "26일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D26" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д26" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿六" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿六" + } + } + } + }, + "lunar.day.27": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د27" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D27" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д27" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿七" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿七" + } + } + } + }, + "lunar.day.28": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D28" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д28" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿八" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿八" + } + } + } + }, + "lunar.day.29": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د29" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D29" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D29" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D29" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D29" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D29" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "29일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D29" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Д29" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "廿九" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廿九" + } + } + } + }, + "lunar.day.3": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د3" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D3" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初三" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初三" + } + } + } + }, + "lunar.day.30": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د30" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "30일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D30" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "三十" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "三十" + } + } + } + }, + "lunar.day.4": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د4" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D4" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初四" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初四" + } + } + } + }, + "lunar.day.5": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د5" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D5" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初五" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初五" + } + } + } + }, + "lunar.day.6": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د6" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D6" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初六" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初六" + } + } + } + }, + "lunar.day.7": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د7" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D7" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初七" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初七" + } + } + } + }, + "lunar.day.8": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د8" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D8" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初八" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初八" + } + } + } + }, + "lunar.day.9": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "د9" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "D9" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初九" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初九" + } + } + } + }, + "lunar.festival.doubleNinth": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "دبل. التاسع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dbl. 9." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dbl. 9th" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Doble. 9º" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dbl. 9ème" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Dbl. 9 位" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Dbl. 9일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Dbl. 9º" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Двойной. 9-е" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重阳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重陽" + } + } + } + }, + "lunar.festival.dragonBoat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "دوانوو" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Duanwu" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Duanwu" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Duanwu" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Duanwu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドゥアンウー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "단우" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Duan Wu" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дуаньу" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "端午" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "端午" + } + } + } + }, + "lunar.festival.laba": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لابا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laba" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Laba" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Laba" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Laba" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ラバ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "라바" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Laba" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Лаба" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "腊八" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "臘八" + } + } + } + }, + "lunar.festival.lanternFestival": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فانوس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laterne" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lantern" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Linterna" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lanterne" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ランタン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "랜턴" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lanterna" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Фонарь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "元宵" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "元宵" + } + } + } + }, + "lunar.festival.littleNewYear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شياونيان" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Xiaonisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Xiaonian" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Xianiano" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Xiaonien" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シャオニアン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "샤오니안" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Xiaoniano" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сяонянь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "小年" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "小年" + } + } + } + }, + "lunar.festival.midAutumn": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "منتصف الخريف." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitte Herbst" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mid-Aut." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mediados de aut." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mid-Auto." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "8月中旬" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오월 중순" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Meados de agosto." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Середина августа." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "中秋" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "中秋" + } + } + } + }, + "lunar.festival.newYearsEve": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عشية LNY" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "LNY Eve" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "LNY Eve" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Víspera de LNY" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "LNY Eve" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LNY イブ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LNY 이브" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Véspera do LNY" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Канун НЙ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "除夕" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "除夕" + } + } + } + }, + "lunar.festival.qixi": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشيشي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Qixi" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Qixi" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Qixi" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Qixi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "七夕" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "치시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Qixi" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Циси" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "七夕" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "七夕" + } + } + } + }, + "lunar.festival.springFestival": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ニューヨーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "LNY" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ЛНЙ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "春节" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "春節" + } + } + } + }, + "lunar.leapPrefix": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "القفزة " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sprung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Leap " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salto " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saut " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "飛躍 " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "도약 " + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Salto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прыжок " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "闰" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "閏" + } + } + } + }, + "lunar.month.1": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م1" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M1" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正月" + } + } + } + }, + "lunar.month.10": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م10" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M10" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "十月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "十月" + } + } + } + }, + "lunar.month.11": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م11" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M11" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "冬月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "冬月" + } + } + } + }, + "lunar.month.12": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م12" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M12" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "腊月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "臘月" + } + } + } + }, + "lunar.month.2": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م2" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M2" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "二月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "二月" + } + } + } + }, + "lunar.month.3": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م3" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M3" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "三月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "三月" + } + } + } + }, + "lunar.month.4": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م4" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M4" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "四月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "四月" + } + } + } + }, + "lunar.month.5": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م5" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M5" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "五月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "五月" + } + } + } + }, + "lunar.month.6": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م6" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M6" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "六月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "六月" + } + } + } + }, + "lunar.month.7": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م7" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M7" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "七月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "七月" + } + } + } + }, + "lunar.month.8": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م8" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M8" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "八月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "八月" + } + } + } + }, + "lunar.month.9": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م9" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "M9" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "九月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "九月" + } + } + } + }, + "lunar.month.fallback": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "م" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "M" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "M" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "M" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "M" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "남" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "M" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "М" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "月" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض التواريخ والعطلات وأحداث التقويم." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Termine, Feiertage und Kalenderereignisse anzeigen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View dates, holidays, and calendar events." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver fechas, días festivos y eventos del calendario." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les dates, les jours fériés et les événements du calendrier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付、休日、カレンダーイベントを表示します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜, 공휴일 및 달력 이벤트를 확인하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Veja datas, feriados e eventos da agenda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотр дат, праздников и событий календаря." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看日期、节假日和系统日程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看日期、節假日和系統行程" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التقويم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalender" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Calendario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Calendrier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캘린더" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Calendário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Календарь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日历" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日曆" + } + } + } + }, + "month.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%1$04d/%2$02d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%04d年%02d月" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%04d年%02d月" + } + } + } + }, + "permission.automation.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتحكم في تطبيق تقويم النظام لفتح التاريخ المحدد عند النقر فوق يوم." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Steuert die Kalender-App des Systems, um das ausgewählte Datum zu öffnen, wenn Sie auf einen Tag klicken." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Controls the system Calendar app to open the selected date when you click a day." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Controla la aplicación Calendario del sistema para abrir la fecha seleccionada cuando haces clic en un día." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle l'application Calendrier du système pour ouvrir la date sélectionnée lorsque vous cliquez sur un jour." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日付をクリックすると、選択した日付が開くようにシステム カレンダー アプリを制御します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "날짜를 클릭하면 선택한 날짜가 열리도록 시스템 캘린더 앱을 제어합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Controla o aplicativo Calendário do sistema para abrir a data selecionada quando você clica em um dia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляет системным приложением «Календарь», открывая выбранную дату при нажатии на день." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击日期时需要控制系统日历应用,打开并定位到对应日期。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊日期時需要控制系統日曆App,打開並定位到對應日期。" + } + } + } + }, + "permission.automation.footnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سيطلب نظام التشغيل macOS الإذن للتحكم في التقويم في المرة الأولى التي تفتح فيها تاريخًا. إذا رفضت ذلك من قبل، فاسمح بذلك في إعدادات التشغيل الآلي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "macOS bittet Sie um Erlaubnis, den Kalender zu steuern, wenn Sie zum ersten Mal ein Datum öffnen. Wenn Sie es zuvor abgelehnt haben, lassen Sie es in den Automatisierungseinstellungen zu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "macOS will ask for permission to control Calendar the first time you open a date. If you denied it before, allow it in Automation settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "macOS le pedirá permiso para controlar el Calendario la primera vez que abra una fecha. Si lo negaste antes, permítelo en la configuración de Automatización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "macOS demandera l'autorisation de contrôler le calendrier la première fois que vous ouvrirez une date. Si vous l'avez déjà refusé, autorisez-le dans les paramètres d'automatisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "macOS は、日付を初めて開くときに、カレンダーを制御する許可を求めます。以前に拒否した場合は、オートメーション設定で許可してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "macOS는 날짜를 처음 열 때 캘린더 제어 권한을 요청합니다. 이전에 거부했다면 자동화 설정에서 허용하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O macOS solicitará permissão para controlar o Calendário na primeira vez que você abrir uma data. Se você negou antes, permita nas configurações de automação." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "macOS запросит разрешение на управление Календарем при первом открытии даты. Если вы ранее отклонили это, разрешите это в настройках автоматизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次定位系统日历时 macOS 会请求控制“日历”的权限;若曾拒绝,请在系统设置的自动化中允许。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首次定位系統日曆時 macOS 會請求控制“日曆”的權限;若曾拒絕,請在系統設定的自動化中允許。" + } + } + } + }, + "permission.automation.status": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد عند الحاجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bei Bedarf bestätigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm when needed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar cuando sea necesario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer si nécessaire" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "必要に応じて確認する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필요할 때 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirme quando necessário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтвердите при необходимости" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按需确认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按需確認" + } + } + } + }, + "permission.automation.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح التواريخ في التقويم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Termine im Kalender öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Dates in Calendar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fechas abiertas en el calendario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dates d'ouverture dans le calendrier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーの空き日付" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "달력에서 날짜 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir datas no calendário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть даты в календаре" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "定位系统日历" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "定位系統日曆" + } + } + } + }, + "permission.events.denied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@. السماح بالوصول مرة أخرى في إعدادات خصوصية التقويم." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@. Erlauben Sie den Zugriff in den Datenschutzeinstellungen des Kalenders erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@. Allow access again in Calendar privacy settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@. Permita el acceso nuevamente en la configuración de privacidad de Calendario." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@. Autorisez à nouveau l'accès dans les paramètres de confidentialité de Calendrier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@。カレンダーのプライバシー設定でアクセスを再度許可します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@. 캘린더 개인정보 보호 설정에서 다시 액세스를 허용하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@. Permita o acesso novamente nas configurações de privacidade do Agenda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@. Снова разрешите доступ в настройках конфиденциальности Календаря." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@。可在系统设置的日历隐私项中重新允许。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@。可在系統設定的日曆隱私項中重新允許。" + } + } + } + }, + "permission.events.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يقرأ أحداث التقويم حتى يظهر جدول اليوم في مكون التقويم." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liest Kalenderereignisse, damit der heutige Zeitplan in der Kalenderkomponente angezeigt werden kann." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reads calendar events so today's schedule can appear in the calendar component." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lee eventos del calendario para que el programa de hoy pueda aparecer en el componente del calendario." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lit les événements du calendrier afin que le planning du jour puisse apparaître dans le composant Calendrier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダー イベントを読み取り、今日のスケジュールをカレンダー コンポーネントに表示できるようにします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오늘의 일정이 캘린더 구성요소에 표시될 수 있도록 캘린더 이벤트를 읽습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lê eventos da agenda para que a programação de hoje possa aparecer no componente da agenda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Считывает события календаря, чтобы расписание на сегодня могло отображаться в компоненте календаря." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取系统日历事件,用于在日历组件中显示当天日程。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取系統日曆事件,用於在日曆組件中顯示當天行程。" + } + } + } + }, + "permission.events.notDetermined": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بعد طلب الوصول، سيسألك macOS عما إذا كان سيسمح بقراءة أحداث التقويم أم لا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nachdem Sie den Zugriff angefordert haben, fragt macOS, ob das Lesen von Kalenderereignissen zugelassen werden soll." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "After you request access, macOS will ask whether to allow reading calendar events." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Después de solicitar acceso, macOS le preguntará si permite la lectura de eventos del calendario." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Après avoir demandé l'accès, macOS vous demandera s'il est autorisé à lire les événements du calendrier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセスをリクエストすると、macOS はカレンダー イベントの読み取りを許可するかどうかを尋ねます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "액세스를 요청하면 macOS에서 캘린더 이벤트 읽기를 허용할지 묻습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Depois que você solicitar acesso, o macOS perguntará se permite a leitura de eventos da agenda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После того, как вы запросите доступ, macOS спросит, разрешить ли чтение событий календаря." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击请求授权后,系统会询问是否允许读取日历事件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊請求授權後,系統會詢問是否允許讀取日曆事件。" + } + } + } + }, + "permission.events.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أحداث التقويم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kalenderereignisse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendar Events" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eventos del calendario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Événements du calendrier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カレンダーイベント" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캘린더 이벤트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Eventos do calendário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "События календаря" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统日历事件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統日曆事件" + } + } + } + }, + "weekday.friday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الجمعة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fr" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fri" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "viernes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vendredi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "金曜日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "금" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sexta-feira" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пятница" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "五" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "五" + } + } + } + }, + "weekday.monday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإثنين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mo" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mon" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "lun" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lun" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "月" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "월" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Seg" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пн" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一" + } + } + } + }, + "weekday.saturday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السبت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sa" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sat" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "sábado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Samedi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "土" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "토" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sábado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сб" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "六" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "六" + } + } + } + }, + "weekday.sunday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشمس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sonntag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sun" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sol" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Soleil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "日" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sol" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "日" + } + } + } + }, + "weekday.thursday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الخميس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Do" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Thu" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Jueves" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jeudi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "木" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Qui" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чт" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "四" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "四" + } + } + } + }, + "weekday.tuesday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الثلاثاء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dienstag" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tue" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "mardi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "火" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Terça" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вторник" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "二" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "二" + } + } + } + }, + "weekday.wednesday.short": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأربعاء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mi" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Wed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "miércoles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mercredi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "水" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Quarta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ср" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "三" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "三" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/Calendar/Sources/CalendarAppearancePreference.swift b/Plugins/Calendar/Sources/CalendarAppearancePreference.swift new file mode 100644 index 0000000..c51d3b6 --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarAppearancePreference.swift @@ -0,0 +1,48 @@ +import AppKit + +enum CalendarAppearancePreference: String { + case system + case dark + case light + + private static let userDefaultsKey = "app.appearancePreference" + + @MainActor + func apply(to view: NSView?) { + view?.appearance = nsAppearance + } + + @MainActor + func apply(to window: NSWindow?) { + window?.appearance = nsAppearance + } + + @MainActor + func apply(to popover: NSPopover) { + apply(to: popover.contentViewController?.view) + apply(to: popover.contentViewController?.view.window) + } + + @MainActor + private var nsAppearance: NSAppearance? { + switch self { + case .system: + return nil + case .dark: + return NSAppearance(named: .darkAqua) + case .light: + return NSAppearance(named: .aqua) + } + } + + static func stored(in userDefaults: UserDefaults = .standard) -> CalendarAppearancePreference { + guard + let rawValue = userDefaults.string(forKey: userDefaultsKey), + let preference = CalendarAppearancePreference(rawValue: rawValue) + else { + return .system + } + + return preference + } +} diff --git a/Plugins/Calendar/Sources/CalendarComponentView.swift b/Plugins/Calendar/Sources/CalendarComponentView.swift new file mode 100644 index 0000000..b91f875 --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarComponentView.swift @@ -0,0 +1,547 @@ +import AppKit +import SwiftUI +import MacToolsPluginKit + +struct CalendarComponentView: View { + private enum Layout { + static let contentPadding: CGFloat = 8 + static let sectionSpacing: CGFloat = 3 + static let gridSpacing: CGFloat = 6 + static let headerHeight: CGFloat = 20 + static let weekdayHeight: CGFloat = 10 + static let dayCellSize: CGFloat = 36 + static let cornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius + } + + @ObservedObject private var viewModel: CalendarComponentViewModel + private let localization: PluginLocalization + + init( + context: PluginComponentContext, + viewModel: CalendarComponentViewModel, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.viewModel = viewModel + self.localization = localization + } + + var body: some View { + VStack(spacing: 0) { + calendarCard + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var calendarCard: some View { + VStack(spacing: Layout.sectionSpacing) { + CalendarHeaderView( + title: viewModel.month.title, + localization: localization, + onPrevious: { viewModel.moveMonth(by: -1) }, + onToday: { viewModel.goToToday() }, + onNext: { viewModel.moveMonth(by: 1) } + ) + + CalendarWeekdayRow( + symbols: viewModel.month.weekdaySymbols, + dayCellSize: Layout.dayCellSize, + gridSpacing: Layout.gridSpacing + ) + + CalendarMonthGrid( + days: viewModel.month.days, + selectedDayID: viewModel.selectedDay?.id, + dayCellSize: Layout.dayCellSize, + gridSpacing: Layout.gridSpacing, + localization: localization, + onSelect: { viewModel.select($0) }, + onOpen: { viewModel.open($0) } + ) + } + .padding(Layout.contentPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(CalendarComponentBackground(cornerRadius: Layout.cornerRadius)) + } + +} + +private struct CalendarHeaderView: View { + let title: String + let localization: PluginLocalization + let onPrevious: () -> Void + let onToday: () -> Void + let onNext: () -> Void + + var body: some View { + HStack(spacing: 6) { + Text(title) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + CalendarIconButton( + systemName: "chevron.left", + help: localization.string("header.previous.help", defaultValue: "上个月"), + action: onPrevious + ) + Button(localization.string("header.today.button", defaultValue: "今天"), action: onToday) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .buttonStyle(.plain) + .frame(width: 32, height: 20) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + .help(localization.string("header.today.help", defaultValue: "回到今天")) + CalendarIconButton( + systemName: "chevron.right", + help: localization.string("header.next.help", defaultValue: "下个月"), + action: onNext + ) + } + .frame(height: 20) + } +} + +private struct CalendarIconButton: View { + let systemName: String + let help: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .help(help) + } +} + +private struct CalendarWeekdayRow: View { + let symbols: [String] + let dayCellSize: CGFloat + let gridSpacing: CGFloat + + var body: some View { + HStack(spacing: gridSpacing) { + ForEach(Array(symbols.enumerated()), id: \.offset) { _, symbol in + Text(symbol) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: dayCellSize) + } + } + .frame(height: 10) + } +} + +private struct CalendarMonthGrid: View { + let days: [CalendarDayModel] + let selectedDayID: String? + let dayCellSize: CGFloat + let gridSpacing: CGFloat + let localization: PluginLocalization + let onSelect: (CalendarDayModel) -> Void + let onOpen: (CalendarDayModel) -> Void + + @State private var hoveredDayID: String? + + private var columns: [GridItem] { + Array( + repeating: GridItem(.fixed(dayCellSize), spacing: gridSpacing), + count: 7 + ) + } + + var body: some View { + LazyVGrid(columns: columns, spacing: gridSpacing) { + ForEach(days) { day in + CalendarDayCell( + day: day, + isSelected: selectedDayID == day.id, + localization: localization, + onOpen: { onOpen(day) } + ) + .frame(width: dayCellSize, height: dayCellSize) + .background( + CalendarEventPopoverPresenter( + title: CalendarDayPresentation.dateTitle(for: day), + subtitle: CalendarDayPresentation.dateSubtitle(for: day, localization: localization), + events: day.events, + localization: localization, + isPresented: hoveredDayID == day.id && !day.events.isEmpty + ) + ) + .onHover { isHovered in + if isHovered { + hoveredDayID = day.id + onSelect(day) + } else if hoveredDayID == day.id { + hoveredDayID = nil + } + } + } + } + .frame(maxWidth: .infinity) + } +} + +private struct CalendarDayCell: View { + let day: CalendarDayModel + let isSelected: Bool + let localization: PluginLocalization + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(backgroundColor) + + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(borderColor, lineWidth: day.isToday ? 1.5 : 0) + + VStack(spacing: 1) { + Text(day.dayNumber) + .font(.system(size: 13, weight: day.isToday ? .bold : .semibold, design: .rounded)) + .foregroundStyle(primaryTextStyle) + .lineLimit(1) + + if !day.lunarText.isEmpty { + Text(day.lunarText) + .font(.system(size: 8.5, weight: .medium)) + .foregroundStyle(secondaryTextStyle) + .lineLimit(1) + .minimumScaleFactor(0.65) + } + + CalendarEventDots(events: day.visibleEvents) + .padding(.top, 1) + } + .padding(.horizontal, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if let holidayKind = day.holidayKind { + CalendarHolidayBadge(kind: holidayKind, localization: localization) + .offset(x: 2, y: -3) + } + } + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + } + + private var backgroundColor: Color { + if isSelected { + return Color.accentColor.opacity(0.16) + } + + if day.isToday { + return Color.accentColor.opacity(0.08) + } + + return Color(nsColor: .controlBackgroundColor).opacity(day.isInDisplayedMonth ? 0.35 : 0.12) + } + + private var borderColor: Color { + day.isToday ? Color.accentColor.opacity(0.95) : .clear + } + + private var primaryTextStyle: HierarchicalShapeStyle { + if day.isInDisplayedMonth { + return day.isWeekend ? .secondary : .primary + } + + return .tertiary + } + + private var secondaryTextStyle: HierarchicalShapeStyle { + day.isInDisplayedMonth ? .secondary : .tertiary + } + + private var accessibilityLabel: String { + var parts = [day.dayNumber] + if !day.lunarText.isEmpty { + parts.append(day.lunarText) + } + if day.isToday { + parts.append(localization.string("accessibility.today", defaultValue: "今天")) + } + if let holidayKind = day.holidayKind { + parts.append(CalendarDayPresentation.holidayText(for: holidayKind, localization: localization)) + } + if !day.events.isEmpty { + parts.append( + localization.format( + "accessibility.eventCount", + defaultValue: "%d 个日程", + day.events.count + ) + ) + } + return parts.joined( + separator: localization.string("list.separator.comma", defaultValue: ",") + ) + } +} + +private struct CalendarHolidayBadge: View { + let kind: CalendarHolidayKind + let localization: PluginLocalization + + var body: some View { + Text(kind.badgeText(localization: localization)) + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 14, height: 14) + .background( + Circle() + .fill(kind == .holiday ? Color(nsColor: .systemTeal) : Color(nsColor: .systemOrange)) + .shadow(color: .black.opacity(0.12), radius: 1, y: 0.5) + ) + } +} + +private struct CalendarEventDots: View { + private static let dotSize: CGFloat = 3 + private static let rowHeight: CGFloat = 5 + + let events: [CalendarEventSummary] + + var body: some View { + HStack(spacing: 2) { + if events.isEmpty { + Color.clear + .frame(width: Self.dotSize, height: Self.dotSize) + } else { + ForEach(events) { event in + Circle() + .fill(Color(calendarEventColor: event.color)) + .frame(width: Self.dotSize, height: Self.dotSize) + } + } + } + .frame(height: Self.rowHeight) + } +} + +private struct CalendarEventPopoverPresenter: NSViewRepresentable { + let title: String + let subtitle: String + let events: [CalendarEventSummary] + let localization: PluginLocalization + let isPresented: Bool + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> NSView { + NSView(frame: .zero) + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.update( + title: title, + subtitle: subtitle, + events: events, + localization: localization, + isPresented: isPresented, + sourceView: nsView + ) + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.close() + } + + final class Coordinator { + private var popover: NSPopover? + + @MainActor + func update( + title: String, + subtitle: String, + events: [CalendarEventSummary], + localization: PluginLocalization, + isPresented: Bool, + sourceView: NSView + ) { + guard isPresented, !events.isEmpty, sourceView.window != nil else { + close() + return + } + + let popover = popover ?? makePopover() + let content = CalendarFloatingEventPopoverContent( + title: title, + subtitle: subtitle, + events: events, + localization: localization + ) + let hostingController = NSHostingController(rootView: content) + CalendarAppearancePreference.stored().apply(to: hostingController.view) + popover.contentViewController = hostingController + popover.contentSize = NSSize(width: 230, height: min(hostingController.view.fittingSize.height, 260)) + CalendarAppearancePreference.stored().apply(to: popover) + + if !popover.isShown { + popover.show(relativeTo: sourceView.bounds, of: sourceView, preferredEdge: .maxY) + CalendarAppearancePreference.stored().apply(to: popover) + } + + self.popover = popover + } + + @MainActor + func close() { + popover?.performClose(nil) + popover = nil + } + + @MainActor + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .applicationDefined + popover.animates = false + return popover + } + } +} + +private struct CalendarFloatingEventPopoverContent: View { + let title: String + let subtitle: String + let events: [CalendarEventSummary] + let localization: PluginLocalization + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + + Text(subtitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Divider() + + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(events.prefix(6))) { event in + CalendarEventRow(event: event) + } + + if events.count > 6 { + Text( + localization.format( + "event.moreCount", + defaultValue: "还有 %d 个日程", + events.count - 6 + ) + ) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .frame(width: 230, alignment: .leading) + } +} + +private enum CalendarDayPresentation { + static func dateTitle(for day: CalendarDayModel) -> String { + let formatter = DateFormatter() + formatter.locale = PluginRuntimeLocalization.locale + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: day.date) + } + + static func dateSubtitle( + for day: CalendarDayModel, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> String { + var parts: [String] = [] + if !day.lunarText.isEmpty { + parts.append(day.lunarText) + } + if let holidayKind = day.holidayKind { + parts.append(holidayText(for: holidayKind, localization: localization)) + } else if day.isWeekend { + parts.append(localization.string("day.weekend", defaultValue: "周末")) + } + if day.events.count > CalendarDayModel.maximumVisibleEvents { + parts.append( + localization.format( + "event.moreCount", + defaultValue: "还有 %d 个日程", + day.events.count - CalendarDayModel.maximumVisibleEvents + ) + ) + } + return parts.joined(separator: localization.string("list.separator.dot", defaultValue: " · ")) + } + + static func holidayText( + for holidayKind: CalendarHolidayKind, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> String { + switch holidayKind { + case .holiday: + return localization.string("day.holiday", defaultValue: "休息日") + case .workday: + return localization.string("day.workday", defaultValue: "调休工作日") + } + } +} + +private struct CalendarEventRow: View { + let event: CalendarEventSummary + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(Color(calendarEventColor: event.color)) + .frame(width: 6, height: 6) + + Text(event.title) + .font(.system(size: 10.5, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(event.timeText) + .font(.system(size: 9.5, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +private struct CalendarComponentBackground: View { + let cornerRadius: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.primary.opacity(0.045)) + } +} + +private extension Color { + init(calendarEventColor color: CalendarEventColor) { + self.init( + red: color.red, + green: color.green, + blue: color.blue, + opacity: color.alpha + ) + } +} diff --git a/Plugins/Calendar/Sources/CalendarComponentViewModel.swift b/Plugins/Calendar/Sources/CalendarComponentViewModel.swift new file mode 100644 index 0000000..40dff92 --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarComponentViewModel.swift @@ -0,0 +1,152 @@ +import Combine +import Foundation +import MacToolsPluginKit + +@MainActor +final class CalendarComponentViewModel: ObservableObject { + @Published private(set) var month: CalendarMonthModel + @Published private(set) var selectedDay: CalendarDayModel? + @Published private(set) var authorization: CalendarEventAuthorization + @Published private(set) var isLoadingEvents = false + + private let eventService: CalendarEventServicing + private let holidayProvider: CalendarHolidayProvider + private let calendar: Calendar + private let localization: PluginLocalization + private var displayedMonthStart: Date + private var selectedDate: Date + private var eventsByDay: [Date: [CalendarEventSummary]] = [:] + private var loadTask: Task? + + init( + eventService: CalendarEventServicing = CalendarEventService(), + holidayProvider: CalendarHolidayProvider, + calendar: Calendar = CalendarComponentCalendars.gregorianFollowingSystem(), + localization: PluginLocalization = PluginLocalization(bundle: .main), + today: Date = Date() + ) { + self.eventService = eventService + self.holidayProvider = holidayProvider + self.calendar = calendar + self.localization = localization + self.displayedMonthStart = CalendarComponentCalendars.monthStart(containing: today, calendar: calendar) + self.selectedDate = calendar.startOfDay(for: today) + self.authorization = eventService.authorization + self.month = CalendarMonthModelBuilder( + calendar: calendar, + holidayProvider: holidayProvider, + localization: localization + ).makeMonth(containing: today, today: today) + self.selectedDay = month.days.first { calendar.isDate($0.date, inSameDayAs: selectedDate) } + } + + func start() { + refresh() + } + + func stop() { + loadTask?.cancel() + loadTask = nil + } + + func refresh() { + rebuildMonth() + reloadEvents() + } + + func moveMonth(by value: Int) { + guard let nextMonth = calendar.date(byAdding: .month, value: value, to: displayedMonthStart) else { + return + } + + displayedMonthStart = CalendarComponentCalendars.monthStart(containing: nextMonth, calendar: calendar) + selectedDate = displayedMonthStart + eventsByDay = [:] + rebuildMonth() + reloadEvents() + } + + func goToToday() { + let today = calendar.startOfDay(for: Date()) + displayedMonthStart = CalendarComponentCalendars.monthStart(containing: today, calendar: calendar) + selectedDate = today + eventsByDay = [:] + rebuildMonth(today: today) + reloadEvents() + } + + func select(_ day: CalendarDayModel) { + selectedDate = day.date + selectedDay = day + } + + func open(_ day: CalendarDayModel) { + select(day) + eventService.openSystemCalendar(at: day.date) + } + + private func reloadEvents() { + loadTask?.cancel() + loadTask = Task { [weak self] in + guard let self else { + return + } + + authorization = eventService.authorization + guard authorization.isFullAccess else { + eventsByDay = [:] + isLoadingEvents = false + rebuildMonth() + return + } + + guard let firstDate = month.days.first?.date, + let lastDate = month.days.last?.date, + let endDate = calendar.date(byAdding: .day, value: 1, to: lastDate) else { + return + } + + isLoadingEvents = true + + do { + let events = try await eventService.events(from: firstDate, to: endDate) + guard !Task.isCancelled else { + return + } + + eventsByDay = CalendarEventGrouper.group( + events: events, + visibleDates: month.days.map(\.date), + calendar: calendar, + localization: localization + ) + isLoadingEvents = false + rebuildMonth() + } catch { + guard !Task.isCancelled else { + return + } + + eventsByDay = [:] + isLoadingEvents = false + rebuildMonth() + } + } + } + + private func rebuildMonth(today: Date = Date()) { + month = CalendarMonthModelBuilder( + calendar: calendar, + holidayProvider: holidayProvider, + localization: localization + ).makeMonth( + containing: displayedMonthStart, + today: today, + eventsByDay: eventsByDay + ) + + selectedDay = month.days.first { calendar.isDate($0.date, inSameDayAs: selectedDate) } + ?? month.days.first { $0.isToday } + ?? month.days.first + } +} diff --git a/Plugins/Calendar/Sources/CalendarEventGrouper.swift b/Plugins/Calendar/Sources/CalendarEventGrouper.swift new file mode 100644 index 0000000..89c847e --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarEventGrouper.swift @@ -0,0 +1,107 @@ +import Foundation +import MacToolsPluginKit + +enum CalendarEventGrouper { + static func group( + events: [CalendarEventInput], + visibleDates: [Date], + calendar: Calendar, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> [Date: [CalendarEventSummary]] { + let dayStarts = visibleDates.map { calendar.startOfDay(for: $0) } + var grouped = Dictionary(uniqueKeysWithValues: dayStarts.map { ($0, [CalendarEventSummary]()) }) + + for event in events { + for dayStart in dayStarts where overlaps(event: event, dayStart: dayStart, calendar: calendar) { + grouped[dayStart, default: []].append( + summary( + for: event, + dayStart: dayStart, + calendar: calendar, + localization: localization + ) + ) + } + } + + return grouped.mapValues { summaries in + summaries.sorted { lhs, rhs in + if lhs.isAllDay != rhs.isAllDay { + return lhs.isAllDay && !rhs.isAllDay + } + + if lhs.startDate != rhs.startDate { + return lhs.startDate < rhs.startDate + } + + return lhs.title.localizedCompare(rhs.title) == .orderedAscending + } + } + } + + private static func overlaps(event: CalendarEventInput, dayStart: Date, calendar: Calendar) -> Bool { + guard let nextDayStart = calendar.date(byAdding: .day, value: 1, to: dayStart) else { + return false + } + + let eventEnd = normalizedEndDate(for: event, calendar: calendar) + return event.startDate < nextDayStart && eventEnd > dayStart + } + + private static func summary( + for event: CalendarEventInput, + dayStart: Date, + calendar: Calendar, + localization: PluginLocalization + ) -> CalendarEventSummary { + CalendarEventSummary( + id: "\(event.id)-\(CalendarComponentCalendars.dayID(for: dayStart, calendar: calendar))", + title: event.title.isEmpty + ? localization.string("event.untitled", defaultValue: "未命名日程") + : event.title, + timeText: timeText(for: event, dayStart: dayStart, calendar: calendar, localization: localization), + startDate: event.startDate, + endDate: normalizedEndDate(for: event, calendar: calendar), + isAllDay: event.isAllDay, + color: event.color + ) + } + + private static func normalizedEndDate(for event: CalendarEventInput, calendar: Calendar) -> Date { + if event.endDate > event.startDate { + return event.endDate + } + + return calendar.date(byAdding: .minute, value: 1, to: event.startDate) ?? event.startDate + } + + private static func timeText( + for event: CalendarEventInput, + dayStart: Date, + calendar: Calendar, + localization: PluginLocalization + ) -> String { + guard !event.isAllDay else { + return localization.string("event.allDay", defaultValue: "全天") + } + + let endDate = normalizedEndDate(for: event, calendar: calendar) + let nextDayStart = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart + let visibleStart = max(event.startDate, dayStart) + let visibleEnd = min(endDate, nextDayStart) + + if visibleStart >= visibleEnd { + return formatTime(event.startDate) + } + + return "\(formatTime(visibleStart))–\(formatTime(visibleEnd))" + } + + private static func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = PluginRuntimeLocalization.locale + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/Plugins/Calendar/Sources/CalendarEventService.swift b/Plugins/Calendar/Sources/CalendarEventService.swift new file mode 100644 index 0000000..979e285 --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarEventService.swift @@ -0,0 +1,216 @@ +import AppKit +import EventKit +import Foundation +import MacToolsPluginKit + +@MainActor +enum CalendarEventAuthorization: Equatable { + case notDetermined + case fullAccess + case denied(String) + + var isFullAccess: Bool { + if case .fullAccess = self { + return true + } + + return false + } +} + +@MainActor +protocol CalendarEventServicing: AnyObject { + var authorization: CalendarEventAuthorization { get } + + func requestAccess() async -> CalendarEventAuthorization + func events(from startDate: Date, to endDate: Date) async throws -> [CalendarEventInput] + func openSystemCalendar(at date: Date) +} + +@MainActor +final class CalendarEventService: CalendarEventServicing { + private var eventStore = EKEventStore() + private let localization: PluginLocalization + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + } + + var authorization: CalendarEventAuthorization { + Self.authorization(from: EKEventStore.authorizationStatus(for: .event), localization: localization) + } + + func requestAccess() async -> CalendarEventAuthorization { + guard authorization == .notDetermined else { + return authorization + } + + do { + _ = try await requestFullAccessToEvents() + eventStore = EKEventStore() + return authorization + } catch { + eventStore = EKEventStore() + return .denied(error.localizedDescription) + } + } + + private func requestFullAccessToEvents() async throws -> Bool { + let eventStore = eventStore + + return try await withCheckedThrowingContinuation { continuation in + eventStore.requestFullAccessToEvents { granted, error in + if let error { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: granted) + } + } + } + + func events(from startDate: Date, to endDate: Date) async throws -> [CalendarEventInput] { + guard authorization.isFullAccess else { + return [] + } + + let calendars = eventStore.calendars(for: .event) + guard !calendars.isEmpty else { + return [] + } + + let predicate = eventStore.predicateForEvents( + withStart: startDate, + end: endDate, + calendars: calendars + ) + + return eventStore.events(matching: predicate).map { Self.input(from: $0, localization: localization) } + } + + func openSystemCalendar(at date: Date) { + Task { @MainActor [weak self] in + guard let self else { + return + } + + _ = await requestAccess() + openSystemCalendarAfterAccessCheck(at: date) + } + } + + private func openSystemCalendarAfterAccessCheck(at date: Date) { + let script = Self.openingScript(for: date) + var error: NSDictionary? + NSAppleScript(source: script)?.executeAndReturnError(&error) + + if error != nil { + openCalendarApplication() + } + } + + private static func authorization( + from status: EKAuthorizationStatus, + localization: PluginLocalization + ) -> CalendarEventAuthorization { + switch status { + case .notDetermined: + return .notDetermined + case .fullAccess: + return .fullAccess + case .denied: + return .denied(localization.string("authorization.denied", defaultValue: "未获得日历访问权限")) + case .restricted: + return .denied(localization.string("authorization.restricted", defaultValue: "系统限制了日历访问")) + case .writeOnly: + return .denied(localization.string("authorization.writeOnly", defaultValue: "仅允许写入日历,无法读取日程")) + @unknown default: + return .denied(localization.string("authorization.unknown", defaultValue: "当前系统不允许读取日历")) + } + } + + private static func input(from event: EKEvent, localization: PluginLocalization) -> CalendarEventInput { + CalendarEventInput( + id: event.eventIdentifier ?? event.calendarItemIdentifier, + title: event.title ?? localization.string("event.untitled", defaultValue: "未命名日程"), + startDate: event.startDate, + endDate: event.endDate, + isAllDay: event.isAllDay, + color: CalendarEventColor(nsColor: event.calendar.color) + ) + } + + static func openingScript(for date: Date) -> String { + openingScript(for: date, dateFormatter: appleScriptDateFormatter()) + } + + static func openingScript(for date: Date, dateFormatter: DateFormatter) -> String { + let dateText = appleScriptEscapedString(dateFormatter.string(from: date)) + + return """ + tell application "Calendar" + activate + switch view to day view + view calendar at date "\(dateText)" + end tell + """ + } + + private static func appleScriptDateFormatter() -> DateFormatter { + let calendar = CalendarComponentCalendars.gregorianFollowingSystem() + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = .autoupdatingCurrent + formatter.timeZone = calendar.timeZone + formatter.dateStyle = .full + formatter.timeStyle = .none + return formatter + } + + private static func appleScriptEscapedString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private func openCalendarApplication() { + let calendarURL = URL(fileURLWithPath: "/System/Applications/Calendar.app") + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: calendarURL, configuration: configuration) { runningApplication, error in + if error != nil { + NSWorkspace.shared.open(calendarURL) + Task { @MainActor in + Self.activateRunningCalendarApplication() + } + return + } + + runningApplication?.activate(options: [.activateAllWindows]) + } + } + + private static func activateRunningCalendarApplication() { + NSRunningApplication + .runningApplications(withBundleIdentifier: "com.apple.iCal") + .first? + .activate(options: [.activateAllWindows]) + } +} + +private extension CalendarEventColor { + init(nsColor: NSColor?) { + guard let color = nsColor?.usingColorSpace(.deviceRGB) else { + self = .accent + return + } + + self.init( + red: Double(color.redComponent), + green: Double(color.greenComponent), + blue: Double(color.blueComponent), + alpha: Double(color.alphaComponent) + ) + } +} diff --git a/Plugins/Calendar/Sources/CalendarHolidayProvider.swift b/Plugins/Calendar/Sources/CalendarHolidayProvider.swift new file mode 100644 index 0000000..b617d13 --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarHolidayProvider.swift @@ -0,0 +1,46 @@ +import Foundation +import MacToolsPluginKit + +struct CalendarHolidayProvider { + private typealias RawData = [String: [String: Int]] + + static let empty = CalendarHolidayProvider(records: [:]) + + private let records: [String: [String: CalendarHolidayKind]] + + init(data: Data) throws { + let rawData = try JSONDecoder().decode(RawData.self, from: data) + self.records = rawData.mapValues { days in + days.reduce(into: [String: CalendarHolidayKind]()) { result, item in + result[item.key] = CalendarHolidayKind(rawValue: item.value) + } + } + } + + private init(records: [String: [String: CalendarHolidayKind]]) { + self.records = records + } + + @MainActor + static func bundled(context: PluginRuntimeContext) -> CalendarHolidayProvider { + guard let url = context.resourceURL(forResource: "ChinaHolidayOverrides", withExtension: "json") else { + return .empty + } + + do { + let data = try Data(contentsOf: url) + return try CalendarHolidayProvider(data: data) + } catch { + return .empty + } + } + + func kind(for date: Date, calendar: Calendar) -> CalendarHolidayKind? { + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, let month = components.month, let day = components.day else { + return nil + } + + return records[String(year)]?[String(format: "%02d%02d", month, day)] + } +} diff --git a/Plugins/Calendar/Sources/CalendarModels.swift b/Plugins/Calendar/Sources/CalendarModels.swift new file mode 100644 index 0000000..230660d --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarModels.swift @@ -0,0 +1,321 @@ +import Foundation +import MacToolsPluginKit + +struct CalendarEventColor: Equatable, Sendable { + let red: Double + let green: Double + let blue: Double + let alpha: Double + + static let accent = CalendarEventColor(red: 0.0, green: 0.48, blue: 1.0, alpha: 1.0) +} + +struct CalendarEventInput: Equatable, Sendable { + let id: String + let title: String + let startDate: Date + let endDate: Date + let isAllDay: Bool + let color: CalendarEventColor +} + +struct CalendarEventSummary: Identifiable, Equatable, Sendable { + let id: String + let title: String + let timeText: String + let startDate: Date + let endDate: Date + let isAllDay: Bool + let color: CalendarEventColor +} + +enum CalendarHolidayKind: Int, Equatable, Sendable { + case workday = 1 + case holiday = 2 + + func badgeText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .workday: + return localization.string("holiday.badge.workday", defaultValue: "班") + case .holiday: + return localization.string("holiday.badge.holiday", defaultValue: "休") + } + } +} + +struct CalendarDayModel: Identifiable, Equatable, Sendable { + let id: String + let date: Date + let dayNumber: String + let lunarText: String + let isInDisplayedMonth: Bool + let isToday: Bool + let isWeekend: Bool + let holidayKind: CalendarHolidayKind? + let events: [CalendarEventSummary] + + var visibleEvents: [CalendarEventSummary] { + Array(events.prefix(Self.maximumVisibleEvents)) + } + + static let maximumVisibleEvents = 3 +} + +struct CalendarMonthModel: Equatable, Sendable { + let displayedMonthStart: Date + let title: String + let weekdaySymbols: [String] + let days: [CalendarDayModel] +} + +enum CalendarComponentCalendars { + static func gregorianFollowingSystem() -> Calendar { + let current = Calendar.autoupdatingCurrent + var calendar = current.identifier == .gregorian ? current : Calendar(identifier: .gregorian) + calendar.locale = current.locale + calendar.timeZone = current.timeZone + calendar.firstWeekday = current.firstWeekday + return calendar + } + + static func monthStart(containing date: Date, calendar: Calendar) -> Date { + let components = calendar.dateComponents([.year, .month], from: date) + return calendar.date(from: components) ?? calendar.startOfDay(for: date) + } + + static func dayID(for date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String( + format: "%04d%02d%02d", + components.year ?? 0, + components.month ?? 0, + components.day ?? 0 + ) + } +} + +struct CalendarMonthModelBuilder { + private let calendar: Calendar + private let lunarCalendar: Calendar + private let holidayProvider: CalendarHolidayProvider + private let localization: PluginLocalization + private let showsHolidayBadges: Bool + + init( + calendar: Calendar = CalendarComponentCalendars.gregorianFollowingSystem(), + holidayProvider: CalendarHolidayProvider = .empty, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.calendar = calendar + self.holidayProvider = holidayProvider + self.localization = localization + var lunarCalendar = Calendar(identifier: .chinese) + lunarCalendar.locale = Locale(identifier: "zh_Hans_CN") + lunarCalendar.timeZone = calendar.timeZone + self.lunarCalendar = lunarCalendar + self.showsHolidayBadges = Self.shouldShowHolidayBadges(calendar: calendar) + } + + func makeMonth( + containing monthDate: Date, + today: Date = Date(), + eventsByDay: [Date: [CalendarEventSummary]] = [:] + ) -> CalendarMonthModel { + let monthStart = CalendarComponentCalendars.monthStart(containing: monthDate, calendar: calendar) + let title = monthTitle(for: monthStart, calendar: calendar) + let days = makeDays(displayedMonthStart: monthStart, today: today, eventsByDay: eventsByDay) + + return CalendarMonthModel( + displayedMonthStart: monthStart, + title: title, + weekdaySymbols: weekdaySymbols(), + days: days + ) + } + + func makeDays( + displayedMonthStart: Date, + today: Date = Date(), + eventsByDay: [Date: [CalendarEventSummary]] = [:] + ) -> [CalendarDayModel] { + guard let gridStart = gridStartDate(for: displayedMonthStart) else { + return [] + } + + return (0..<42).compactMap { offset in + guard let date = calendar.date(byAdding: .day, value: offset, to: gridStart) else { + return nil + } + + let dayStart = calendar.startOfDay(for: date) + let components = calendar.dateComponents([.year, .month, .day], from: dayStart) + let isInDisplayedMonth = calendar.isDate(dayStart, equalTo: displayedMonthStart, toGranularity: .month) + let holidayKind = showsHolidayBadges ? holidayProvider.kind(for: dayStart, calendar: calendar) : nil + + return CalendarDayModel( + id: CalendarComponentCalendars.dayID(for: dayStart, calendar: calendar), + date: dayStart, + dayNumber: String(components.day ?? 0), + lunarText: lunarText(for: dayStart), + isInDisplayedMonth: isInDisplayedMonth, + isToday: calendar.isDate(dayStart, inSameDayAs: today), + isWeekend: calendar.isDateInWeekend(dayStart), + holidayKind: holidayKind, + events: eventsByDay[dayStart] ?? [] + ) + } + } + + private func gridStartDate(for displayedMonthStart: Date) -> Date? { + let weekday = calendar.component(.weekday, from: displayedMonthStart) + let leadingDays = (weekday - calendar.firstWeekday + 7) % 7 + return calendar.date(byAdding: .day, value: -leadingDays, to: displayedMonthStart) + } + + private func weekdaySymbols() -> [String] { + let symbols = [ + localization.string("weekday.sunday.short", defaultValue: "日"), + localization.string("weekday.monday.short", defaultValue: "一"), + localization.string("weekday.tuesday.short", defaultValue: "二"), + localization.string("weekday.wednesday.short", defaultValue: "三"), + localization.string("weekday.thursday.short", defaultValue: "四"), + localization.string("weekday.friday.short", defaultValue: "五"), + localization.string("weekday.saturday.short", defaultValue: "六") + ] + let startIndex = max(calendar.firstWeekday - 1, 0) % symbols.count + return Array(symbols[startIndex.. String { + let components = lunarCalendar.dateComponents([.year, .month, .day, .isLeapMonth], from: date) + let month = components.month ?? 1 + let day = components.day ?? 1 + + if isLastDayOfLunarYear(date) { + return localization.string("lunar.festival.newYearsEve", defaultValue: "除夕") + } + + if let festival = lunarFestival(month: month, day: day) { + return festival + } + + if day == 1 { + let prefix = components.isLeapMonth == true + ? localization.string("lunar.leapPrefix", defaultValue: "闰") + : "" + return prefix + lunarMonthName(month) + } + + return lunarDayName(day) + } + + private func isLastDayOfLunarYear(_ date: Date) -> Bool { + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { + return false + } + + let year = lunarCalendar.component(.year, from: date) + let nextYear = lunarCalendar.component(.year, from: nextDay) + return year != nextYear + } + + private func lunarFestival(month: Int, day: Int) -> String? { + switch (month, day) { + case (1, 1): + return localization.string("lunar.festival.springFestival", defaultValue: "春节") + case (1, 15): + return localization.string("lunar.festival.lanternFestival", defaultValue: "元宵") + case (5, 5): + return localization.string("lunar.festival.dragonBoat", defaultValue: "端午") + case (7, 7): + return localization.string("lunar.festival.qixi", defaultValue: "七夕") + case (8, 15): + return localization.string("lunar.festival.midAutumn", defaultValue: "中秋") + case (9, 9): + return localization.string("lunar.festival.doubleNinth", defaultValue: "重阳") + case (12, 8): + return localization.string("lunar.festival.laba", defaultValue: "腊八") + case (12, 23): + return localization.string("lunar.festival.littleNewYear", defaultValue: "小年") + default: + return nil + } + } + + private func monthTitle(for date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month], from: date) + return String( + format: localization.string("month.title", defaultValue: "%04d年%02d月"), + locale: Locale(identifier: "en_US_POSIX"), + components.year ?? 0, + components.month ?? 0 + ) + } + + private func lunarMonthName(_ month: Int) -> String { + let names = [ + localization.string("lunar.month.1", defaultValue: "正月"), + localization.string("lunar.month.2", defaultValue: "二月"), + localization.string("lunar.month.3", defaultValue: "三月"), + localization.string("lunar.month.4", defaultValue: "四月"), + localization.string("lunar.month.5", defaultValue: "五月"), + localization.string("lunar.month.6", defaultValue: "六月"), + localization.string("lunar.month.7", defaultValue: "七月"), + localization.string("lunar.month.8", defaultValue: "八月"), + localization.string("lunar.month.9", defaultValue: "九月"), + localization.string("lunar.month.10", defaultValue: "十月"), + localization.string("lunar.month.11", defaultValue: "冬月"), + localization.string("lunar.month.12", defaultValue: "腊月") + ] + guard (1...names.count).contains(month) else { + return localization.string("lunar.month.fallback", defaultValue: "月") + } + + return names[month - 1] + } + + private func lunarDayName(_ day: Int) -> String { + let names = [ + localization.string("lunar.day.1", defaultValue: "初一"), + localization.string("lunar.day.2", defaultValue: "初二"), + localization.string("lunar.day.3", defaultValue: "初三"), + localization.string("lunar.day.4", defaultValue: "初四"), + localization.string("lunar.day.5", defaultValue: "初五"), + localization.string("lunar.day.6", defaultValue: "初六"), + localization.string("lunar.day.7", defaultValue: "初七"), + localization.string("lunar.day.8", defaultValue: "初八"), + localization.string("lunar.day.9", defaultValue: "初九"), + localization.string("lunar.day.10", defaultValue: "初十"), + localization.string("lunar.day.11", defaultValue: "十一"), + localization.string("lunar.day.12", defaultValue: "十二"), + localization.string("lunar.day.13", defaultValue: "十三"), + localization.string("lunar.day.14", defaultValue: "十四"), + localization.string("lunar.day.15", defaultValue: "十五"), + localization.string("lunar.day.16", defaultValue: "十六"), + localization.string("lunar.day.17", defaultValue: "十七"), + localization.string("lunar.day.18", defaultValue: "十八"), + localization.string("lunar.day.19", defaultValue: "十九"), + localization.string("lunar.day.20", defaultValue: "二十"), + localization.string("lunar.day.21", defaultValue: "廿一"), + localization.string("lunar.day.22", defaultValue: "廿二"), + localization.string("lunar.day.23", defaultValue: "廿三"), + localization.string("lunar.day.24", defaultValue: "廿四"), + localization.string("lunar.day.25", defaultValue: "廿五"), + localization.string("lunar.day.26", defaultValue: "廿六"), + localization.string("lunar.day.27", defaultValue: "廿七"), + localization.string("lunar.day.28", defaultValue: "廿八"), + localization.string("lunar.day.29", defaultValue: "廿九"), + localization.string("lunar.day.30", defaultValue: "三十") + ] + guard (1...names.count).contains(day) else { + return "" + } + + return names[day - 1] + } + + private static func shouldShowHolidayBadges(calendar: Calendar) -> Bool { + let identifier = calendar.locale?.identifier ?? Locale.autoupdatingCurrent.identifier + return identifier.lowercased().hasPrefix("zh") + } +} diff --git a/Plugins/Calendar/Sources/CalendarPlugin.swift b/Plugins/Calendar/Sources/CalendarPlugin.swift new file mode 100644 index 0000000..b7d7bf0 --- /dev/null +++ b/Plugins/Calendar/Sources/CalendarPlugin.swift @@ -0,0 +1,226 @@ +import AppKit +import SwiftUI +import MacToolsPluginKit + +public final class CalendarPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + CalendarPluginProvider(context: context) + } +} + +@MainActor +private struct CalendarPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + let localization = PluginLocalization(bundle: context.resourceBundle) + let resourceContext = PluginRuntimeContext( + pluginID: context.pluginID, + resourceBundle: context.resourceBundle, + resourceSubdirectory: "CalendarPluginResources", + storage: context.storage, + supportDirectory: context.supportDirectory, + cacheDirectory: context.cacheDirectory, + temporaryDirectory: context.temporaryDirectory + ) + return [CalendarPlugin(context: resourceContext, localization: localization)] + } +} + +@MainActor +final class CalendarPlugin: MacToolsPlugin, PluginComponentPanel, PluginPanelSurfaceLifecycleHandling { + private enum PermissionID { + static let calendarEvents = "calendar-events" + static let calendarAutomation = "calendar-automation" + } + + let metadata: PluginMetadata + + let descriptor = PluginComponentDescriptor( + span: PluginComponentSpan( + width: 4, + height: PluginComponentPanelLayoutMetrics.default.heightSpan(closestToOriginalSpanHeight: 3) + )! + ) + + private let context: PluginRuntimeContext + private let eventService: CalendarEventServicing + private let localization: PluginLocalization + private let viewModel: CalendarComponentViewModel + + init( + context: PluginRuntimeContext = PluginRuntimeContext( + pluginID: "calendar", + resourceSubdirectory: "CalendarPluginResources" + ), + eventService: CalendarEventServicing? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.context = context + self.localization = localization + self.eventService = eventService ?? CalendarEventService(localization: localization) + self.viewModel = CalendarComponentViewModel( + eventService: self.eventService, + holidayProvider: .bundled(context: context), + localization: localization + ) + self.metadata = PluginMetadata( + id: "calendar", + title: localization.string("metadata.title", defaultValue: "日历"), + iconName: "calendar", + iconTint: Color(nsColor: .systemIndigo), + order: 15, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "查看日期、节假日和系统日程" + ) + ) + } + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + var componentPanelState: PluginComponentState { + PluginComponentState( + subtitle: metadata.defaultDescription, + isActive: false, + isEnabled: true, + isVisible: true, + errorMessage: nil + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.calendarEvents, + kind: .calendarFullAccess, + title: localization.string("permission.events.title", defaultValue: "系统日历事件"), + description: localization.string( + "permission.events.description", + defaultValue: "读取系统日历事件,用于在日历组件中显示当天日程。" + ) + ), + PluginPermissionRequirement( + id: PermissionID.calendarAutomation, + kind: .automation, + title: localization.string("permission.automation.title", defaultValue: "定位系统日历"), + description: localization.string( + "permission.automation.description", + defaultValue: "点击日期时需要控制系统日历应用,打开并定位到对应日期。" + ) + ) + ] + } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func makeView(context: PluginComponentContext) -> AnyView { + AnyView( + CalendarComponentView( + context: context, + viewModel: viewModel, + localization: localization + ) + ) + } + + func refresh() {} + + func panelSurfaceDidBecomeVisible(_ surface: PluginPanelSurface) { + guard surface == .component else { + return + } + + viewModel.start() + } + + func panelSurfaceDidBecomeHidden(_ surface: PluginPanelSurface) { + guard surface == .component else { + return + } + + viewModel.stop() + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + switch permissionID { + case PermissionID.calendarEvents: + return calendarEventsPermissionState + case PermissionID.calendarAutomation: + return PluginPermissionState( + isGranted: false, + footnote: localization.string( + "permission.automation.footnote", + defaultValue: "首次定位系统日历时 macOS 会请求控制“日历”的权限;若曾拒绝,请在系统设置的自动化中允许。" + ), + statusText: localization.string("permission.automation.status", defaultValue: "按需确认"), + statusSystemImage: "cursorarrow.click.2", + statusTone: .neutral + ) + default: + return PluginPermissionState(isGranted: true, footnote: nil) + } + } + + func handlePermissionAction(id: String) { + switch id { + case PermissionID.calendarEvents: + handleCalendarEventsPermissionAction() + case PermissionID.calendarAutomation: + openPrivacyPane(anchor: "Privacy_Automation") + default: + break + } + } + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + private var calendarEventsPermissionState: PluginPermissionState { + switch eventService.authorization { + case .fullAccess: + return PluginPermissionState(isGranted: true, footnote: nil) + case .notDetermined: + return PluginPermissionState( + isGranted: false, + footnote: localization.string( + "permission.events.notDetermined", + defaultValue: "点击请求授权后,系统会询问是否允许读取日历事件。" + ) + ) + case let .denied(message): + return PluginPermissionState( + isGranted: false, + footnote: localization.format( + "permission.events.denied", + defaultValue: "%@。可在系统设置的日历隐私项中重新允许。", + message + ) + ) + } + } + + private func handleCalendarEventsPermissionAction() { + Task { @MainActor [weak self] in + guard let self else { + return + } + + let authorization = await eventService.requestAccess() + if case .denied = authorization { + openPrivacyPane(anchor: "Privacy_Calendars") + } + + onStateChange?() + } + } + + private func openPrivacyPane(anchor: String) { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(anchor)") else { + return + } + + NSWorkspace.shared.open(url) + } +} diff --git a/Plugins/Calendar/Tests/CalendarComponentViewModelTests.swift b/Plugins/Calendar/Tests/CalendarComponentViewModelTests.swift new file mode 100644 index 0000000..28dff9e --- /dev/null +++ b/Plugins/Calendar/Tests/CalendarComponentViewModelTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import MacTools +@testable import CalendarPlugin + +@MainActor +final class CalendarComponentViewModelTests: XCTestCase { + func testOpenSelectsDayAndDelegatesToSystemCalendar() throws { + let calendar = Self.makeCalendar() + let service = MockCalendarEventService() + let targetDate = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 4, day: 29))) + let viewModel = CalendarComponentViewModel( + eventService: service, + holidayProvider: .empty, + calendar: calendar, + today: targetDate + ) + let targetDay = CalendarDayModel( + id: "20260429", + date: targetDate, + dayNumber: "29", + lunarText: "十三", + isInDisplayedMonth: true, + isToday: true, + isWeekend: false, + holidayKind: nil, + events: [] + ) + + viewModel.open(targetDay) + + XCTAssertEqual(viewModel.selectedDay?.id, "20260429") + XCTAssertEqual(service.openedDates, [targetDate]) + } + + private static func makeCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.firstWeekday = 1 + return calendar + } +} + +@MainActor +private final class MockCalendarEventService: CalendarEventServicing { + var authorization: CalendarEventAuthorization = .denied("未授权") + private(set) var openedDates: [Date] = [] + + func requestAccess() async -> CalendarEventAuthorization { + authorization + } + + func events(from startDate: Date, to endDate: Date) async throws -> [CalendarEventInput] { + [] + } + + func openSystemCalendar(at date: Date) { + openedDates.append(date) + } +} diff --git a/Plugins/Calendar/Tests/CalendarEventGrouperTests.swift b/Plugins/Calendar/Tests/CalendarEventGrouperTests.swift new file mode 100644 index 0000000..804b99b --- /dev/null +++ b/Plugins/Calendar/Tests/CalendarEventGrouperTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import MacTools +@testable import CalendarPlugin + +final class CalendarEventGrouperTests: XCTestCase { + func testGroupsAllDayTimedAndCrossDayEventsByOverlappingDates() throws { + let calendar = Self.makeCalendar() + let april1 = try Self.date(year: 2026, month: 4, day: 1, hour: 0, calendar: calendar) + let april2 = try Self.date(year: 2026, month: 4, day: 2, hour: 0, calendar: calendar) + let april3 = try Self.date(year: 2026, month: 4, day: 3, hour: 0, calendar: calendar) + let visibleDates = [april1, april2, april3] + let events = [ + CalendarEventInput( + id: "timed", + title: "Timed", + startDate: try Self.date(year: 2026, month: 4, day: 1, hour: 10, calendar: calendar), + endDate: try Self.date(year: 2026, month: 4, day: 1, hour: 11, calendar: calendar), + isAllDay: false, + color: .accent + ), + CalendarEventInput( + id: "all-day", + title: "All Day", + startDate: april2, + endDate: april3, + isAllDay: true, + color: .accent + ), + CalendarEventInput( + id: "cross-day", + title: "Cross Day", + startDate: try Self.date(year: 2026, month: 4, day: 1, hour: 23, calendar: calendar), + endDate: try Self.date(year: 2026, month: 4, day: 3, hour: 1, calendar: calendar), + isAllDay: false, + color: .accent + ) + ] + + let grouped = CalendarEventGrouper.group(events: events, visibleDates: visibleDates, calendar: calendar) + + XCTAssertEqual(grouped[april1]?.map(\.title), ["Timed", "Cross Day"]) + XCTAssertEqual(grouped[april2]?.map(\.title), ["All Day", "Cross Day"]) + XCTAssertEqual(grouped[april3]?.map(\.title), ["Cross Day"]) + } + + func testZeroDurationEventsRemainVisibleOnStartDay() throws { + let calendar = Self.makeCalendar() + let april2 = try Self.date(year: 2026, month: 4, day: 2, hour: 0, calendar: calendar) + let noon = try Self.date(year: 2026, month: 4, day: 2, hour: 12, calendar: calendar) + let event = CalendarEventInput( + id: "zero", + title: "Zero", + startDate: noon, + endDate: noon, + isAllDay: false, + color: .accent + ) + + let grouped = CalendarEventGrouper.group(events: [event], visibleDates: [april2], calendar: calendar) + + XCTAssertEqual(grouped[april2]?.map(\.title), ["Zero"]) + } + + private static func makeCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + calendar: Calendar + ) throws -> Date { + try XCTUnwrap(calendar.date(from: DateComponents(year: year, month: month, day: day, hour: hour))) + } +} diff --git a/Plugins/Calendar/Tests/CalendarEventServiceOpeningTests.swift b/Plugins/Calendar/Tests/CalendarEventServiceOpeningTests.swift new file mode 100644 index 0000000..27f4996 --- /dev/null +++ b/Plugins/Calendar/Tests/CalendarEventServiceOpeningTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import MacTools +@testable import CalendarPlugin + +@MainActor +final class CalendarEventServiceOpeningTests: XCTestCase { + func testOpeningScriptUsesCalendarDateLiteral() throws { + let calendar = Self.makeCalendar() + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = calendar.timeZone + formatter.dateStyle = .full + formatter.timeStyle = .none + let date = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 4, day: 29))) + + let script = CalendarEventService.openingScript(for: date, dateFormatter: formatter) + + XCTAssertTrue(script.contains("activate")) + XCTAssertTrue(script.contains("switch view to day view")) + XCTAssertTrue(script.contains("view calendar at date \"Wednesday, April 29, 2026\"")) + XCTAssertFalse(script.contains("set targetDate")) + } + + func testOpeningScriptEscapesDateTextForAppleScriptLiteral() throws { + let calendar = Self.makeCalendar() + let formatter = QuotedDateFormatter(calendar: calendar) + let date = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 4, day: 29))) + + let script = CalendarEventService.openingScript(for: date, dateFormatter: formatter) + + XCTAssertTrue(script.contains("view calendar at date \"A\\\"B\\\\C\"")) + } + + private static func makeCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} + +private final class QuotedDateFormatter: DateFormatter, @unchecked Sendable { + init(calendar: Calendar) { + super.init() + self.calendar = calendar + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func string(from date: Date) -> String { + "A\"B\\C" + } +} diff --git a/Plugins/Calendar/Tests/CalendarHolidayProviderTests.swift b/Plugins/Calendar/Tests/CalendarHolidayProviderTests.swift new file mode 100644 index 0000000..111d1ff --- /dev/null +++ b/Plugins/Calendar/Tests/CalendarHolidayProviderTests.swift @@ -0,0 +1,64 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import CalendarPlugin + +final class CalendarHolidayProviderTests: XCTestCase { + func testDecodesSupportedHolidayKindsAndIgnoresUnknownDates() throws { + let calendar = Self.makeCalendar() + let provider = try CalendarHolidayProvider( + data: #"{"2026":{"0101":2,"0104":1,"0201":9}}"#.data(using: .utf8)! + ) + + XCTAssertEqual( + provider.kind(for: try Self.date(year: 2026, month: 1, day: 1, calendar: calendar), calendar: calendar), + .holiday + ) + XCTAssertEqual( + provider.kind(for: try Self.date(year: 2026, month: 1, day: 4, calendar: calendar), calendar: calendar), + .workday + ) + XCTAssertNil(provider.kind(for: try Self.date(year: 2026, month: 2, day: 1, calendar: calendar), calendar: calendar)) + XCTAssertNil(provider.kind(for: try Self.date(year: 2027, month: 1, day: 1, calendar: calendar), calendar: calendar)) + } + + @MainActor + func testBundledProviderReadsFromPluginResourceContext() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let resourcesDirectory = directory.appendingPathComponent("CalendarPluginResources", isDirectory: true) + try FileManager.default.createDirectory(at: resourcesDirectory, withIntermediateDirectories: true) + try #"{"2026":{"0501":2}}"#.write( + to: resourcesDirectory.appendingPathComponent("ChinaHolidayOverrides.json"), + atomically: true, + encoding: .utf8 + ) + let bundle = try XCTUnwrap(Bundle(url: directory)) + let provider = CalendarHolidayProvider.bundled( + context: PluginRuntimeContext( + pluginID: "calendar", + resourceBundle: bundle, + resourceSubdirectory: "CalendarPluginResources" + ) + ) + let calendar = Self.makeCalendar() + + XCTAssertEqual( + provider.kind(for: try Self.date(year: 2026, month: 5, day: 1, calendar: calendar), calendar: calendar), + .holiday + ) + } + + private static func makeCalendar() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func date(year: Int, month: Int, day: Int, calendar: Calendar) throws -> Date { + try XCTUnwrap(calendar.date(from: DateComponents(year: year, month: month, day: day))) + } +} diff --git a/Plugins/Calendar/Tests/CalendarMonthModelBuilderTests.swift b/Plugins/Calendar/Tests/CalendarMonthModelBuilderTests.swift new file mode 100644 index 0000000..85f6282 --- /dev/null +++ b/Plugins/Calendar/Tests/CalendarMonthModelBuilderTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import MacTools +@testable import CalendarPlugin + +final class CalendarMonthModelBuilderTests: XCTestCase { + func testMonthAlwaysBuildsFortyTwoDaysFromConfiguredFirstWeekday() throws { + var calendar = Self.makeCalendar(firstWeekday: 2) + calendar.locale = Locale(identifier: "en_US_POSIX") + let builder = CalendarMonthModelBuilder(calendar: calendar) + let model = builder.makeMonth( + containing: try Self.date(year: 2026, month: 4, day: 15, calendar: calendar), + today: try Self.date(year: 2026, month: 4, day: 29, calendar: calendar) + ) + + XCTAssertEqual(model.days.count, 42) + XCTAssertEqual(model.days.first?.date, try Self.date(year: 2026, month: 3, day: 30, calendar: calendar)) + XCTAssertEqual(model.days.filter(\.isInDisplayedMonth).count, 30) + XCTAssertEqual(model.days.first(where: { $0.isToday })?.date, try Self.date(year: 2026, month: 4, day: 29, calendar: calendar)) + XCTAssertEqual(model.weekdaySymbols, ["一", "二", "三", "四", "五", "六", "日"]) + } + + func testWeekendAndHolidayOverrideCanCoexist() throws { + var calendar = Self.makeCalendar(firstWeekday: 1) + calendar.locale = Locale(identifier: "zh_Hans_CN") + let data = #"{"2026":{"0101":2,"0104":1}}"#.data(using: .utf8)! + let provider = try CalendarHolidayProvider(data: data) + let builder = CalendarMonthModelBuilder(calendar: calendar, holidayProvider: provider) + let model = builder.makeMonth( + containing: try Self.date(year: 2026, month: 1, day: 15, calendar: calendar), + today: try Self.date(year: 2026, month: 1, day: 2, calendar: calendar) + ) + + let holiday = try XCTUnwrap(model.days.first { $0.id == "20260101" }) + XCTAssertEqual(holiday.holidayKind, .holiday) + + let adjustedWorkday = try XCTUnwrap(model.days.first { $0.id == "20260104" }) + XCTAssertTrue(adjustedWorkday.isWeekend) + XCTAssertEqual(adjustedWorkday.holidayKind, .workday) + } + + func testHolidayBadgesAreHiddenForNonChineseLocales() throws { + var calendar = Self.makeCalendar(firstWeekday: 1) + calendar.locale = Locale(identifier: "en_US_POSIX") + let data = #"{"2026":{"0101":2,"0104":1}}"#.data(using: .utf8)! + let provider = try CalendarHolidayProvider(data: data) + let builder = CalendarMonthModelBuilder(calendar: calendar, holidayProvider: provider) + let model = builder.makeMonth( + containing: try Self.date(year: 2026, month: 1, day: 15, calendar: calendar), + today: try Self.date(year: 2026, month: 1, day: 2, calendar: calendar) + ) + + XCTAssertNil(model.days.first { $0.id == "20260101" }?.holidayKind) + XCTAssertNil(model.days.first { $0.id == "20260104" }?.holidayKind) + } + + func testVisibleEventsLimitKeepsFirstThreeEvents() throws { + let calendar = Self.makeCalendar(firstWeekday: 1) + let day = try Self.date(year: 2026, month: 4, day: 29, calendar: calendar) + let events = (0..<4).map { index in + CalendarEventSummary( + id: "event-\(index)", + title: "Event \(index)", + timeText: "全天", + startDate: day, + endDate: day, + isAllDay: true, + color: .accent + ) + } + let model = CalendarDayModel( + id: "20260429", + date: day, + dayNumber: "29", + lunarText: "十三", + isInDisplayedMonth: true, + isToday: true, + isWeekend: false, + holidayKind: nil, + events: events + ) + + XCTAssertEqual(model.visibleEvents.map(\.id), ["event-0", "event-1", "event-2"]) + } + + private static func makeCalendar(firstWeekday: Int) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.firstWeekday = firstWeekday + return calendar + } + + private static func date(year: Int, month: Int, day: Int, calendar: Calendar) throws -> Date { + try XCTUnwrap(calendar.date(from: DateComponents(year: year, month: month, day: day))) + } +} diff --git a/Plugins/Calendar/Tests/CalendarPluginIntegrationTests.swift b/Plugins/Calendar/Tests/CalendarPluginIntegrationTests.swift new file mode 100644 index 0000000..8231251 --- /dev/null +++ b/Plugins/Calendar/Tests/CalendarPluginIntegrationTests.swift @@ -0,0 +1,77 @@ +import SwiftUI +import XCTest +@testable import MacTools +@testable import CalendarPlugin + +@MainActor +final class CalendarPluginIntegrationTests: XCTestCase { + private let suiteName = "CalendarPluginIntegrationTests" + + override func tearDown() { + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + func testCalendarPluginAppearsOnlyInComponentPanelAtFullWidth() { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let host = PluginHost( + plugins: [CalendarPlugin()], + shortcutStore: ShortcutStore(userDefaults: defaults), + pluginDisplayPreferencesStore: PluginDisplayPreferencesStore(userDefaults: defaults), + globalShortcutManager: GlobalShortcutManager() + ) + + XCTAssertTrue(host.panelItems.isEmpty) + XCTAssertEqual(host.componentItems.map(\.id), ["calendar"]) + XCTAssertEqual(host.componentItems.first?.span.width, 4) + XCTAssertEqual(host.componentItems.first?.span.height, 37) + XCTAssertEqual(host.permissionCards.map(\.permissionID), ["calendar-events", "calendar-automation"]) + XCTAssertEqual(host.pluginConfigurationItems.map(\.id), ["calendar"]) + XCTAssertEqual(host.pluginConfigurationItems.first?.permissionCards.map(\.permissionID), [ + "calendar-events", + "calendar-automation" + ]) + } + + func testCalendarPermissionActionRequestsEventAccess() async { + let service = MockCalendarPermissionService( + authorization: .notDetermined, + requestResult: .fullAccess + ) + let plugin = CalendarPlugin(eventService: service) + let stateChanged = expectation(description: "calendar permission state changed") + plugin.onStateChange = { + stateChanged.fulfill() + } + + plugin.handlePermissionAction(id: "calendar-events") + + await fulfillment(of: [stateChanged], timeout: 1) + XCTAssertEqual(service.requestAccessCallCount, 1) + } +} + +@MainActor +private final class MockCalendarPermissionService: CalendarEventServicing { + var authorization: CalendarEventAuthorization + private let requestResult: CalendarEventAuthorization + private(set) var requestAccessCallCount = 0 + + init(authorization: CalendarEventAuthorization, requestResult: CalendarEventAuthorization) { + self.authorization = authorization + self.requestResult = requestResult + } + + func requestAccess() async -> CalendarEventAuthorization { + requestAccessCallCount += 1 + authorization = requestResult + return requestResult + } + + func events(from startDate: Date, to endDate: Date) async throws -> [CalendarEventInput] { + [] + } + + func openSystemCalendar(at date: Date) {} +} diff --git a/Plugins/Calendar/plugin.json b/Plugins/Calendar/plugin.json new file mode 100644 index 0000000..18609c4 --- /dev/null +++ b/Plugins/Calendar/plugin.json @@ -0,0 +1,70 @@ +{ + "id": "calendar", + "displayName": "日历", + "summary": "查看日期、节假日和系统日程", + "localizedMetadata": { + "ar": { + "displayName": "التقويم", + "summary": "عرض التواريخ والعطلات وأحداث التقويم." + }, + "de": { + "displayName": "Kalender", + "summary": "Termine, Feiertage und Kalenderereignisse anzeigen." + }, + "en": { + "displayName": "Calendar", + "summary": "View dates, holidays, and calendar events." + }, + "es": { + "displayName": "Calendario", + "summary": "Ver fechas, días festivos y eventos del calendario." + }, + "fr": { + "displayName": "Calendrier", + "summary": "Afficher les dates, les jours fériés et les événements du calendrier." + }, + "ja": { + "displayName": "カレンダー", + "summary": "日付、休日、カレンダーイベントを表示します。" + }, + "ko": { + "displayName": "캘린더", + "summary": "날짜, 공휴일 및 달력 이벤트를 확인하세요." + }, + "pt": { + "displayName": "Calendário", + "summary": "Veja datas, feriados e eventos da agenda." + }, + "ru": { + "displayName": "Календарь", + "summary": "Просмотр дат, праздников и событий календаря." + }, + "zh-Hans": { + "displayName": "日历", + "summary": "查看日期、节假日和系统日程" + }, + "zh-Hant": { + "displayName": "行事曆", + "summary": "查看日期、節假日和系統行程" + } + }, + "version": "1.0.11", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "Calendar.bundle", + "factoryClass": "CalendarPlugin.CalendarPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "CalendarPlugin" + }, + "capabilities": { + "primaryPanel": false, + "componentPanel": true, + "configuration": false + }, + "permissions": [ + "calendarFullAccess", + "automation" + ], + "category": "productivity" +} diff --git a/Plugins/Calendar/project.yml b/Plugins/Calendar/project.yml new file mode 100644 index 0000000..230356a --- /dev/null +++ b/Plugins/Calendar/project.yml @@ -0,0 +1,16 @@ +settings: + base: + OTHER_LDFLAGS: -framework EventKit + +bundle: + postBuildScripts: + - name: Copy CalendarPluginResources + script: | + set -euo pipefail + resource_dir="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/CalendarPluginResources" + rm -rf "$resource_dir" + ditto "$SRCROOT/Plugins/Calendar/CalendarPluginResources" "$resource_dir" + inputFiles: + - "$(SRCROOT)/Plugins/Calendar/CalendarPluginResources/ChinaHolidayOverrides.json" + outputFiles: + - "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/CalendarPluginResources/ChinaHolidayOverrides.json" diff --git a/Plugins/ClipboardClear/Bundle/ClipboardClearPluginBundleEntrypoint.swift b/Plugins/ClipboardClear/Bundle/ClipboardClearPluginBundleEntrypoint.swift new file mode 100644 index 0000000..7b76d4c --- /dev/null +++ b/Plugins/ClipboardClear/Bundle/ClipboardClearPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import ClipboardClearPlugin + +private let clipboardClearPluginFactoryAnchor: Any.Type = ClipboardClearPluginFactory.self diff --git a/Plugins/ClipboardClear/Resources/Localizable.xcstrings b/Plugins/ClipboardClear/Resources/Localizable.xcstrings new file mode 100644 index 0000000..024b5c3 --- /dev/null +++ b/Plugins/ClipboardClear/Resources/Localizable.xcstrings @@ -0,0 +1,219 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح الحافظة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zwischenablage leeren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Clipboard" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar portapapeles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer le Presse-papiers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリップボードをクリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클립보드 지우기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar área de transferência" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить буфер обмена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空剪贴板" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空剪貼板" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "امسح محتويات الحافظة الحالية بنقرة واحدة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen Sie den aktuellen Inhalt der Zwischenablage mit einem Klick." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear the current clipboard contents with one click." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borra el contenido actual del portapapeles con un solo clic." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacez le contenu actuel du presse-papiers en un seul clic." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワンクリックで現在のクリップボードの内容をクリアします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "한 번의 클릭으로 현재 클립보드 내용을 지웁니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpe o conteúdo atual da área de transferência com um clique." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистите текущее содержимое буфера обмена одним щелчком мыши." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一键清空当前剪贴板内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一鍵清空當前剪貼板內容" + } + } + } + }, + "panel.button.clear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지우기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/ClipboardClear/Sources/ClipboardClearPlugin.swift b/Plugins/ClipboardClear/Sources/ClipboardClearPlugin.swift new file mode 100644 index 0000000..81a7b50 --- /dev/null +++ b/Plugins/ClipboardClear/Sources/ClipboardClearPlugin.swift @@ -0,0 +1,109 @@ +import Foundation +import AppKit +import SwiftUI +import OSLog +import MacToolsPluginKit + +public final class ClipboardClearPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + ClipboardClearPluginProvider(context: context) + } +} + +@MainActor +private struct ClipboardClearPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [ClipboardClearPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class ClipboardClearPlugin: MacToolsPlugin, PluginPrimaryPanel { + static let pluginID = "clipboard-clear" + static let pluginOrder: Int = 120 + + private let pasteboard = NSPasteboard.general + private let localization: PluginLocalization + private var canClearClipboard = false + + let metadata: PluginMetadata + + let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + self.metadata = PluginMetadata( + id: ClipboardClearPlugin.pluginID, + title: localization.string("metadata.title", defaultValue: "清空剪贴板"), + iconName: "trash", + iconTint: .accentColor, + order: ClipboardClearPlugin.pluginOrder, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "一键清空当前剪贴板内容" + ) + ) + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .dismissBeforeHandling, + buttonTitleProvider: { localization.string("panel.button.clear", defaultValue: "清空") } + ) + canClearClipboard = Self.hasClipboardContents(in: pasteboard) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: metadata.defaultDescription, + isOn: false, + isExpanded: false, + isEnabled: canClearClipboard, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + func handleAction(_ action: PluginPanelAction) { + if case .invokeAction(let controlID) = action, controlID == "execute" { + pasteboard.clearContents() + syncPasteboardState(forceNotify: true) + Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.mactools", category: "ClipboardClearPlugin").info("Clipboard cleared") + } + } + + func refresh() { + syncPasteboardState(forceNotify: false) + } + + func permissionState(for permissionID: String) -> PluginPermissionState { PluginPermissionState(isGranted: false, footnote: nil) } + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + private func syncPasteboardState(forceNotify: Bool) { + let hasContents = Self.hasClipboardContents(in: pasteboard) + let didChange = canClearClipboard != hasContents + canClearClipboard = hasContents + + if forceNotify || didChange { + onStateChange?() + } + } + + nonisolated private static func hasClipboardContents(in pasteboard: NSPasteboard) -> Bool { + guard let items = pasteboard.pasteboardItems, !items.isEmpty else { + return false + } + + return items.contains { !$0.types.isEmpty } + } +} diff --git a/Plugins/ClipboardClear/plugin.json b/Plugins/ClipboardClear/plugin.json new file mode 100644 index 0000000..887135a --- /dev/null +++ b/Plugins/ClipboardClear/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "clipboard-clear", + "displayName": "清空剪贴板", + "summary": "一键清空当前剪贴板内容", + "localizedMetadata": { + "ar": { + "displayName": "مسح الحافظة", + "summary": "امسح محتويات الحافظة الحالية بنقرة واحدة." + }, + "de": { + "displayName": "Zwischenablage leeren", + "summary": "Löschen Sie den aktuellen Inhalt der Zwischenablage mit einem Klick." + }, + "en": { + "displayName": "Clear Clipboard", + "summary": "Clear the current clipboard contents with one click." + }, + "es": { + "displayName": "Borrar portapapeles", + "summary": "Borra el contenido actual del portapapeles con un solo clic." + }, + "fr": { + "displayName": "Vider le presse-papiers", + "summary": "Effacez le contenu actuel du presse-papiers en un seul clic." + }, + "ja": { + "displayName": "クリップボードを消去", + "summary": "ワンクリックで現在のクリップボードの内容をクリアします。" + }, + "ko": { + "displayName": "클립보드 지우기", + "summary": "한 번의 클릭으로 현재 클립보드 내용을 지웁니다." + }, + "pt": { + "displayName": "Limpar área de transferência", + "summary": "Limpe o conteúdo atual da área de transferência com um clique." + }, + "ru": { + "displayName": "Очистить буфер обмена", + "summary": "Очистите текущее содержимое буфера обмена одним щелчком мыши." + }, + "zh-Hans": { + "displayName": "清空剪贴板", + "summary": "一键清空当前剪贴板内容" + }, + "zh-Hant": { + "displayName": "清空剪貼簿", + "summary": "一鍵清空當前剪貼板內容" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "ClipboardClear.bundle", + "factoryClass": "ClipboardClearPlugin.ClipboardClearPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "ClipboardClearPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "storage" +} diff --git a/Plugins/DeviceBattery/Bundle/DeviceBatteryPluginBundleEntrypoint.swift b/Plugins/DeviceBattery/Bundle/DeviceBatteryPluginBundleEntrypoint.swift new file mode 100644 index 0000000..0116046 --- /dev/null +++ b/Plugins/DeviceBattery/Bundle/DeviceBatteryPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import DeviceBatteryPlugin + +private let deviceBatteryPluginFactoryAnchor: Any.Type = DeviceBatteryPluginFactory.self diff --git a/Plugins/DeviceBattery/Resources/Localizable.xcstrings b/Plugins/DeviceBattery/Resources/Localizable.xcstrings new file mode 100644 index 0000000..fc951b8 --- /dev/null +++ b/Plugins/DeviceBattery/Resources/Localizable.xcstrings @@ -0,0 +1,5718 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "accessibility.ring": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@، %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@、%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@, %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@,%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@,%@" + } + } + } + }, + "batteryDetail.remainingTime": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ %dh %dm" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ %dч %dм" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ %d小时%d分" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ %d小時%d分" + } + } + } + }, + "chargeState.charged": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "كامل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voll" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Full" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Completo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Complet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Completo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已充满" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已充滿" + } + } + } + }, + "chargeState.charging": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشحن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "充電中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "충전 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Зарядка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電中" + } + } + } + }, + "chargeState.invalid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مستوى البطارية غير صالح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiger Akkustand" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid battery level" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nivel de batería no válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Niveau de batterie invalide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なバッテリーレベル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 배터리 잔량" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nível de bateria inválido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверный уровень заряда батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "电量无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "電量無效" + } + } + } + }, + "chargeState.normal": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عادي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通常" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보통" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нормальный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正常" + } + } + } + }, + "chargeState.plugged": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "موصول" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Angeschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged in" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Branché" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "電源に接続されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "外接电源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "外接電源" + } + } + } + }, + "chargeState.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "component.chargingCase": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحالة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fall" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Case" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Caso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cas" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ケース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "케이스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Caso" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дело" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "充电盒" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "充電盒" + } + } + } + }, + "component.left": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اليسار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Links" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Left" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Izquierda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gauche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "왼쪽" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esquerda" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Слева" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左耳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左耳" + } + } + } + }, + "component.right": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Richtig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Right" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Derecha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Droite" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "右" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맞습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Certo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Верно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "右耳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "右耳" + } + } + } + }, + "configuration.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر تخطيط المكون والمصادر المرئية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie das Komponentenlayout und die sichtbaren Quellen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose the component layout and visible sources." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija el diseño del componente y las fuentes visibles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez la disposition des composants et les sources visibles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンポーネントのレイアウトと表示されるソースを選択します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성요소 레이아웃과 표시 소스를 선택하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha o layout do componente e as fontes visíveis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите макет компонента и видимые источники." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择组件面板布局和显示内容。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇組件面板佈局和顯示內容。" + } + } + } + }, + "deviceDetail.connected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متصل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已連接" + } + } + } + }, + "deviceKind.airPodsPart": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سماعات الأذن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohrhörer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Earbuds" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Auriculares" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écouteurs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イヤホン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이어폰" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fones de ouvido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наушники" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "耳机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "耳機" + } + } + } + }, + "deviceKind.bluetooth": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بلوتوث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "블루투스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝牙" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "藍牙" + } + } + } + }, + "deviceKind.magicAccessory": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ملحق أبل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Apple-Zubehör" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple accessory" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Accesorio Apple" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accessoire Apple" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Apple アクセサリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple 액세서리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acessório Apple" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аксессуар Apple" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Apple 外设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Apple 外設" + } + } + } + }, + "deviceKind.other": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الجهاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gerät" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Device" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dispositivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appareil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Dispositivo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройство" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設備" + } + } + } + }, + "deviceKind.rapooMouse": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Rapoo الماوس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rapoo Maus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rapoo mouse" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Rapoo ratón" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Souris Rapoo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Rapoo マウス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Rapoo 마우스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "rato Rapoo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Rapoo мышь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "雷柏鼠标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雷柏滑鼠" + } + } + } + }, + "deviceName.bluetoothFallback": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جهاز بلوتوث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth-Gerät" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth device" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dispositivo Bluetooth" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appareil Bluetooth" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth デバイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "블루투스 장치" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Dispositivo Bluetooth" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройство Bluetooth" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝牙设备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "藍牙設備" + } + } + } + }, + "deviceName.internalBattery": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بطارية ماك" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mac-Akku" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac battery" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Batería Mac" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac バッテリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mac 배터리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bateria do Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аккумулятор Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mac 电池" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Mac 電池" + } + } + } + }, + "deviceName.withPart": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + } + } + }, + "empty.openSettings": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح إعدادات النظام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open System Settings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir configuración del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム設定を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abra as configurações do sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки системы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开系统设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開系統設定" + } + } + } + }, + "empty.subtitle.noDevices": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توصيل جهاز بلوتوث أو ماوس مدعوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinden Sie ein Bluetooth-Gerät oder eine unterstützte Maus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a Bluetooth device or supported mouse" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectar un dispositivo Bluetooth o un mouse compatible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectez un appareil Bluetooth ou une souris prise en charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth デバイスまたはサポートされているマウスを接続します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "블루투스 장치 또는 지원되는 마우스 연결" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conecte um dispositivo Bluetooth ou mouse compatível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключите устройство Bluetooth или поддерживаемую мышь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接蓝牙设备或厂商 HID 鼠标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連接藍牙設備或廠商 HID 滑鼠" + } + } + } + }, + "empty.subtitle.permissionDenied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح بالوصول لقراءة أجهزة الماوس المدعومة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriff zum Lesen unterstützter Mäuse erlauben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow access to read supported mice" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir el acceso para leer ratones compatibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser l'accès aux souris prises en charge en lecture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているマウスの読み取りアクセスを許可します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 마우스 읽기에 대한 액세스 허용" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permitir acesso para leitura de mouses compatíveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить доступ к чтению поддерживаемых мышей" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权后可读取厂商 HID 鼠标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權後可讀取廠商 HID 滑鼠" + } + } + } + }, + "empty.subtitle.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من طاقة النظام وأجهزة البلوتوث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfen der Systemleistung und der Bluetooth-Geräte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking system power and Bluetooth devices" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobación de la alimentación del sistema y de los dispositivos Bluetooth" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification de l'alimentation du système et des appareils Bluetooth" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム電源と Bluetooth デバイスを確認しています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 전원 및 블루투스 장치 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando a energia do sistema e os dispositivos Bluetooth" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка питания системы и устройств Bluetooth" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在查询系统电源与蓝牙设备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在查詢系統電源與藍牙設備" + } + } + } + }, + "empty.title.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذرت قراءة مستويات البطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akkustand konnte nicht gelesen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could Not Read Battery Levels" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron leer los niveles de batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire les niveaux de batterie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリーレベルを読み取れませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 잔량을 읽을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler os níveis da bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось определить уровень заряда батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取失敗" + } + } + } + }, + "empty.title.noDevices": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد مستويات للبطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Batteriestände" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Battery Levels" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin niveles de batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun niveau de batterie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー残量がありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 수준 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sem níveis de bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет уровня заряда батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无设备电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無設備電量" + } + } + } + }, + "empty.title.permissionDenied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مراقبة المدخلات مطلوبة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabeüberwachung erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Input Monitoring Required" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere monitoreo de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillance des entrées requise" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリングが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링 필요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de entrada obrigatório" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется мониторинг ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要输入监控权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要輸入監控權限" + } + } + } + }, + "empty.title.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قراءة مستويات البطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batteriestand ablesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading battery levels" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lectura de niveles de batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture des niveaux de batterie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー残量の読み取り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 잔량 읽기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Leitura dos níveis da bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение уровня заряда батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在读取电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在讀取電量" + } + } + } + }, + "help.levelAndState": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "help.parent": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ذات صلة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwandt: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Related: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Relacionado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexe : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "関連: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관련: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Relacionado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Связано: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关联:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關聯:%@" + } + } + } + }, + "help.source": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المصدر: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Quelle: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Source: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "出典: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출처: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fonte: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "来源:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "來源:%@" + } + } + } + }, + "help.updated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التحديث: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisiert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Updated: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新日: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлено: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新:%@" + } + } + } + }, + "layout.grid.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار جهاز واحد لكل صف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Gerät pro Zeile anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show one device per row" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar un dispositivo por fila" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher un appareil par ligne" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 行に 1 つのデバイスを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "행당 하나의 기기 표시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mostrar um dispositivo por linha" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать по одному устройству в строке" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按设备逐行显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按設備逐行顯示" + } + } + } + }, + "layout.grid.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "القائمة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liste" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "List" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lista" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Список" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列表" + } + } + } + }, + "layout.list.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رنين متعدد الأجهزة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mehrere Geräte klingeln" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Multi-device rings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Anillos multidispositivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sonneries multi-appareils" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マルチデバイスリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다중 장치 링" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Toques para vários dispositivos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Звонки для нескольких устройств" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "多设备圆环" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "多設備圓環" + } + } + } + }, + "layout.list.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خواتم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ringe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Anillos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Anneaux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "반지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Anéis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кольца" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "圆环" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圓環" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض مستويات البطارية لجهاز Mac وأجهزة Apple المحمولة وملحقات Bluetooth وأجهزة الماوس Rapoo." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigen Sie den Akkustand Ihres Mac, mobiler Apple-Geräte, von Bluetooth-Zubehör und Rapoo-Mäusen an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View battery levels for your Mac, Apple mobile devices, Bluetooth accessories, and Rapoo mice." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Consulta la batería del Mac, dispositivos móviles Apple, accesorios Bluetooth y ratones Rapoo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affichez la batterie du Mac, des appareils mobiles Apple, des accessoires Bluetooth et des souris Rapoo." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac、Apple モバイルデバイス、Bluetooth アクセサリ、Rapoo マウスのバッテリー残量を表示します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mac, Apple 모바일 기기, Bluetooth 액세서리 및 Rapoo 마우스의 배터리를 확인합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Veja a bateria do Mac, dispositivos móveis Apple, acessórios Bluetooth e mouses Rapoo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просматривайте заряд Mac, мобильных устройств Apple, Bluetooth-аксессуаров и мышей Rapoo." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看 Mac、Apple 移动设备、蓝牙外设和雷柏鼠标电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看 Mac、Apple 行動裝置、藍牙外設和雷柏滑鼠電量" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بطارية الجهاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gerätebatterie" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Device Battery" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Batería del dispositivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie de l'appareil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスのバッテリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 배터리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bateria do dispositivo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аккумулятор устройства" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設備電量" + } + } + } + }, + "notification.lowBattery.multiple.item": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + } + } + }, + "notification.lowBattery.multiple.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d الأجهزة منخفضة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Geräte sind niedrig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d devices are low" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d dispositivos están bajos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les appareils %d sont faibles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d デバイスの残量が少ない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 장치 부족" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d dispositivos estão com carga baixa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройства %d низкий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 台设备电量偏低" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 臺設備電量偏低" + } + } + } + }, + "notification.lowBattery.single.body": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ يقع عند %@، أسفل %d%%." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ liegt bei %@, unter %d%%." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is at %@, below %d%%." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ está en %@, debajo de %d%%." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est à %@, en dessous de %d%%." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ is at %@, below %d%%." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은(는) %@, %d%% 아래에 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ está em %@, abaixo de %d%%." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ находится на %@, ниже %d%%." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 当前电量 %@,已低于 %d%%。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 當前電量 %@,已低於 %d%%。" + } + } + } + }, + "notification.lowBattery.single.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بطارية الجهاز منخفضة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gerätebatterie schwach" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Device battery low" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Batería del dispositivo baja" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie de l'appareil faible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスのバッテリー残量が少なくなっています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 배터리 부족" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bateria do dispositivo fraca" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разряд батареи устройства" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备电量偏低" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設備電量偏低" + } + } + } + }, + "overflow.moreDevices": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d المزيد من الأجهزة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d weitere Geräte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d more devices" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d más dispositivos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d appareils supplémentaires" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 台のデバイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 추가 기기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d mais dispositivos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d больше устройств" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还有 %d 台设备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "還有 %d 臺設備" + } + } + } + }, + "permission.inputMonitoring.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يقرأ مستوى البطارية وحالة الشحن والطراز والاسم من أجهزة الماوس المدعومة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liest Akkustand, Ladezustand, Modell und Namen von unterstützten Mäusen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reads battery level, charging state, model, and name from supported mice." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lee el nivel de la batería, el estado de carga, el modelo y el nombre de los ratones compatibles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lit le niveau de la batterie, l'état de charge, le modèle et le nom des souris prises en charge." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているマウスからバッテリー レベル、充電状態、モデル、名前を読み取ります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 마우스에서 배터리 잔량, 충전 상태, 모델 및 이름을 읽습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lê o nível da bateria, estado de carregamento, modelo e nome dos mouses compatíveis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Считывает уровень заряда батареи, состояние зарядки, модель и название поддерживаемых мышей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用于读取已适配厂商 HID 鼠标的电量、充电状态、设备型号和名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用於讀取已適配廠商 HID 滑鼠的電量、充電狀態、設備型號和名稱。" + } + } + } + }, + "permission.inputMonitoring.granted.connected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "MacTools يمكنه استخدام مراقبة الإدخال وقد قرأ معلومات الماوس المدعومة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "MacTools kann die Eingabeüberwachung verwenden und hat unterstützte Mausinformationen gelesen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "MacTools can use Input Monitoring and has read supported mouse information." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "MacTools puede usar la supervisión de entrada y ha leído la información del mouse compatible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "MacTools peut utiliser la surveillance des entrées et a lu les informations de souris prises en charge." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "MacTools は入力モニタリングを使用でき、サポートされているマウス情報を読み取っています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "MacTools은(는) 입력 모니터링을 사용할 수 있으며 지원되는 마우스 정보를 읽었습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "MacTools pode usar o monitoramento de entrada e leu as informações suportadas do mouse." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "MacTools может использовать мониторинг ввода и прочитал поддерживаемую информацию о мыши." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已允许 MacTools 使用输入监控,并已读取到厂商 HID 鼠标信息。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已允許 MacTools 使用輸入監控,並已讀取到廠商 HID 滑鼠信息。" + } + } + } + }, + "permission.inputMonitoring.granted.default": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "MacTools استخدام مراقبة الإدخال لقراءة أجهزة الماوس المدعومة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "MacTools kann Input Monitoring verwenden, um unterstützte Mäuse zu lesen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "MacTools can use Input Monitoring to read supported mice." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "MacTools puede usar la supervisión de entrada para leer ratones compatibles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "MacTools peut utiliser la surveillance des entrées pour lire les souris prises en charge." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "MacTools は、入力モニタリングを使用してサポートされているマウスを読み取ることができます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "MacTools은 입력 모니터링을 사용하여 지원되는 마우스를 읽을 수 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "MacTools pode usar o monitoramento de entrada para ler mouses compatíveis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "MacTools может использовать мониторинг ввода для чтения поддерживаемых мышей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已允许 MacTools 使用输入监控,可读取已适配厂商 HID 鼠标信息。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已允許 MacTools 使用輸入監控,可讀取已適配廠商 HID 滑鼠信息。" + } + } + } + }, + "permission.inputMonitoring.granted.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "MacTools يمكنه استخدام مراقبة الإدخال. فشلت آخر قراءة مدعومة للماوس: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "MacTools kann die Eingabeüberwachung verwenden. Der letzte unterstützte Mauslesevorgang ist fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "MacTools can use Input Monitoring. The latest supported mouse read failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "MacTools puede utilizar la supervisión de entrada. La última lectura del mouse admitida falló: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "MacTools peut utiliser la surveillance des entrées. La dernière lecture de souris prise en charge a échoué : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "MacTools は入力モニタリングを使用できます。サポートされている最新のマウスの読み取りに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "MacTools은 입력 모니터링을 사용할 수 있습니다. 지원되는 최근 마우스 읽기 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "MacTools pode usar o monitoramento de entrada. A última leitura do mouse compatível falhou: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "MacTools может использовать мониторинг ввода. Не удалось выполнить последнее поддерживаемое чтение с помощью мыши: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已允许 MacTools 使用输入监控。最近读取厂商 HID 鼠标失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已允許 MacTools 使用輸入監控。最近讀取廠商 HID 滑鼠失敗:%@" + } + } + } + }, + "permission.inputMonitoring.openSettingsFootnote": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتقل إلى إعدادات النظام > الخصوصية والأمان > مراقبة الإدخال واسمح بـ MacTools." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gehen Sie zu Systemeinstellungen > Datenschutz und Sicherheit > Eingabeüberwachung und erlauben Sie MacTools." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Go to System Settings > Privacy & Security > Input Monitoring and allow MacTools." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vaya a Configuración del sistema > Privacidad y seguridad > Monitoreo de entrada y permita MacTools." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accédez à Paramètres système > Confidentialité et sécurité > Surveillance des entrées et autorisez MacTools." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[システム設定] > [プライバシーとセキュリティ] > [入力モニタリング] に移動し、MacTools を許可します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 설정 > 개인정보 보호 및 보안 > 입력 모니터링으로 이동하여 MacTools을(를) 허용하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Vá para Configurações do sistema > Privacidade e segurança > Monitoramento de entrada e permita MacTools." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перейдите в «Настройки системы» > «Конфиденциальность и безопасность» > «Мониторинг ввода» и разрешите MacTools." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前往系统设置 → 隐私与安全性 → 输入监控,允许 MacTools。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "前往系統設定 → 隱私權與安全性 → 輸入監控,允許 MacTools。" + } + } + } + }, + "permission.inputMonitoring.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مراقبة الإدخال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabeüberwachung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Input Monitoring" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Monitoreo de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillance des entrées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリング" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de entrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入监控授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入監控授權" + } + } + } + }, + "permission.inputMonitoring.unauthorized.default": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "امنح هذا الإذن لقراءة مستويات بطارية الماوس المدعومة. ولا تزال بطارية Mac المدمجة وأجهزة Bluetooth الخاصة بالنظام وملحقات Apple تعمل بدونها." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gewähren Sie diese Berechtigung, um den unterstützten Batteriestand der Maus zu lesen. Der eingebaute Mac-Akku, System-Bluetooth-Geräte und Apple-Zubehör funktionieren auch ohne ihn." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant this permission to read supported mouse battery levels. The built-in Mac battery, system Bluetooth devices, and Apple accessories still work without it." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conceda este permiso para leer los niveles de batería del mouse admitidos. La batería integrada del Mac, los dispositivos Bluetooth del sistema y los accesorios de Apple siguen funcionando sin ella." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accordez cette autorisation pour lire les niveaux de batterie de la souris pris en charge. La batterie Mac intégrée, les appareils Bluetooth du système et les accessoires Apple fonctionnent toujours sans elle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているマウスのバッテリー レベルを読み取るために、このアクセス許可を付与します。 Mac の内蔵バッテリー、システム Bluetooth デバイス、Apple アクセサリは、バッテリーなしでも動作します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 마우스 배터리 수준을 읽으려면 이 권한을 부여하세요. 내장 Mac 배터리, 시스템 Bluetooth 장치 및 Apple 액세서리는 이 배터리가 없어도 계속 작동합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conceda esta permissão para ler os níveis de bateria do mouse suportados. A bateria integrada do Mac, os dispositivos Bluetooth do sistema e os acessórios da Apple ainda funcionam sem ela." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предоставьте это разрешение для чтения поддерживаемых уровней заряда батареи мыши. Встроенный аккумулятор Mac, системные устройства Bluetooth и аксессуары Apple по-прежнему работают без него." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权后可读取厂商 HID 鼠标信息;未授权不影响 Mac 内置电池、系统蓝牙设备和 Apple 外设电量。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權後可讀取廠商 HID 滑鼠信息;未授權不影響 Mac 內置電池、系統藍牙設備和 Apple 外設電量。" + } + } + } + }, + "permission.inputMonitoring.unauthorized.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "امنح هذا الإذن لقراءة مستويات بطارية الماوس المدعومة. فشلت القراءة الأخيرة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erteilen Sie diese Berechtigung, um den unterstützten Batteriestand der Maus zu lesen. Letzter Lesevorgang fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Grant this permission to read supported mouse battery levels. Last read failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Otorgue este permiso para leer los niveles de batería del mouse admitidos. Última lectura fallida: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accordez cette autorisation pour lire les niveaux de batterie de la souris pris en charge. Échec de la dernière lecture : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているマウスのバッテリー レベルを読み取るために、このアクセス許可を付与します。前回の読み取りに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 마우스 배터리 수준을 읽으려면 이 권한을 부여하세요. 마지막 읽기 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conceda esta permissão para ler os níveis de bateria do mouse suportados. Falha na última leitura: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предоставьте это разрешение для чтения поддерживаемых уровней заряда батареи мыши. Последнее чтение не удалось: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权后可读取厂商 HID 鼠标信息。最近读取失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權後可讀取廠商 HID 滑鼠信息。最近讀取失敗:%@" + } + } + } + }, + "rapoo.detail.idle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتظار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待檢測" + } + } + } + }, + "rapoo.detail.noDevice": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشافه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No detectado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出されません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "감지되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não detectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не обнаружено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到" + } + } + } + }, + "rapoo.detail.permissionDenied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مراقبة الإدخال مطلوبة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabeüberwachung erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Input Monitoring required" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere monitoreo de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillance des entrées requise" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリングが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링 필요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de entrada necessário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется мониторинг ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要输入监控权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要輸入監控權限" + } + } + } + }, + "rapoo.detail.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الكشف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erkennen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Detecting" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detectando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "감지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Detectando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обнаружение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測中" + } + } + } + }, + "rapoo.detail.waitingForReport": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "في انتظار تقرير البطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf Batteriebericht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for battery report" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando informe de batería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente du rapport de batterie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリーレポートを待っています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 보고서를 기다리는 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aguardando relatório de bateria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание отчета о заряде батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待电量上报" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待電量上報" + } + } + } + }, + "rapoo.deviceName.fallback": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Rapoo الماوس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rapoo Maus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rapoo mouse" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Rapoo ratón" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Souris Rapoo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Rapoo マウス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Rapoo 마우스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "rato Rapoo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Rapoo мышь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Rapoo 鼠标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Rapoo 滑鼠" + } + } + } + }, + "rapoo.error.openFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل في فتح HID: 0x%@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "HID konnte nicht geöffnet werden: 0x%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to open HID: 0x%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al abrir HID: 0x%@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'ouverture du HID : 0x%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "HID を開けませんでした: 0x%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "HID를 열지 못했습니다: 0x%@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao abrir o HID: 0x%@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось открыть HID: 0x%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "HID 打开失败:0x%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "HID 打開失敗:0x%@" + } + } + } + }, + "settings.layout.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطيط المكون" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Komponentenlayout" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Component Layout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diseño de componentes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disposition des composants" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンポーネントのレイアウト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성요소 레이아웃" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Layout do componente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Схема компонентов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "组件布局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "組件佈局" + } + } + } + }, + "settings.notification.lowBattery.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسال إشعار النظام عندما يقل الجهاز عن الحد الأدنى." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Senden Sie eine Systembenachrichtigung, wenn ein Gerät den Schwellenwert unterschreitet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send a system notification when a device falls below the threshold." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar una notificación del sistema cuando un dispositivo caiga por debajo del umbral." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer une notification système lorsqu'un appareil tombe en dessous du seuil." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスがしきい値を下回ったときにシステム通知を送信します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기가 임계값 아래로 떨어지면 시스템 알림을 보냅니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Enviar uma notificação do sistema quando um dispositivo ficar abaixo do limite." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправлять системное уведомление, когда значение устройства падает ниже порогового значения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备电量低于阈值时发送系统通知。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設備電量低於閾值時發送系統通知。" + } + } + } + }, + "settings.notification.lowBattery.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسال إشعارات بانخفاض طاقة البطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen über niedrigen Batteriestand senden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Send low battery notifications" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar notificaciones de batería baja" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer des notifications de batterie faible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー残量低下通知を送信する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 부족 알림 보내기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Enviar notificações de bateria fraca" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправлять уведомления о низком заряде батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送低电量通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "發送低電量通知" + } + } + } + }, + "settings.notification.threshold.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنبيه عندما يكون مستوى البطارية أقل من هذه النسبة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warnung, wenn der Akkustand unter diesem Prozentsatz liegt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Alert when battery level is below this percentage." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Avisa cuando el nivel de la batería está por debajo de este porcentaje." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Alerte lorsque le niveau de la batterie est inférieur à ce pourcentage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー残量がこのパーセンテージを下回ると警告します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 수준이 이 백분율 미만일 때 경고합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alerta quando o nível da bateria estiver abaixo dessa porcentagem." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оповещать, когда уровень заряда батареи опускается ниже этого процента." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低于此百分比时提醒。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低於此百分比時提醒。" + } + } + } + }, + "settings.notification.threshold.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حد الإشعارات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungsschwellenwert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification threshold" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Umbral de notificación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuil de notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知しきい値" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알림 기준" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limite de notificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Порог уведомления" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通知阈值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通知閾值" + } + } + } + }, + "settings.notification.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إشعارات انخفاض مستوى البطارية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benachrichtigungen bei niedrigem Batteriestand" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Low Battery Notifications" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones de batería baja" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications de batterie faible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バッテリー残量低下の通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "배터리 부족 알림" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Notificações de bateria fraca" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления о низком заряде батареи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "低电量通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "低電量通知" + } + } + } + }, + "settings.source.appleMobile.description": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "قراءة أجهزة Apple المحمولة الموثوقة والمتصلة عبر USB أو Wi-Fi." } }, + "de": { "stringUnit": { "state": "translated", "value": "Liest vertrauenswürdige Apple-Mobilgeräte über USB oder WLAN." } }, + "en": { "stringUnit": { "state": "translated", "value": "Read trusted Apple mobile devices connected over USB or Wi-Fi." } }, + "es": { "stringUnit": { "state": "translated", "value": "Lee dispositivos móviles Apple de confianza conectados por USB o Wi-Fi." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Lit les appareils mobiles Apple approuvés connectés par USB ou Wi-Fi." } }, + "ja": { "stringUnit": { "state": "translated", "value": "USB または Wi-Fi で接続された信頼済み Apple モバイルデバイスを読み取ります。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "USB 또는 Wi-Fi로 연결된 신뢰하는 Apple 모바일 기기를 읽습니다." } }, + "pt": { "stringUnit": { "state": "translated", "value": "Lê dispositivos móveis Apple confiáveis conectados por USB ou Wi-Fi." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Читает доверенные мобильные устройства Apple через USB или Wi-Fi." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "读取已信任并通过 USB 或 Wi-Fi 连接的 Apple 移动设备。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "讀取已信任並透過 USB 或 Wi-Fi 連接的 Apple 行動裝置。" } } + } + }, + "settings.source.appleMobile.title": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "iPhone وiPad وApple Watch" } }, + "de": { "stringUnit": { "state": "translated", "value": "iPhone, iPad und Apple Watch" } }, + "en": { "stringUnit": { "state": "translated", "value": "iPhone, iPad, and Apple Watch" } }, + "es": { "stringUnit": { "state": "translated", "value": "iPhone, iPad y Apple Watch" } }, + "fr": { "stringUnit": { "state": "translated", "value": "iPhone, iPad et Apple Watch" } }, + "ja": { "stringUnit": { "state": "translated", "value": "iPhone、iPad、Apple Watch" } }, + "ko": { "stringUnit": { "state": "translated", "value": "iPhone, iPad 및 Apple Watch" } }, + "pt": { "stringUnit": { "state": "translated", "value": "iPhone, iPad e Apple Watch" } }, + "ru": { "stringUnit": { "state": "translated", "value": "iPhone, iPad и Apple Watch" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "iPhone、iPad 与 Apple Watch" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "iPhone、iPad 與 Apple Watch" } } + } + }, + "settings.source.bluetooth.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قراءة أجهزة Bluetooth المرئية، وبطاريات مكونات AirPods، وملحقات Magic." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liest sichtbare Bluetooth-Geräte, AirPods-Komponentenbatterien und Magic-Zubehör." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reads visible Bluetooth devices, AirPods component batteries, and Magic accessories." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lee dispositivos Bluetooth visibles, baterías de componentes de AirPods y accesorios Magic." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lit les appareils Bluetooth visibles, les batteries des composants AirPods et les accessoires Magic." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "目に見える Bluetooth デバイス、AirPods コンポーネントのバッテリー、Magic アクセサリを読み取ります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "눈에 보이는 Bluetooth 기기, AirPods 구성 요소 배터리 및 Magic 액세서리를 읽습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lê dispositivos Bluetooth visíveis, baterias componentes de AirPods e acessórios Magic." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Считывает видимые устройства Bluetooth, аккумуляторы компонентов AirPods и аксессуары Magic." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取系统可见的蓝牙设备、AirPods 分体电量和 Magic 外设。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取系統可見的藍牙設備、AirPods 分體電量和 Magic 外設。" + } + } + } + }, + "settings.source.bluetooth.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ملحقات البلوتوث وأبل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth- und Apple-Zubehör" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth and Apple accessories" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Accesorios Bluetooth y Apple" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accessoires Bluetooth et Apple" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth および Apple アクセサリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "블루투스 및 Apple 액세서리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acessórios Bluetooth e Apple" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Bluetooth и аксессуары Apple" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "蓝牙与 Apple 外设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "藍牙與 Apple 外設" + } + } + } + }, + "settings.source.internal.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر بطارية جهاز Mac هذا وحالة الشحن والوقت المتبقي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigt den Akku, den Ladezustand und die verbleibende Zeit dieses Mac an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows this Mac's battery, charging state, and remaining time." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra la batería de esta Mac, el estado de carga y el tiempo restante." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affiche la batterie de ce Mac, l'état de charge et le temps restant." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac のバッテリー、充電状態、残り時間を表示します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac의 배터리, 충전 상태 및 남은 시간을 표시합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mostra a bateria, o estado de carregamento e o tempo restante deste Mac." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывает уровень заряда аккумулятора Mac, состояние зарядки и оставшееся время." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示本机电量、充电状态和剩余时间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示本機電量、充電狀態和剩餘時間。" + } + } + } + }, + "settings.source.internal.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بطارية Mac مدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingebauter Mac-Akku" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in Mac battery" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Batería Mac integrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie Mac intégrée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵 Mac バッテリー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 Mac 배터리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bateria integrada para Mac" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный аккумулятор Mac" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mac 内置电池" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Mac 內置電池" + } + } + } + }, + "settings.source.rapoo.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يقرأ مستوى البطارية وحالة الشحن والطراز والاسم من أجهزة الماوس المدعومة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liest Akkustand, Ladezustand, Modell und Namen von unterstützten Mäusen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reads battery level, charging state, model, and name from supported mice." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lee el nivel de la batería, el estado de carga, el modelo y el nombre de los ratones compatibles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lit le niveau de la batterie, l'état de charge, le modèle et le nom des souris prises en charge." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているマウスからバッテリー レベル、充電状態、モデル、名前を読み取ります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 마우스에서 배터리 잔량, 충전 상태, 모델 및 이름을 읽습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lê o nível da bateria, o estado de carregamento, o modelo e o nome dos mouses compatíveis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Считывает уровень заряда батареи, состояние зарядки, модель и название поддерживаемых мышей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取已适配鼠标的电量、充电状态、设备型号和名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取已適配滑鼠的電量、充電狀態、設備型號和名稱。" + } + } + } + }, + "settings.source.rapoo.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الفئران المدعومة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unterstützte Mäuse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Supported mice" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ratones compatibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Souris prises en charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされているマウス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되는 마우스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mouses compatíveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поддерживаемые мыши" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "厂商 HID 鼠标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廠商 HID 滑鼠" + } + } + } + }, + "settings.sources.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المصادر المرئية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichtbare Quellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Visible Sources" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuentes visibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sources visibles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "可視ソース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시되는 소스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fontes visíveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Видимые источники" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示內容" + } + } + } + }, + "snapshot.error.permissionDenied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن الوصول إلى واجهة Rapoo HID. اسمح لـ MacTools باستخدام مراقبة الإدخال في إعدادات النظام." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kann nicht auf die HID-Schnittstelle Rapoo zugreifen. Erlauben Sie MacTools, die Eingabeüberwachung in den Systemeinstellungen zu verwenden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cannot access the Rapoo HID interface. Allow MacTools to use Input Monitoring in System Settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede acceder a la interfaz HID Rapoo. Permita que MacTools utilice la supervisión de entrada en la configuración del sistema." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'accéder à l'interface Rapoo HID. Autorisez MacTools à utiliser la surveillance des entrées dans les paramètres système." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Rapoo HID インターフェイスにアクセスできません。 MacTools がシステム設定で入力モニタリングを使用できるようにします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Rapoo HID 인터페이스에 액세스할 수 없습니다. MacTools이(가) 시스템 설정에서 입력 모니터링을 사용하도록 허용하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível acessar a interface HID Rapoo. Permita que MacTools use o monitoramento de entrada nas configurações do sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно получить доступ к HID-интерфейсу Rapoo. Разрешите MacTools использовать мониторинг ввода в настройках системы." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法访问雷柏 HID 接口,请在系统设置中允许 MacTools 使用输入监控。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法訪問雷柏 HID 接口,請在系統設定中允許 MacTools 使用輸入監控。" + } + } + } + }, + "snapshot.subtitle.idle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "في انتظار الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf Scan" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting to scan" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando para escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente de numérisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンを待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 대기 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aguardando digitalização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание сканирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待檢測" + } + } + } + }, + "snapshot.subtitle.noDevices": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مستويات بطارية قابلة للعرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine anzeigbaren Akkustände gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No displayable battery levels found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontraron niveles de batería visualizables" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun niveau de batterie affichable trouvé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示可能なバッテリー レベルが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시 가능한 배터리 잔량을 찾을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum nível de bateria visível encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображаемые уровни заряда батареи не обнаружены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到可显示电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到可顯示電量" + } + } + } + }, + "snapshot.subtitle.permissionDenied": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مطلوب مراقبة الإدخال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingabeüberwachung erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Input Monitoring required" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere monitoreo de entrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillance des entrées requise" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力モニタリングが必要です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 모니터링 필요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitoramento de entrada necessário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется мониторинг ввода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要输入监控权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要輸入監控權限" + } + } + } + }, + "snapshot.subtitle.ready": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d الأجهزة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Geräte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d devices" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d dispositivos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appareils %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d デバイス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 장치" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d dispositivos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "устройства %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 台设备" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 臺設備" + } + } + } + }, + "snapshot.subtitle.readyWithLowBattery": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d الأجهزة، %d منخفض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Geräte, %d niedrig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d devices, %d low" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d dispositivos, %d bajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appareils %d, %d faible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d デバイス、%d 低" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 장치, %d 낮음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d dispositivos, %d baixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Устройства %d, %d низкий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 台设备,%d 台低电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 臺設備,%d 臺低電量" + } + } + } + }, + "snapshot.subtitle.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قراءة مستويات بطارية الجهاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Akkustand des Geräts lesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading device battery levels" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lectura de niveles de batería del dispositivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture des niveaux de batterie de l'appareil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスのバッテリー残量を読み取ります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기기 배터리 잔량 읽기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Leitura dos níveis de bateria do dispositivo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение уровня заряда батареи устройства" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在读取设备电量" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在讀取設備電量" + } + } + } + }, + "time.notUpdated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم التحديث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht aktualisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not updated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No actualizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non mis à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新されていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não atualizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не обновляется" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未更新" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryComponentView.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryComponentView.swift new file mode 100644 index 0000000..618df90 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryComponentView.swift @@ -0,0 +1,910 @@ +import AppKit +import SwiftUI +import MacToolsPluginKit + +struct DeviceBatteryComponentView: View { + @ObservedObject var viewModel: DeviceBatteryViewModel + @ObservedObject var store: DeviceBatteryStore + let localization: PluginLocalization + let openSettings: () -> Void + + var body: some View { + Group { + if visibleItems.isEmpty { + emptyState + } else { + switch store.layoutMode { + case .grid: + DeviceBatteryListCard( + items: Array(visibleItems.prefix(DeviceBatteryLayout.maximumListItems)), + totalCount: visibleItems.count, + localization: localization + ) + case .list: + DeviceBatteryGaugeGrid( + items: Array(visibleItems.prefix(DeviceBatteryLayout.maximumGaugeItems)), + totalCount: visibleItems.count, + localization: localization + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var visibleItems: [DeviceBatteryItem] { + viewModel.snapshot.visibleItems + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: viewModel.snapshot.accessState.isError ? "exclamationmark.triangle" : "battery.0percent") + .font(.system(size: 24, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(viewModel.snapshot.accessState.isError ? Color(nsColor: .systemOrange) : Color.secondary) + + Text(emptyTitle) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Text(emptySubtitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + if viewModel.snapshot.accessState == .permissionDenied { + Button(action: openSettings) { + Label( + localization.string("empty.openSettings", defaultValue: "打开系统设置"), + systemImage: "gearshape" + ) + .font(.system(size: 11, weight: .semibold)) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(12) + .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius)) + } + + private var emptyTitle: String { + switch viewModel.snapshot.accessState { + case .permissionDenied: + return localization.string("empty.title.permissionDenied", defaultValue: "需要输入监控权限") + case .scanning: + return localization.string("empty.title.scanning", defaultValue: "正在读取电量") + case .failed: + return localization.string("empty.title.failed", defaultValue: "读取失败") + case .idle, .ready, .noDevices: + return localization.string("empty.title.noDevices", defaultValue: "暂无设备电量") + } + } + + private var emptySubtitle: String { + switch viewModel.snapshot.accessState { + case .permissionDenied: + return localization.string("empty.subtitle.permissionDenied", defaultValue: "授权后可读取厂商 HID 鼠标") + case .failed(let message): + return message + case .scanning: + return localization.string("empty.subtitle.scanning", defaultValue: "正在查询系统电源与蓝牙设备") + case .idle, .ready, .noDevices: + return localization.string("empty.subtitle.noDevices", defaultValue: "连接蓝牙设备或厂商 HID 鼠标") + } + } +} + +enum DeviceBatteryComponentLayout { + static let width = 4 + static let cornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius + static let horizontalPadding: CGFloat = 12 + static let rowHeight: CGFloat = 34 + static let overflowHeight: CGFloat = 20 + static let rowIconWidth: CGFloat = 26 + static let percentWidth: CGFloat = 38 + static let batteryWidth: CGFloat = 23 + static let cardVerticalPadding: CGFloat = 6 + static let gaugeHorizontalPadding: CGFloat = 12 + static let gaugeVerticalPadding: CGFloat = 10 + static let maximumListItems = 6 + static let maximumGaugeItems = 8 + static let ringSpan: CGFloat = 0.78 + static let ringRotation: Double = 129.6 + static let ringTrackLineWidthScale: CGFloat = 1.15 + static let poweredRingLineWidthScale: CGFloat = 1.38 + + static func contentHeight(mode: DeviceBatteryLayoutMode, visibleItemCount: Int) -> CGFloat { + guard visibleItemCount > 0 else { + return 116 + } + + switch mode { + case .grid: + return listCardHeight(totalCount: visibleItemCount) + case .list: + return gaugeCardHeight(totalCount: visibleItemCount) + } + } + + static func spanHeight( + mode: DeviceBatteryLayoutMode, + visibleItemCount: Int, + metrics: PluginComponentPanelLayoutMetrics = .default + ) -> Int { + metrics.heightSpan( + fittingContentHeight: contentHeight( + mode: mode, + visibleItemCount: visibleItemCount + ) + ) + } + + static func listCardHeight(totalCount: Int) -> CGFloat { + let displayCount = min(max(totalCount, 1), maximumListItems) + return cardVerticalPadding * 2 + + CGFloat(displayCount) * rowHeight + + CGFloat(max(displayCount - 1, 0)) + + overflowHeightIfNeeded(totalCount: totalCount, displayedCount: displayCount) + } + + static func gaugeCardHeight(totalCount: Int) -> CGFloat { + let displayCount = min(max(totalCount, 1), maximumGaugeItems) + let tileSize = gaugeTileSize(for: displayCount) + let rows = gaugeRowCount(totalCount: totalCount) + let rowSpacing = gaugeRowSpacing(for: displayCount) + return gaugeVerticalPadding * 2 + + CGFloat(rows) * (tileSize + gaugePercentHeight) + + CGFloat(max(rows - 1, 0)) * rowSpacing + } + + static func gaugeColumnCount(for itemCount: Int) -> Int { + switch itemCount { + case 0...1: + return 1 + case 2: + return 2 + case 3: + return 3 + default: + return 4 + } + } + + static func gaugeRowCount(totalCount: Int) -> Int { + let displayCount = min(max(totalCount, 1), maximumGaugeItems) + let cellCount = displayCount + (totalCount > displayCount ? 1 : 0) + let columns = gaugeColumnCount(for: displayCount) + return Int(ceil(Double(cellCount) / Double(columns))) + } + + static func gaugeTileSize(for itemCount: Int) -> CGFloat { + switch itemCount { + case 0...2: + return 66 + case 3...4: + return 58 + default: + return 50 + } + } + + static func gaugeLineWidth(for tileSize: CGFloat) -> CGFloat { + max(4, tileSize * 0.078) + } + + static let gaugePercentHeight: CGFloat = 20 + + static func gaugeRowSpacing(for itemCount: Int) -> CGFloat { + itemCount > 4 ? 8 : 6 + } + + static func overflowHeightIfNeeded(totalCount: Int, displayedCount: Int) -> CGFloat { + totalCount > displayedCount ? overflowHeight + 1 : 0 + } +} + +private typealias DeviceBatteryLayout = DeviceBatteryComponentLayout + +private struct DeviceBatteryListCard: View { + let items: [DeviceBatteryItem] + let totalCount: Int + let localization: PluginLocalization + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + DeviceBatteryNativeRow( + item: item, + rowHeight: DeviceBatteryLayout.rowHeight, + showsDetail: false, + localization: localization + ) + + if index < items.count - 1 { + DeviceBatteryDivider() + } + } + + if totalCount > items.count { + DeviceBatteryDivider() + DeviceBatteryOverflowRow(count: totalCount - items.count, localization: localization) + } + } + .padding(.vertical, DeviceBatteryLayout.cardVerticalPadding) + .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius)) + } +} + +private struct DeviceBatteryGaugeGrid: View { + let items: [DeviceBatteryItem] + let totalCount: Int + let localization: PluginLocalization + + var body: some View { + Group { + LazyVGrid(columns: columns, spacing: rowSpacing) { + ForEach(items) { item in + DeviceBatteryGaugeTile( + item: item, + tileSize: tileSize, + lineWidth: DeviceBatteryLayout.gaugeLineWidth(for: tileSize), + localization: localization + ) + .frame(maxWidth: .infinity) + } + + if totalCount > items.count { + Text("+\(totalCount - items.count)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: tileSize, height: tileSize + DeviceBatteryLayout.gaugePercentHeight) + } + } + .padding(.horizontal, DeviceBatteryLayout.gaugeHorizontalPadding) + .padding(.vertical, DeviceBatteryLayout.gaugeVerticalPadding) + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(DeviceBatteryCardBackground(cornerRadius: DeviceBatteryLayout.cornerRadius)) + } + + private var tileSize: CGFloat { + DeviceBatteryLayout.gaugeTileSize(for: items.count) + } + + private var rowSpacing: CGFloat { + DeviceBatteryLayout.gaugeRowSpacing(for: items.count) + } + + private var columns: [GridItem] { + Array( + repeating: GridItem(.flexible(minimum: tileSize), spacing: items.count > 4 ? 6 : 8), + count: DeviceBatteryLayout.gaugeColumnCount(for: items.count) + ) + } +} + +private struct DeviceBatteryGaugeTile: View { + let item: DeviceBatteryItem + let tileSize: CGFloat + let lineWidth: CGFloat + let localization: PluginLocalization + + var body: some View { + VStack(spacing: 2) { + ZStack { + DeviceBatteryRing( + item: item, + size: tileSize, + lineWidth: lineWidth, + localization: localization + ) + + Image(systemName: deviceSymbolName(for: item)) + .font(.system(size: tileSize * 0.32, weight: .regular)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(iconTint) + .frame(width: tileSize * 0.54, height: tileSize * 0.48) + } + .frame(width: tileSize, height: tileSize) + .overlay { + if chargingSymbolName(for: item) != nil { + let badgeSize = max(14, tileSize * 0.24) + DeviceBatteryChargingBadge( + systemName: "bolt.fill", + color: batteryTint(for: item), + size: badgeSize + ) + .position(x: tileSize / 2, y: chargingBadgeCenterY) + } + } + + Text(DeviceBatteryFormatter.percent(item.clampedLevel)) + .font(.system(size: max(10, tileSize * 0.18), weight: .regular, design: .rounded)) + .foregroundStyle(.primary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(width: tileSize, height: tileSize + DeviceBatteryLayout.gaugePercentHeight) + .compositingGroup() + .help(helpText(for: item, localization: localization)) + } + + private var iconTint: Color { + chargingSymbolName(for: item) == nil + ? Color.primary.opacity(0.58) + : batteryTint(for: item).opacity(0.84) + } + + private var chargingBadgeCenterY: CGFloat { + ringPathInset + } + + private var ringPathInset: CGFloat { + lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale / 2 + } +} + +private struct DeviceBatteryChargingBadge: View { + let systemName: String + let color: Color + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill(Color.white) + + Image(systemName: systemName) + .font(.system(size: max(7.2, size * 0.54), weight: .black)) + .foregroundStyle(color) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +private struct DeviceBatteryRing: View { + let item: DeviceBatteryItem + let size: CGFloat + let lineWidth: CGFloat + var localization: PluginLocalization = PluginLocalization(bundle: .main) + + var body: some View { + ZStack { + Circle() + .inset(by: ringPathInset) + .trim(from: 0, to: DeviceBatteryLayout.ringSpan) + .stroke( + Color.primary.opacity(0.12), + style: StrokeStyle( + lineWidth: lineWidth * DeviceBatteryLayout.ringTrackLineWidthScale, + lineCap: .round, + lineJoin: .round + ) + ) + + if showsPoweredOverlay { + Circle() + .inset(by: ringPathInset) + .trim(from: 0, to: DeviceBatteryLayout.ringSpan) + .stroke( + batteryTint(for: item).opacity(0.12), + style: StrokeStyle( + lineWidth: lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale, + lineCap: .round, + lineJoin: .round + ) + ) + + if progressSpan > 0.002, progress < 0.995 { + Circle() + .inset(by: ringPathInset) + .trim(from: max(progressSpan - 0.002, 0), to: max(progressSpan - 0.0004, 0)) + .stroke( + batteryTint(for: item), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) + ) + .shadow(color: batteryTint(for: item).opacity(0.32), radius: lineWidth * 0.7) + .clipShape( + Circle() + .inset(by: ringPathInset) + .trim(from: 0, to: DeviceBatteryLayout.ringSpan) + .stroke(style: StrokeStyle(lineWidth: lineWidth * 1.4, lineCap: .round, lineJoin: .round)) + ) + } + } + + Circle() + .inset(by: ringPathInset) + .trim(from: 0, to: progressSpan) + .stroke( + batteryTint(for: item), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round) + ) + } + .rotationEffect(.degrees(DeviceBatteryLayout.ringRotation)) + .frame(width: size, height: size) + .accessibilityLabel( + localization.format( + "accessibility.ring", + defaultValue: "%@,%@", + item.name, + DeviceBatteryFormatter.percent(item.clampedLevel) + ) + ) + } + + private var progress: CGFloat { + CGFloat(item.clampedLevel ?? 0) / 100 + } + + private var progressSpan: CGFloat { + DeviceBatteryLayout.ringSpan * progress + } + + private var showsPoweredOverlay: Bool { + chargingSymbolName(for: item) != nil + } + + private var ringPathInset: CGFloat { + lineWidth * DeviceBatteryLayout.poweredRingLineWidthScale / 2 + } +} + +private struct DeviceBatteryDivider: View { + var body: some View { + Divider() + .padding(.leading, DeviceBatteryLayout.horizontalPadding + DeviceBatteryLayout.rowIconWidth + 10) + .padding(.trailing, DeviceBatteryLayout.horizontalPadding) + } +} + +private struct DeviceBatteryOverflowRow: View { + let count: Int + let localization: PluginLocalization + + var body: some View { + Text(localization.format("overflow.moreDevices", defaultValue: "还有 %d 台设备", count)) + .font(.caption2.weight(.medium)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .frame(height: DeviceBatteryLayout.overflowHeight) + } +} + +private struct DeviceBatteryNativeRow: View { + let item: DeviceBatteryItem + let rowHeight: CGFloat + var showsDetail = false + var compact = false + var prominent = false + var localization: PluginLocalization = PluginLocalization(bundle: .main) + + var body: some View { + HStack(spacing: compact ? 8 : 10) { + Image(systemName: deviceSymbolName(for: item)) + .font(.system(size: iconSize, weight: .regular)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(Color.primary.opacity(iconOpacity)) + .frame(width: DeviceBatteryLayout.rowIconWidth, height: rowHeight) + + VStack(alignment: .leading, spacing: showsDetail ? 2 : 0) { + Text(item.name) + .font(titleFont) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.72) + + if showsDetail { + Text(deviceDetailText(for: item, localization: localization)) + .font(.caption2.weight(.medium)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.7) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + + Text(DeviceBatteryFormatter.percent(item.clampedLevel)) + .font(percentFont) + .foregroundStyle(item.clampedLevel ?? 100 <= 10 ? Color(nsColor: .systemRed) : .primary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(width: DeviceBatteryLayout.percentWidth, alignment: .trailing) + + DeviceBatterySystemBattery(item: item) + .frame(width: DeviceBatteryLayout.batteryWidth, height: 14) + } + .padding(.horizontal, DeviceBatteryLayout.horizontalPadding) + .frame(height: rowHeight) + .help(helpText(for: item, localization: localization)) + } + + private var titleFont: Font { + if prominent { + return .body.weight(.semibold) + } + return compact ? .caption.weight(.medium) : .subheadline.weight(.medium) + } + + private var percentFont: Font { + if prominent { + return .subheadline + } + return compact + ? .caption2 + : .caption + } + + private var iconSize: CGFloat { + if prominent { + return 21 + } + return compact ? 15 : 17 + } + + private var iconOpacity: Double { + prominent ? 0.64 : 0.56 + } +} + +private struct DeviceBatterySystemBattery: View { + let item: DeviceBatteryItem + + var body: some View { + HStack(spacing: 1) { + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 1.9, style: .continuous) + .stroke(Color.primary.opacity(0.30), lineWidth: 0.85) + .frame(width: 18, height: 8.2) + + RoundedRectangle(cornerRadius: 1.2, style: .continuous) + .fill(batteryTint(for: item)) + .frame(width: fillWidth, height: 4.8) + .padding(.leading, 1.9) + .opacity(item.clampedLevel == nil ? 0.18 : 0.82) + + if chargingSymbolName(for: item) != nil { + Image(systemName: "bolt.fill") + .font(.system(size: 5.1, weight: .black)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.white) + .frame(width: 18, height: 8.2) + } + } + + RoundedRectangle(cornerRadius: 0.8, style: .continuous) + .fill(Color.primary.opacity(0.30)) + .frame(width: 1.3, height: 3.6) + } + .accessibilityLabel(DeviceBatteryFormatter.percent(item.clampedLevel)) + } + + private var fillWidth: CGFloat { + guard let level = item.clampedLevel else { + return 3 + } + + return max(2.8, 14.2 * CGFloat(level) / 100) + } +} + +private struct DeviceBatteryCardBackground: View { + let cornerRadius: CGFloat + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.primary.opacity(0.045)) + } +} + +private func batteryTint(for item: DeviceBatteryItem?) -> Color { + guard let item else { + return Color(nsColor: .systemGreen) + } + + if item.chargeState == .charging || item.chargeState == .charged { + return Color(nsColor: .systemGreen) + } + + guard let level = item.clampedLevel else { + return Color(nsColor: .systemBlue) + } + + if level <= 20 { + return Color(nsColor: .systemRed) + } + if level <= 35 { + return Color(nsColor: .systemOrange) + } + return Color(nsColor: .systemGreen) +} + +private func chargingSymbolName(for item: DeviceBatteryItem) -> String? { + switch item.chargeState { + case .charging, .charged: + return "bolt.fill" + case .plugged: + return "powerplug.fill" + case .unknown, .normal, .invalid: + return nil + } +} + +private func batterySymbolName(for item: DeviceBatteryItem) -> String { + if item.chargeState == .charging || item.chargeState == .charged || item.chargeState == .plugged { + return "battery.100percent.bolt" + } + + guard let level = item.clampedLevel else { + return "battery.0percent" + } + + switch level { + case 76...100: + return "battery.100percent" + case 51...75: + return "battery.75percent" + case 26...50: + return "battery.50percent" + case 11...25: + return "battery.25percent" + default: + return "battery.0percent" + } +} + +private func deviceDetailText( + for item: DeviceBatteryItem, + localization: PluginLocalization = PluginLocalization(bundle: .main) +) -> String { + if let detail = item.detail, !detail.isEmpty { + return detail + } + if let model = item.model, !model.isEmpty, model != item.name { + return model + } + return item.chargeState == .normal + ? localization.string("deviceDetail.connected", defaultValue: "已连接") + : item.chargeState.title(localization: localization) +} + +func deviceSymbolName(for item: DeviceBatteryItem) -> String { + let haystack = [ + item.name, + item.model, + item.detail, + item.parentName, + item.kind.title() + ] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + switch item.kind { + case .internalBattery: + if containsAny(haystack, ["macbook", "mac book", "book"]) { + return "macbook" + } + if containsAny(haystack, ["mac mini", "macmini", "mini"]) { + return "macmini" + } + if containsAny(haystack, ["imac"]) { + return "desktopcomputer" + } + return "desktopcomputer.and.macbook" + case .phone: + return "iphone.gen2" + case .tablet: + return "ipad" + case .mediaPlayer: + return "ipodtouch" + case .watch: + return "applewatch" + case .spatialComputer: + return "visionpro" + case .rapooMouse: + return "computermouse.fill" + case .airPodsPart: + let ownName = item.name.lowercased() + if ownName.contains("case") || ownName.contains("充电盒") { + return airPodsSymbolName(in: haystack, part: .case) + } + if ownName.contains("左耳") || ownName.contains("left") || ownName.contains("🄻") { + return airPodsSymbolName(in: haystack, part: .left) + } + if ownName.contains("右耳") || ownName.contains("right") || ownName.contains("🅁") { + return airPodsSymbolName(in: haystack, part: .right) + } + return airPodsSymbolName(in: haystack, part: .all) + case .bluetooth, .magicAccessory, .other: + if containsAny(haystack, ["iphone", "phone", "mobile phone", "手机"]) { + return "iphone.gen2" + } + if containsAny(haystack, ["ipad", "tablet", "平板"]) { + return "ipad" + } + if containsAny(haystack, ["watch", "手表"]) { + return "applewatch" + } + if haystack.contains("vision") { + return "visionpro" + } + if containsAny(haystack, ["macbook", "mac book", "notebook", "laptop"]) { + return "macbook" + } + if containsAny(haystack, ["mac mini", "macmini"]) { + return "macmini" + } + if containsAny(haystack, ["imac", "desktop computer"]) { + return "desktopcomputer" + } + if containsAny(haystack, ["magic mouse", "magicmouse"]) { + return "magicmouse.fill" + } + if containsAny(haystack, ["mouse", "pointing", "鼠标", "mx anywhere", "mx master", "razer"]) { + return "computermouse.fill" + } + if containsAny(haystack, ["keyboard", "键盘", "hhkb", "niz"]) { + return "keyboard.fill" + } + if containsAny(haystack, ["trackpad", "touchpad", "触控板"]) { + return "rectangle.and.hand.point.up.left.fill" + } + if containsAny(haystack, ["airpods"]) { + return airPodsSymbolName(in: haystack, part: .all) + } + if containsAny(haystack, ["beats", "headphone", "headphones", "headset", "earbud", "earbuds", "耳机"]) { + return "headphones" + } + if containsAny(haystack, ["gamepad", "controller", "joystick", "手柄"]) { + return "gamecontroller.fill" + } + if containsAny(haystack, ["speaker", "音箱", "扬声器"]) { + return "hifispeaker.fill" + } + if containsAny(haystack, ["printer", "打印机"]) { + return "printer.fill" + } + if containsAny(haystack, ["camera", "摄像头", "相机"]) { + return "camera.fill" + } + return item.kind.iconName + } +} + +private func containsAny(_ text: String, _ needles: [String]) -> Bool { + needles.contains { text.contains($0) } +} + +private enum DeviceBatteryAirPodsPart { + case all + case left + case right + case `case` +} + +private func airPodsSymbolName( + in haystack: String, + part: DeviceBatteryAirPodsPart +) -> String { + if haystack.contains("airpods max") { + return availableSystemSymbolName(["airpods.max", "airpodsmax"]) + } + + if isAirPodsPro(in: haystack) { + switch part { + case .all: + return "airpodspro" + case .left: + return "airpodpro.left" + case .right: + return "airpodpro.right" + case .case: + return "airpodspro.chargingcase.wireless" + } + } + + if isAirPodsGeneration(4, in: haystack) { + switch part { + case .all: + return availableSystemSymbolName(["airpods.gen4", "airpods"]) + case .left: + return availableSystemSymbolName(["airpods.gen4.left", "airpod.left"]) + case .right: + return availableSystemSymbolName(["airpods.gen4.right", "airpod.right"]) + case .case: + return availableSystemSymbolName(["airpods.gen4.chargingcase.wireless", "airpods.chargingcase"]) + } + } + + if isAirPodsGeneration(3, in: haystack) { + switch part { + case .all: + return availableSystemSymbolName(["airpods.gen3", "airpods"]) + case .left: + return availableSystemSymbolName(["airpod.gen3.left", "airpod.left"]) + case .right: + return availableSystemSymbolName(["airpod.gen3.right", "airpod.right"]) + case .case: + return availableSystemSymbolName(["airpods.gen3.chargingcase.wireless", "airpods.chargingcase"]) + } + } + + switch part { + case .all: + return "airpods" + case .left: + return "airpod.left" + case .right: + return "airpod.right" + case .case: + return "airpods.chargingcase" + } +} + +private func isAirPodsPro(in haystack: String) -> Bool { + haystack.contains("airpods pro") || haystack.contains("airpodspro") +} + +private func isAirPodsGeneration(_ generation: Int, in haystack: String) -> Bool { + let compact = haystack.replacingOccurrences(of: " ", with: "") + return haystack.contains("airpods \(generation)") + || haystack.contains("airpods gen\(generation)") + || haystack.contains("airpods gen \(generation)") + || haystack.contains("airpods.gen\(generation)") + || compact.contains("airpods\(generation)") + || compact.contains("第\(generation)代") +} + +private func availableSystemSymbolName(_ candidates: [String]) -> String { + for candidate in candidates { + if NSImage(systemSymbolName: candidate, accessibilityDescription: nil) != nil { + return candidate + } + } + + return candidates.last ?? "airpods" +} + +private func helpText( + for item: DeviceBatteryItem, + localization: PluginLocalization = PluginLocalization(bundle: .main) +) -> String { + var lines = [ + item.name, + localization.format( + "help.levelAndState", + defaultValue: "%@ · %@", + DeviceBatteryFormatter.percent(item.clampedLevel), + item.chargeState.title(localization: localization) + ), + localization.format("help.source", defaultValue: "来源:%@", item.source) + ] + if let parentName = item.parentName { + lines.append(localization.format("help.parent", defaultValue: "关联:%@", parentName)) + } + if let lastUpdated = item.lastUpdated { + lines.append( + localization.format( + "help.updated", + defaultValue: "更新:%@", + DeviceBatteryFormatter.time(lastUpdated, localization: localization) + ) + ) + } + return lines.joined(separator: "\n") +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift new file mode 100644 index 0000000..f03ff49 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryLowBatteryNotifier.swift @@ -0,0 +1,218 @@ +import Foundation +import MacToolsPluginKit +@preconcurrency import UserNotifications + +struct DeviceBatteryLowBatteryNotification: Equatable { + let title: String + let body: String + let deviceIDs: Set +} + +@MainActor +protocol DeviceBatteryLowBatteryNotifying { + func notifyLowBatteryDevices( + _ items: [DeviceBatteryItem], + threshold: Int, + localization: PluginLocalization + ) +} + +@MainActor +final class DeviceBatteryLowBatteryNotificationController { + private let notifier: any DeviceBatteryLowBatteryNotifying + private var notifiedDeviceKeys: Set = [] + + init(notifier: any DeviceBatteryLowBatteryNotifying) { + self.notifier = notifier + } + + func evaluate( + snapshot: DeviceBatterySnapshot, + isEnabled: Bool, + threshold: Int, + localization: PluginLocalization + ) { + let normalizedThreshold = DeviceBatteryLowBatteryThresholds.normalized(threshold) + guard isEnabled else { + notifiedDeviceKeys.removeAll() + return + } + + let lowBatteryItems = snapshot.lowBatteryItems(threshold: normalizedThreshold) + let recoveredDeviceKeys = snapshot.visibleItems + .filter { $0.shouldResetLowBatteryNotification(threshold: normalizedThreshold) } + .map(\.lowBatteryNotificationKey) + notifiedDeviceKeys.subtract(recoveredDeviceKeys) + + let newLowBatteryItems = lowBatteryItems.filter { + !notifiedDeviceKeys.contains($0.lowBatteryNotificationKey) + } + guard !newLowBatteryItems.isEmpty else { + return + } + + notifier.notifyLowBatteryDevices( + newLowBatteryItems, + threshold: normalizedThreshold, + localization: localization + ) + notifiedDeviceKeys.formUnion(newLowBatteryItems.map(\.lowBatteryNotificationKey)) + } +} + +private extension DeviceBatteryItem { + var lowBatteryNotificationKey: String { + [ + parentName.map(Self.normalizedNotificationText) ?? "", + Self.normalizedNotificationText(name), + lowBatteryNotificationRoleKey + ] + .joined(separator: "|") + } + + private var lowBatteryNotificationRoleKey: String { + guard let role = componentIdentity?.role, role != .aggregate else { + return "" + } + + return role.rawValue + } + + func shouldResetLowBatteryNotification(threshold: Int) -> Bool { + if chargeState.isActiveChargingState { + return true + } + + guard isConnected, let level = clampedLevel else { + return false + } + + return level >= threshold + } + + private static func normalizedNotificationText(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + } +} + +@MainActor +final class DeviceBatteryUserNotificationCenterNotifier: DeviceBatteryLowBatteryNotifying { + private let notificationCenter: UNUserNotificationCenter + + init(notificationCenter: UNUserNotificationCenter = .current()) { + self.notificationCenter = notificationCenter + } + + func notifyLowBatteryDevices( + _ items: [DeviceBatteryItem], + threshold: Int, + localization: PluginLocalization + ) { + let notification = DeviceBatteryLowBatteryNotificationContent.make( + items: items, + threshold: threshold, + localization: localization + ) + + let title = notification.title + let body = notification.body + let identifier = "device-battery-low-\(notification.deviceIDs.sorted().joined(separator: "-"))" + + notificationCenter.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + Self.addNotification(title: title, body: body, identifier: identifier) + case .notDetermined: + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in + guard granted else { + return + } + Self.addNotification(title: title, body: body, identifier: identifier) + } + case .denied: + break + @unknown default: + break + } + } + } + + private nonisolated static func addNotification(title: String, body: String, identifier: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } +} + +enum DeviceBatteryLowBatteryNotificationContent { + static func make( + items: [DeviceBatteryItem], + threshold: Int, + localization: PluginLocalization + ) -> DeviceBatteryLowBatteryNotification { + let sortedItems = items.sorted { left, right in + let leftLevel = left.clampedLevel ?? Int.max + let rightLevel = right.clampedLevel ?? Int.max + if leftLevel != rightLevel { + return leftLevel < rightLevel + } + + return left.name.localizedCompare(right.name) == .orderedAscending + } + + let title: String + let body: String + if sortedItems.count == 1, let item = sortedItems.first { + title = localization.string( + "notification.lowBattery.single.title", + defaultValue: "设备电量偏低" + ) + body = localization.format( + "notification.lowBattery.single.body", + defaultValue: "%@ 当前电量 %@,已低于 %d%%。", + displayName(for: item), + DeviceBatteryFormatter.percent(item.clampedLevel), + threshold + ) + } else { + title = localization.format( + "notification.lowBattery.multiple.title", + defaultValue: "%d 台设备电量偏低", + sortedItems.count + ) + body = sortedItems + .map { item in + localization.format( + "notification.lowBattery.multiple.item", + defaultValue: "%@ %@", + displayName(for: item), + DeviceBatteryFormatter.percent(item.clampedLevel) + ) + } + .joined(separator: "\n") + } + + return DeviceBatteryLowBatteryNotification( + title: title, + body: body, + deviceIDs: Set(sortedItems.map(\.id)) + ) + } + + private static func displayName(for item: DeviceBatteryItem) -> String { + guard let parentName = item.parentName, !parentName.isEmpty else { + return item.name + } + + return "\(parentName) \(item.name)" + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryMobileDeviceReader.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryMobileDeviceReader.swift new file mode 100644 index 0000000..e9eb171 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryMobileDeviceReader.swift @@ -0,0 +1,602 @@ +import Darwin +import Foundation + +protocol DeviceBatteryMobileDeviceSampling: Sendable { + func collectDevices( + referenceDate: Date, + minimumRefreshInterval: TimeInterval + ) async -> [DeviceBatteryItem] +} + +actor DeviceBatteryMobileDeviceReader: DeviceBatteryMobileDeviceSampling { + private var cachedItems: [DeviceBatteryItem] = [] + private var lastCollectionDate: Date? + private var inFlightTask: (id: UUID, task: Task<[DeviceBatteryMobileDeviceRecord], Never>)? + + func collectDevices( + referenceDate: Date, + minimumRefreshInterval: TimeInterval + ) async -> [DeviceBatteryItem] { + if let lastCollectionDate, + referenceDate.timeIntervalSince(lastCollectionDate) < minimumRefreshInterval { + return cachedItems + } + + let requestID: UUID + let task: Task<[DeviceBatteryMobileDeviceRecord], Never> + if let inFlightTask { + requestID = inFlightTask.id + task = inFlightTask.task + } else { + requestID = UUID() + let newTask = Task.detached(priority: .utility) { + DeviceBatteryMobileDeviceBridge.readRecords() + } + inFlightTask = (requestID, newTask) + task = newTask + } + + let records = await task.value + if inFlightTask?.id == requestID { + inFlightTask = nil + } + + let items = records.map { $0.batteryItem(referenceDate: referenceDate) } + cachedItems = items + lastCollectionDate = referenceDate + return items + } +} + +enum DeviceBatteryMobileDeviceCategory: Equatable, Sendable { + case phone + case tablet + case mediaPlayer + case watch + case spatialComputer + case other + + var itemKind: DeviceBatteryKind { + switch self { + case .phone: + return .phone + case .tablet: + return .tablet + case .mediaPlayer: + return .mediaPlayer + case .watch: + return .watch + case .spatialComputer: + return .spatialComputer + case .other: + return .other + } + } + + static func resolve(deviceClass: String?, productType: String) -> Self { + let value = [deviceClass, productType] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + if value.contains("iphone") || value.contains("mobilephone") { + return .phone + } + if value.contains("ipad") || value.contains("tablet") { + return .tablet + } + if value.contains("ipod") { + return .mediaPlayer + } + if value.contains("watch") { + return .watch + } + if value.contains("reality") || value.contains("vision") { + return .spatialComputer + } + return .other + } +} + +struct DeviceBatteryMobileDeviceRecord: Equatable, Sendable { + let identifier: String + let name: String + let productType: String + let category: DeviceBatteryMobileDeviceCategory + let level: Int + let chargeState: DeviceBatteryChargeState + let connectionType: String + let parentName: String? + + func batteryItem(referenceDate: Date) -> DeviceBatteryItem { + DeviceBatteryItem( + id: "mobile-device-\(identifier)", + name: name, + model: productType.isEmpty ? nil : productType, + kind: category.itemKind, + level: level, + chargeState: chargeState, + parentName: parentName, + source: "MobileDevice", + lastUpdated: referenceDate, + isConnected: true, + detail: connectionType.isEmpty ? nil : connectionType, + componentIdentity: nil + ) + } +} + +enum DeviceBatteryMobileBatteryParser { + static func record( + identifier: String, + name: String, + productType: String, + deviceClass: String?, + connectionType: String, + parentName: String? = nil, + battery: [String: Any] + ) -> DeviceBatteryMobileDeviceRecord? { + guard let level = batteryLevel(from: battery) else { + return nil + } + + return DeviceBatteryMobileDeviceRecord( + identifier: identifier, + name: name.isEmpty ? fallbackName(deviceClass: deviceClass, productType: productType) : name, + productType: productType, + category: DeviceBatteryMobileDeviceCategory.resolve( + deviceClass: deviceClass, + productType: productType + ), + level: level, + chargeState: chargeState(from: battery), + connectionType: connectionType, + parentName: parentName + ) + } + + static func batteryLevel(from battery: [String: Any]) -> Int? { + if let capacity = intValue(battery["BatteryCurrentCapacity"]), (0...100).contains(capacity) { + return capacity + } + + let current = firstPositiveInt( + battery["AppleRawCurrentCapacity"], + battery["CurrentCapacity"] + ) + let maximum = firstPositiveInt( + battery["AppleRawMaxCapacity"], + battery["NominalChargeCapacity"], + battery["MaxCapacity"] + ) + guard let current, let maximum, maximum > 0 else { + return nil + } + + return min(max(Int((Double(current) / Double(maximum) * 100).rounded()), 0), 100) + } + + static func chargeState(from battery: [String: Any]) -> DeviceBatteryChargeState { + if boolValue(battery["BatteryIsFullyCharged"]) + || boolValue(battery["FullyCharged"]) { + return .charged + } + if boolValue(battery["BatteryIsCharging"]) + || boolValue(battery["IsCharging"]) { + return .charging + } + if boolValue(battery["ExternalConnected"]) + || boolValue(battery["AppleRawExternalConnected"]) { + return .plugged + } + return .normal + } + + private static func fallbackName(deviceClass: String?, productType: String) -> String { + switch DeviceBatteryMobileDeviceCategory.resolve(deviceClass: deviceClass, productType: productType) { + case .phone: + return "iPhone" + case .tablet: + return "iPad" + case .mediaPlayer: + return "iPod touch" + case .watch: + return "Apple Watch" + case .spatialComputer: + return "Apple Vision Pro" + case .other: + return productType.isEmpty ? "Apple 设备" : productType + } + } + + private static func firstPositiveInt(_ values: Any?...) -> Int? { + values.lazy.compactMap(intValue).first { $0 > 0 } + } + + private static func intValue(_ value: Any?) -> Int? { + if let number = value as? NSNumber { + return number.intValue + } + if let value = value as? Int { + return value + } + if let value = value as? String { + return Int(value) + } + return nil + } + + private static func boolValue(_ value: Any?) -> Bool { + if let number = value as? NSNumber { + return number.boolValue + } + if let value = value as? Bool { + return value + } + if let value = value as? String { + return value.caseInsensitiveCompare("true") == .orderedSame + || value.caseInsensitiveCompare("yes") == .orderedSame + || value == "1" + } + return false + } +} + +private enum DeviceBatteryMobileDeviceBridge { + private static let frameworkPath = "/System/Library/PrivateFrameworks/MobileDevice.framework/MobileDevice" + private static let success: Int32 = 0 + private static let serviceTimeoutSeconds: Int = 2 + private static let lock = NSLock() + + private typealias DevicePointer = UnsafeMutableRawPointer + private typealias CreateDeviceListFunction = @convention(c) () -> Unmanaged? + private typealias DeviceResultFunction = @convention(c) (DevicePointer) -> Int32 + private typealias CopyDeviceIdentifierFunction = @convention(c) (DevicePointer) -> Unmanaged? + private typealias CopyValueFunction = @convention(c) (DevicePointer, CFString?, CFString?) -> Unmanaged? + private typealias StartServiceFunction = @convention(c) ( + DevicePointer, + CFString, + CFDictionary?, + UnsafeMutablePointer + ) -> Int32 + private typealias SendMessageFunction = @convention(c) ( + DevicePointer, + CFTypeRef, + CFPropertyListFormat + ) -> Int32 + private typealias ReceiveMessageFunction = @convention(c) ( + DevicePointer, + UnsafeMutablePointer?>, + UnsafeMutablePointer + ) -> Int32 + + private struct Symbols: @unchecked Sendable { + let createDeviceList: CreateDeviceListFunction + let connect: DeviceResultFunction + let disconnect: DeviceResultFunction + let validatePairing: DeviceResultFunction + let startSession: DeviceResultFunction + let stopSession: DeviceResultFunction + let interfaceType: DeviceResultFunction + let copyDeviceIdentifier: CopyDeviceIdentifierFunction + let copyValue: CopyValueFunction + let startService: StartServiceFunction + let sendMessage: SendMessageFunction + let receiveMessage: ReceiveMessageFunction + let invalidateConnection: DeviceResultFunction + let connectionSocket: DeviceResultFunction + } + + private static let symbols: Symbols? = loadSymbols() + + static func readRecords() -> [DeviceBatteryMobileDeviceRecord] { + lock.lock() + defer { lock.unlock() } + + guard let symbols, + let unmanagedList = symbols.createDeviceList() + else { + return [] + } + + let list = unmanagedList.takeRetainedValue() + var records: [DeviceBatteryMobileDeviceRecord] = [] + for index in 0.. [DeviceBatteryMobileDeviceRecord] { + guard symbols.connect(device) == success else { + return [] + } + defer { _ = symbols.disconnect(device) } + + guard symbols.validatePairing(device) == success, + symbols.startSession(device) == success + else { + return [] + } + defer { _ = symbols.stopSession(device) } + + let identifier = symbols.copyDeviceIdentifier(device)?.takeRetainedValue() as String? ?? "" + guard !identifier.isEmpty else { + return [] + } + + let name = copiedString("DeviceName", device: device, symbols: symbols) + let productType = copiedString("ProductType", device: device, symbols: symbols) + let deviceClass = copiedString("DeviceClass", device: device, symbols: symbols) + let connectionType = connectionLabel(symbols.interfaceType(device)) + let directBattery = copiedDictionary( + domain: "com.apple.mobile.battery", + key: nil, + device: device, + symbols: symbols + ) + let battery = directBattery.flatMap { + DeviceBatteryMobileBatteryParser.batteryLevel(from: $0) == nil ? nil : $0 + } ?? readIORegistryBattery(device: device, symbols: symbols) + + var records: [DeviceBatteryMobileDeviceRecord] = [] + if let battery, + let record = DeviceBatteryMobileBatteryParser.record( + identifier: opaqueIdentifier(identifier), + name: name, + productType: productType, + deviceClass: deviceClass, + connectionType: connectionType, + battery: battery + ) { + records.append(record) + } + + if DeviceBatteryMobileDeviceCategory.resolve( + deviceClass: deviceClass, + productType: productType + ) == .phone { + records.append(contentsOf: readPairedWatches( + device: device, + parentName: name, + connectionType: connectionType, + symbols: symbols + )) + } + return records + } + + private static func readPairedWatches( + device: DevicePointer, + parentName: String, + connectionType: String, + symbols: Symbols + ) -> [DeviceBatteryMobileDeviceRecord] { + let registryRequest: [String: Any] = ["Command": "GetDeviceRegistry"] + guard let registry = sendServiceRequest( + service: "com.apple.companion_proxy", + request: registryRequest, + format: .binaryFormat_v1_0, + device: device, + symbols: symbols + ), + let identifiers = registry["PairedDevicesArray"] as? [String] + else { + return [] + } + + return identifiers.compactMap { watchIdentifier in + var values: [String: Any] = [:] + for key in ["DeviceName", "ProductType", "BatteryCurrentCapacity", "BatteryIsCharging"] { + if let value = companionValue( + watchIdentifier: watchIdentifier, + key: key, + device: device, + symbols: symbols + ) { + values[key] = value + } + } + + return DeviceBatteryMobileBatteryParser.record( + identifier: "watch-\(opaqueIdentifier(watchIdentifier))", + name: values["DeviceName"] as? String ?? "Apple Watch", + productType: values["ProductType"] as? String ?? "Watch", + deviceClass: "Watch", + connectionType: connectionType, + parentName: parentName.isEmpty ? nil : parentName, + battery: values + ) + } + } + + private static func companionValue( + watchIdentifier: String, + key: String, + device: DevicePointer, + symbols: Symbols + ) -> Any? { + let request: [String: Any] = [ + "Command": "GetValueFromRegistry", + "GetValueGizmoUDIDKey": watchIdentifier, + "GetValueKeyKey": key + ] + guard let response = sendServiceRequest( + service: "com.apple.companion_proxy", + request: request, + format: .binaryFormat_v1_0, + device: device, + symbols: symbols + ), + let dictionary = response["RetrievedValueDictionary"] as? [String: Any] + else { + return nil + } + return dictionary[key] + } + + private static func readIORegistryBattery( + device: DevicePointer, + symbols: Symbols + ) -> [String: Any]? { + let request: [String: Any] = [ + "Request": "IORegistry", + "EntryClass": "AppleSmartBattery" + ] + guard let response = sendServiceRequest( + service: "com.apple.mobile.diagnostics_relay", + request: request, + format: .xmlFormat_v1_0, + device: device, + symbols: symbols + ), + response["Status"] as? String == "Success", + let diagnostics = response["Diagnostics"] as? [String: Any], + let registry = diagnostics["IORegistry"] as? [String: Any] + else { + return nil + } + return registry + } + + private static func sendServiceRequest( + service: String, + request: [String: Any], + format: CFPropertyListFormat, + device: DevicePointer, + symbols: Symbols + ) -> [String: Any]? { + var connection: DevicePointer? + guard symbols.startService(device, service as CFString, nil, &connection) == success, + let connection + else { + return nil + } + defer { _ = symbols.invalidateConnection(connection) } + + configureTimeout(on: connection, symbols: symbols) + guard symbols.sendMessage(connection, request as CFDictionary, format) == success else { + return nil + } + + var response: Unmanaged? + var responseFormat = format + guard symbols.receiveMessage(connection, &response, &responseFormat) == success else { + return nil + } + return response?.takeRetainedValue() as? [String: Any] + } + + private static func configureTimeout(on connection: DevicePointer, symbols: Symbols) { + let socket = symbols.connectionSocket(connection) + guard socket >= 0 else { + return + } + var timeout = timeval(tv_sec: serviceTimeoutSeconds, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout.size)) + _ = setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout.size)) + } + } + + private static func copiedString( + _ key: String, + device: DevicePointer, + symbols: Symbols + ) -> String { + symbols.copyValue(device, nil, key as CFString)?.takeRetainedValue() as? String ?? "" + } + + private static func copiedDictionary( + domain: String?, + key: String?, + device: DevicePointer, + symbols: Symbols + ) -> [String: Any]? { + symbols.copyValue(device, domain as CFString?, key as CFString?)? + .takeRetainedValue() as? [String: Any] + } + + private static func connectionLabel(_ type: Int32) -> String { + switch type { + case 1: + return "USB" + case 2, 3: + return "Wi-Fi" + default: + return "" + } + } + + private static func opaqueIdentifier(_ identifier: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in identifier.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } + + private static func loadSymbols() -> Symbols? { + guard let handle = dlopen(frameworkPath, RTLD_NOW | RTLD_LOCAL) else { + return nil + } + + func symbol(_ name: String, as type: T.Type) -> T? { + guard let pointer = dlsym(handle, name) else { + return nil + } + return unsafeBitCast(pointer, to: T.self) + } + + guard let createDeviceList = symbol("AMDCreateDeviceList", as: CreateDeviceListFunction.self), + let connect = symbol("AMDeviceConnect", as: DeviceResultFunction.self), + let disconnect = symbol("AMDeviceDisconnect", as: DeviceResultFunction.self), + let validatePairing = symbol("AMDeviceValidatePairing", as: DeviceResultFunction.self), + let startSession = symbol("AMDeviceStartSession", as: DeviceResultFunction.self), + let stopSession = symbol("AMDeviceStopSession", as: DeviceResultFunction.self), + let interfaceType = symbol("AMDeviceGetInterfaceType", as: DeviceResultFunction.self), + let copyDeviceIdentifier = symbol( + "AMDeviceCopyDeviceIdentifier", + as: CopyDeviceIdentifierFunction.self + ), + let copyValue = symbol("AMDeviceCopyValue", as: CopyValueFunction.self), + let startService = symbol("AMDeviceSecureStartService", as: StartServiceFunction.self), + let sendMessage = symbol("AMDServiceConnectionSendMessage", as: SendMessageFunction.self), + let receiveMessage = symbol("AMDServiceConnectionReceiveMessage", as: ReceiveMessageFunction.self), + let invalidateConnection = symbol( + "AMDServiceConnectionInvalidate", + as: DeviceResultFunction.self + ), + let connectionSocket = symbol( + "AMDServiceConnectionGetSocket", + as: DeviceResultFunction.self + ) + else { + dlclose(handle) + return nil + } + + return Symbols( + createDeviceList: createDeviceList, + connect: connect, + disconnect: disconnect, + validatePairing: validatePairing, + startSession: startSession, + stopSession: stopSession, + interfaceType: interfaceType, + copyDeviceIdentifier: copyDeviceIdentifier, + copyValue: copyValue, + startService: startService, + sendMessage: sendMessage, + receiveMessage: receiveMessage, + invalidateConnection: invalidateConnection, + connectionSocket: connectionSocket + ) + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryModels.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryModels.swift new file mode 100644 index 0000000..d7c7496 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryModels.swift @@ -0,0 +1,421 @@ +import Foundation +import MacToolsPluginKit + +enum DeviceBatteryLayoutMode: String, CaseIterable, Equatable { + case grid + case list + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .grid: + return localization.string("layout.grid.title", defaultValue: "列表") + case .list: + return localization.string("layout.list.title", defaultValue: "圆环") + } + } + + func subtitle(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .grid: + return localization.string("layout.grid.subtitle", defaultValue: "按设备逐行显示") + case .list: + return localization.string("layout.list.subtitle", defaultValue: "多设备圆环") + } + } + +} + +enum DeviceBatteryChargeState: Equatable, Sendable { + case unknown + case normal + case charging + case charged + case plugged + case invalid + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .unknown: + return localization.string("chargeState.unknown", defaultValue: "未知") + case .normal: + return localization.string("chargeState.normal", defaultValue: "正常") + case .charging: + return localization.string("chargeState.charging", defaultValue: "充电中") + case .charged: + return localization.string("chargeState.charged", defaultValue: "已充满") + case .plugged: + return localization.string("chargeState.plugged", defaultValue: "外接电源") + case .invalid: + return localization.string("chargeState.invalid", defaultValue: "电量无效") + } + } + + var isActiveChargingState: Bool { + switch self { + case .charging, .charged, .plugged: + return true + case .unknown, .normal, .invalid: + return false + } + } +} + +enum DeviceBatteryKind: Equatable, Sendable { + case internalBattery + case phone + case tablet + case mediaPlayer + case watch + case spatialComputer + case bluetooth + case magicAccessory + case rapooMouse + case airPodsPart + case other + + var iconName: String { + switch self { + case .internalBattery: + return "laptopcomputer" + case .phone: + return "iphone" + case .tablet: + return "ipad" + case .mediaPlayer: + return "ipodtouch" + case .watch: + return "applewatch" + case .spatialComputer: + return "visionpro" + case .bluetooth: + return "dot.radiowaves.left.and.right" + case .magicAccessory: + return "keyboard" + case .rapooMouse: + return "computermouse.fill" + case .airPodsPart: + return "airpodspro" + case .other: + return "battery.75percent" + } + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .internalBattery: + return "Mac" + case .phone: + return "iPhone" + case .tablet: + return "iPad" + case .mediaPlayer: + return "iPod touch" + case .watch: + return "Apple Watch" + case .spatialComputer: + return "Apple Vision Pro" + case .bluetooth: + return localization.string("deviceKind.bluetooth", defaultValue: "蓝牙") + case .magicAccessory: + return localization.string("deviceKind.magicAccessory", defaultValue: "Apple 外设") + case .rapooMouse: + return localization.string("deviceKind.rapooMouse", defaultValue: "雷柏鼠标") + case .airPodsPart: + return localization.string("deviceKind.airPodsPart", defaultValue: "耳机") + case .other: + return localization.string("deviceKind.other", defaultValue: "设备") + } + } +} + +enum DeviceBatteryComponentRole: String, Equatable, Sendable { + case aggregate + case left + case right + case chargingCase + + var isPart: Bool { + self != .aggregate + } +} + +enum DeviceBatteryLowBatteryThresholds { + static let defaultValue = 20 + static let minimum = 1 + static let maximum = 99 + + static func normalized(_ threshold: Int) -> Int { + min(max(threshold, minimum), maximum) + } +} + +struct DeviceBatteryComponentIdentity: Equatable, Sendable { + let groupID: String + let role: DeviceBatteryComponentRole +} + +struct DeviceBatteryItem: Identifiable, Equatable, Sendable { + let id: String + let name: String + let model: String? + let kind: DeviceBatteryKind + let level: Int? + let chargeState: DeviceBatteryChargeState + let parentName: String? + let source: String + let lastUpdated: Date? + let isConnected: Bool + let detail: String? + let componentIdentity: DeviceBatteryComponentIdentity? + + init( + id: String, + name: String, + model: String?, + kind: DeviceBatteryKind, + level: Int?, + chargeState: DeviceBatteryChargeState, + parentName: String?, + source: String, + lastUpdated: Date?, + isConnected: Bool, + detail: String?, + componentIdentity: DeviceBatteryComponentIdentity? = nil + ) { + self.id = id + self.name = name + self.model = model + self.kind = kind + self.level = level + self.chargeState = chargeState + self.parentName = parentName + self.source = source + self.lastUpdated = lastUpdated + self.isConnected = isConnected + self.detail = detail + self.componentIdentity = componentIdentity + } + + var clampedLevel: Int? { + guard let level else { + return nil + } + + return min(max(level, 0), 100) + } + + func isLowBattery(threshold: Int) -> Bool { + guard let level = clampedLevel else { + return false + } + + return isConnected + && level < threshold + && chargeState != .charging + && chargeState != .charged + && chargeState != .plugged + } + + var isDisplayLowBattery: Bool { + guard let level = clampedLevel else { + return false + } + + return level <= DeviceBatteryLowBatteryThresholds.defaultValue + && chargeState != .charging + && chargeState != .charged + } +} + +enum DeviceBatteryItemNormalizer { + static func removingRedundantComponentAggregates( + _ items: [DeviceBatteryItem] + ) -> [DeviceBatteryItem] { + let componentGroups = Set(items.compactMap(componentGroupID)) + guard !componentGroups.isEmpty else { + return items + } + + return items.filter { item in + guard let aggregateGroupID = aggregateGroupID(item) else { + return true + } + + return !componentGroups.contains(aggregateGroupID) + } + } + + private static func aggregateGroupID(_ item: DeviceBatteryItem) -> String? { + guard let identity = item.componentIdentity, + identity.role == .aggregate, + item.clampedLevel != nil + else { + return nil + } + + return identity.groupID + } + + private static func componentGroupID(_ item: DeviceBatteryItem) -> String? { + guard let identity = item.componentIdentity, + identity.role.isPart, + item.clampedLevel != nil + else { + return nil + } + + return identity.groupID + } +} + +enum DeviceBatteryAccessState: Equatable, Sendable { + case idle + case scanning + case ready + case noDevices + case permissionDenied + case failed(String) + + var isError: Bool { + switch self { + case .permissionDenied, .failed: + return true + case .idle, .scanning, .ready, .noDevices: + return false + } + } +} + +struct DeviceBatterySnapshot: Equatable, Sendable { + var accessState: DeviceBatteryAccessState + var items: [DeviceBatteryItem] + var lastUpdated: Date? + var rapooState: RapooBatteryAccessState + + static let idle = DeviceBatterySnapshot( + accessState: .idle, + items: [], + lastUpdated: nil, + rapooState: .idle + ) + + var visibleItems: [DeviceBatteryItem] { + items.sorted(by: Self.sortItems) + } + + var primaryItem: DeviceBatteryItem? { + visibleItems.first + } + + var lowBatteryCount: Int { + visibleItems.filter(\.isDisplayLowBattery).count + } + + func lowBatteryItems(threshold: Int) -> [DeviceBatteryItem] { + let normalizedThreshold = DeviceBatteryLowBatteryThresholds.normalized(threshold) + return visibleItems.filter { $0.isLowBattery(threshold: normalizedThreshold) } + } + + func subtitle(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch accessState { + case .idle: + return localization.string("snapshot.subtitle.idle", defaultValue: "等待检测") + case .scanning: + return localization.string("snapshot.subtitle.scanning", defaultValue: "正在读取设备电量") + case .ready: + if lowBatteryCount > 0 { + return localization.format( + "snapshot.subtitle.readyWithLowBattery", + defaultValue: "%d 台设备,%d 台低电量", + visibleItems.count, + lowBatteryCount + ) + } + return localization.format( + "snapshot.subtitle.ready", + defaultValue: "%d 台设备", + visibleItems.count + ) + case .noDevices: + return localization.string("snapshot.subtitle.noDevices", defaultValue: "未检测到可显示电量") + case .permissionDenied: + return localization.string("snapshot.subtitle.permissionDenied", defaultValue: "需要输入监控权限") + case let .failed(message): + return message + } + } + + func errorMessage(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String? { + switch accessState { + case .permissionDenied: + return localization.string( + "snapshot.error.permissionDenied", + defaultValue: "无法访问雷柏 HID 接口,请在系统设置中允许 MacTools 使用输入监控。" + ) + case let .failed(message): + return message + case .idle, .scanning, .ready, .noDevices: + return nil + } + } + + private static func sortItems(_ left: DeviceBatteryItem, _ right: DeviceBatteryItem) -> Bool { + let leftRank = itemRank(left) + let rightRank = itemRank(right) + if leftRank != rightRank { + return leftRank < rightRank + } + + return left.name.localizedCompare(right.name) == .orderedAscending + } + + private static func itemRank(_ item: DeviceBatteryItem) -> Int { + if item.isDisplayLowBattery { + return 0 + } + + switch item.kind { + case .internalBattery: + return 1 + case .phone, .tablet, .mediaPlayer, .watch, .spatialComputer: + return 2 + case .rapooMouse: + return 3 + case .magicAccessory: + return 4 + case .airPodsPart: + return 5 + case .bluetooth: + return 6 + case .other: + return 7 + } + } +} + +enum DeviceBatteryFormatter { + static func percent(_ level: Int?) -> String { + guard let level else { + return "--" + } + + return "\(min(max(level, 0), 100))%" + } + + static func time( + _ date: Date?, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> String { + guard let date else { + return localization.string("time.notUpdated", defaultValue: "未更新") + } + + return timeFormatter.string(from: date) + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift new file mode 100644 index 0000000..96f9516 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryPlugin.swift @@ -0,0 +1,358 @@ +import AppKit +@preconcurrency import IOKit.hid +import SwiftUI +import MacToolsPluginKit + +public final class DeviceBatteryPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + DeviceBatteryPluginProvider(context: context) + } +} + +@MainActor +private struct DeviceBatteryPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [DeviceBatteryPlugin(context: context, localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +enum DeviceBatteryInputMonitoringAuthorizationStatus { + case granted + case denied + case unknown +} + +@MainActor +final class DeviceBatteryPlugin: MacToolsPlugin, PluginComponentPanel, PluginPanelSurfaceLifecycleHandling { + private enum ControlID { + static let openInputMonitoring = "open-input-monitoring" + } + + private enum PermissionID { + static let inputMonitoring = "input-monitoring" + } + + let metadata: PluginMetadata + + var descriptor: PluginComponentDescriptor { + PluginComponentDescriptor(span: componentSpan) + } + + private let viewModel: DeviceBatteryViewModel + private let store: DeviceBatteryStore + private let localization: PluginLocalization + private let inputMonitoringAuthorizationStatus: () -> DeviceBatteryInputMonitoringAuthorizationStatus + private let lowBatteryNotificationController: DeviceBatteryLowBatteryNotificationController + + private var componentSpan: PluginComponentSpan { + let visibleItemCount = viewModel.snapshot.visibleItems.count + return PluginComponentSpan( + width: DeviceBatteryComponentLayout.width, + height: DeviceBatteryComponentLayout.spanHeight( + mode: store.layoutMode, + visibleItemCount: visibleItemCount + ) + )! + } + + convenience init( + context: PluginRuntimeContext, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.init( + context: context, + viewModel: DeviceBatteryViewModel( + sampler: DeviceBatterySampler(localization: localization), + rapooMonitor: RapooHIDBatteryMonitor(localization: localization), + localization: localization + ), + localization: localization + ) + } + + convenience init( + context: PluginRuntimeContext, + viewModel: DeviceBatteryViewModel, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.init( + context: context, + viewModel: viewModel, + localization: localization, + inputMonitoringAuthorizationStatus: Self.currentInputMonitoringAuthorizationStatus + ) + } + + init( + context: PluginRuntimeContext, + viewModel: DeviceBatteryViewModel, + localization: PluginLocalization = PluginLocalization(bundle: .main), + inputMonitoringAuthorizationStatus: @escaping () -> DeviceBatteryInputMonitoringAuthorizationStatus, + lowBatteryNotifier: any DeviceBatteryLowBatteryNotifying = DeviceBatteryUserNotificationCenterNotifier() + ) { + self.viewModel = viewModel + self.store = DeviceBatteryStore(storage: context.storage) + self.localization = localization + self.inputMonitoringAuthorizationStatus = inputMonitoringAuthorizationStatus + self.lowBatteryNotificationController = DeviceBatteryLowBatteryNotificationController(notifier: lowBatteryNotifier) + self.metadata = PluginMetadata( + id: "device-battery", + title: localization.string("metadata.title", defaultValue: "设备电量"), + iconName: "battery.75percent", + iconTint: Color(nsColor: .systemGreen), + order: 20, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "查看 Mac、Apple 移动设备、蓝牙外设和雷柏鼠标电量" + ) + ) + viewModel.onSnapshotChange = { [weak self] in + self?.handleSnapshotChange() + } + } + + var onStateChange: (() -> Void)? { + didSet { + viewModel.onSnapshotChange = { [weak self] in + self?.handleSnapshotChange() + } + } + } + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + var componentPanelState: PluginComponentState { + PluginComponentState( + subtitle: viewModel.snapshot.subtitle(localization: localization), + isActive: !viewModel.snapshot.visibleItems.isEmpty, + isEnabled: true, + isVisible: true, + errorMessage: viewModel.snapshot.errorMessage(localization: localization) + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { + [ + PluginPermissionRequirement( + id: PermissionID.inputMonitoring, + kind: .inputMonitoring, + title: localization.string("permission.inputMonitoring.title", defaultValue: "输入监控授权"), + description: localization.string( + "permission.inputMonitoring.description", + defaultValue: "用于读取已适配厂商 HID 鼠标的电量、充电状态、设备型号和名称。" + ) + ) + ] + } + + var settingsSections: [PluginSettingsSection] { [] } + + var configuration: PluginConfiguration? { + PluginConfiguration( + description: localization.string( + "configuration.description", + defaultValue: "选择组件面板布局和显示内容。" + ) + ) { [store, viewModel, localization, lowBatteryNotificationController] _ in + DeviceBatterySettingsView( + store: store, + localization: localization, + onChange: { + viewModel.refresh( + includeInternalBattery: store.showInternalBattery, + includeBluetoothDevices: store.showBluetoothDevices, + includeAppleMobileDevices: store.showAppleMobileDevices, + includeRapooDevices: store.showRapooDevices + ) + }, + onNotificationSettingsChange: { + lowBatteryNotificationController.evaluate( + snapshot: viewModel.snapshot, + isEnabled: store.lowBatteryNotificationEnabled, + threshold: store.lowBatteryNotificationThreshold, + localization: localization + ) + } + ) + } + } + + func activate(context: PluginRuntimeContext) { + viewModel.start( + includeInternalBattery: store.showInternalBattery, + includeBluetoothDevices: store.showBluetoothDevices, + includeAppleMobileDevices: store.showAppleMobileDevices, + includeRapooDevices: store.showRapooDevices + ) + onStateChange?() + } + + func deactivate(reason: PluginDeactivationReason) { + viewModel.stop() + onStateChange?() + } + + func refresh() { + viewModel.refresh( + includeInternalBattery: store.showInternalBattery, + includeBluetoothDevices: store.showBluetoothDevices, + includeAppleMobileDevices: store.showAppleMobileDevices, + includeRapooDevices: store.showRapooDevices + ) + onStateChange?() + } + + func panelSurfaceDidBecomeVisible(_ surface: PluginPanelSurface) { + guard surface == .component else { + return + } + + viewModel.setComponentPanelVisible(true) + } + + func panelSurfaceDidBecomeHidden(_ surface: PluginPanelSurface) { + guard surface == .component else { + return + } + + viewModel.setComponentPanelVisible(false) + } + + func makeView(context: PluginComponentContext) -> AnyView { + AnyView( + DeviceBatteryComponentView( + viewModel: viewModel, + store: store, + localization: localization, + openSettings: openInputMonitoringSettings + ) + ) + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + switch permissionID { + case PermissionID.inputMonitoring: + return inputMonitoringPermissionState + default: + return PluginPermissionState(isGranted: true, footnote: nil) + } + } + + func handlePermissionAction(id: String) { + guard id == PermissionID.inputMonitoring else { + return + } + + openInputMonitoringSettings() + } + + func handleSettingsAction(id: String) { + if id == ControlID.openInputMonitoring { + openInputMonitoringSettings() + } + } + + func handleShortcutAction(id: String) {} + + private func handleSnapshotChange() { + lowBatteryNotificationController.evaluate( + snapshot: viewModel.snapshot, + isEnabled: store.lowBatteryNotificationEnabled, + threshold: store.lowBatteryNotificationThreshold, + localization: localization + ) + onStateChange?() + } + + private var inputMonitoringPermissionState: PluginPermissionState { + let authorizationStatus = inputMonitoringAuthorizationStatus() + if viewModel.snapshot.rapooState == .permissionDenied, + authorizationStatus != .granted { + return PluginPermissionState( + isGranted: false, + footnote: localization.string( + "permission.inputMonitoring.openSettingsFootnote", + defaultValue: "前往系统设置 → 隐私与安全性 → 输入监控,允许 MacTools。" + ) + ) + } + + switch authorizationStatus { + case .granted: + return PluginPermissionState( + isGranted: true, + footnote: inputMonitoringGrantedFootnote + ) + case .denied, .unknown: + return PluginPermissionState( + isGranted: false, + footnote: inputMonitoringUnauthorizedFootnote + ) + } + } + + private var inputMonitoringGrantedFootnote: String { + switch viewModel.snapshot.rapooState { + case .connected: + return localization.string( + "permission.inputMonitoring.granted.connected", + defaultValue: "已允许 MacTools 使用输入监控,并已读取到厂商 HID 鼠标信息。" + ) + case .failed(let message): + return localization.format( + "permission.inputMonitoring.granted.failed", + defaultValue: "已允许 MacTools 使用输入监控。最近读取厂商 HID 鼠标失败:%@", + message + ) + case .idle, .scanning, .waitingForReport, .noDevice, .permissionDenied: + return localization.string( + "permission.inputMonitoring.granted.default", + defaultValue: "已允许 MacTools 使用输入监控,可读取已适配厂商 HID 鼠标信息。" + ) + } + } + + private var inputMonitoringUnauthorizedFootnote: String { + switch viewModel.snapshot.rapooState { + case .permissionDenied: + return localization.string( + "permission.inputMonitoring.openSettingsFootnote", + defaultValue: "前往系统设置 → 隐私与安全性 → 输入监控,允许 MacTools。" + ) + case .failed(let message): + return localization.format( + "permission.inputMonitoring.unauthorized.failed", + defaultValue: "授权后可读取厂商 HID 鼠标信息。最近读取失败:%@", + message + ) + case .idle, .scanning, .waitingForReport, .connected, .noDevice: + return localization.string( + "permission.inputMonitoring.unauthorized.default", + defaultValue: "授权后可读取厂商 HID 鼠标信息;未授权不影响 Mac 内置电池、系统蓝牙设备和 Apple 外设电量。" + ) + } + } + + private static func currentInputMonitoringAuthorizationStatus() -> DeviceBatteryInputMonitoringAuthorizationStatus { + switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) { + case kIOHIDAccessTypeGranted: + return .granted + case kIOHIDAccessTypeDenied: + return .denied + case kIOHIDAccessTypeUnknown: + return .unknown + default: + return .unknown + } + } + + private func openInputMonitoringSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") else { + return + } + + NSWorkspace.shared.open(url) + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatterySampler.swift b/Plugins/DeviceBattery/Sources/DeviceBatterySampler.swift new file mode 100644 index 0000000..e4103f8 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatterySampler.swift @@ -0,0 +1,2535 @@ +import Foundation +import CoreBluetooth +import IOBluetooth +import IOKit +import IOKit.ps +import MacToolsPluginKit + +protocol DeviceBatterySampling: Sendable { + func collectSystemDevices( + referenceDate: Date, + options: DeviceBatterySamplingOptions + ) async -> [DeviceBatteryItem] +} + +struct DeviceBatterySamplingOptions: Equatable, Sendable { + let includeInternalBattery: Bool + let includeBluetoothDevices: Bool + let includeAppleMobileDevices: Bool + let appleMobileRefreshInterval: TimeInterval +} + +struct DeviceBatterySampler: DeviceBatterySampling { + private static let bluetoothPowerLogLookback = "1m" + private static let bluetoothPowerLogTimeout: TimeInterval = 1.5 + private static let batteryCenterLogLookback = "2m" + private static let batteryCenterLogTimeout: TimeInterval = 1.0 + private let localization: PluginLocalization + private let mobileDeviceReader: any DeviceBatteryMobileDeviceSampling + + init( + localization: PluginLocalization = PluginLocalization(bundle: .main), + mobileDeviceReader: any DeviceBatteryMobileDeviceSampling = DeviceBatteryMobileDeviceReader() + ) { + self.localization = localization + self.mobileDeviceReader = mobileDeviceReader + } + + func collectSystemDevices( + referenceDate: Date, + options: DeviceBatterySamplingOptions + ) async -> [DeviceBatteryItem] { + let localization = localization + let baseSample = await Task.detached(priority: .utility) { + var items: [DeviceBatteryItem] = [] + if options.includeInternalBattery { + items.append(contentsOf: Self.collectInternalBattery( + referenceDate: referenceDate, + localization: localization + )) + } + + let bluetoothData = options.includeBluetoothDevices + ? Self.collectBluetoothProfile() + : BluetoothProfile(connectedDevices: [], batteryDevices: []) + if options.includeBluetoothDevices { + items.append(contentsOf: Self.collectBluetoothDevices( + from: bluetoothData, + referenceDate: referenceDate, + localization: localization + )) + items.append(contentsOf: Self.collectMagicAccessoryDevices( + from: bluetoothData, + referenceDate: referenceDate, + localization: localization + )) + } + let targets = options.includeBluetoothDevices + ? Self.bluetoothBatteryTargets(from: bluetoothData) + : [] + return DeviceBatteryBaseSample( + items: Self.deduplicated(items), + bluetoothBatteryTargets: targets, + shouldReadBatteryCenterLog: !targets.isEmpty + || !bluetoothData.connectedDevices.isEmpty + || !bluetoothData.batteryDevices.isEmpty + ) + }.value + + async let mobileDeviceItems = options.includeAppleMobileDevices + ? mobileDeviceReader.collectDevices( + referenceDate: referenceDate, + minimumRefreshInterval: options.appleMobileRefreshInterval + ) + : [] + async let bluetoothPowerLogItems = Task.detached(priority: .utility) { + Self.collectBluetoothPowerLogDevices( + targets: baseSample.bluetoothBatteryTargets, + existingItems: baseSample.items, + referenceDate: referenceDate, + localization: localization + ) + }.value + async let batteryCenterLogItems = Task.detached(priority: .utility) { + Self.collectBatteryCenterLogDevices( + shouldReadLog: baseSample.shouldReadBatteryCenterLog, + targets: baseSample.bluetoothBatteryTargets, + referenceDate: referenceDate + ) + }.value + async let appleHeadphoneAdvertisementItems = DeviceBatteryAppleHeadphoneAdvertisementReader.collectBatteryDevices( + targets: baseSample.bluetoothBatteryTargets, + referenceDate: referenceDate, + localization: localization + ) + async let bluetoothBatteryItems = DeviceBatteryBLEBatteryReader.collectBatteryDevices( + targets: baseSample.bluetoothBatteryTargets, + referenceDate: referenceDate, + localization: localization + ) + let powerLogItems = await bluetoothPowerLogItems + let batteryLogItems = await batteryCenterLogItems + let advertisementItems = await appleHeadphoneAdvertisementItems + let bleItems = await bluetoothBatteryItems + let mobileItems = await mobileDeviceItems + return Self.deduplicated( + baseSample.items + + mobileItems + + powerLogItems + + batteryLogItems + + advertisementItems + + bleItems + ) + } + + private static func collectInternalBattery( + referenceDate: Date, + localization: PluginLocalization + ) -> [DeviceBatteryItem] { + guard + let powerSourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let powerSources = IOPSCopyPowerSourcesList(powerSourcesInfo)?.takeRetainedValue() as? [CFTypeRef], + !powerSources.isEmpty + else { + return [] + } + + var fallbackDescription: [String: Any]? + var batteryDescription: [String: Any]? + + for source in powerSources { + guard let description = IOPSGetPowerSourceDescription(powerSourcesInfo, source)?.takeUnretainedValue() as? [String: Any] else { + continue + } + + fallbackDescription = fallbackDescription ?? description + if description[kIOPSTypeKey] as? String == kIOPSInternalBatteryType { + batteryDescription = description + break + } + } + + guard let description = batteryDescription ?? fallbackDescription else { + return [] + } + + let maxCapacity = max(description[kIOPSMaxCapacityKey] as? Int ?? 100, 1) + let currentCapacity = min(max(description[kIOPSCurrentCapacityKey] as? Int ?? 0, 0), maxCapacity) + let level = Int((Double(currentCapacity) / Double(maxCapacity) * 100).rounded()) + let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false + let isCharged = (description[kIOPSIsChargedKey] as? Bool) ?? (level >= 100) + let powerSource = description[kIOPSPowerSourceStateKey] as? String ?? "" + let chargeState = batteryChargeState( + level: level, + isCharging: isCharging, + isCharged: isCharged, + powerSource: powerSource + ) + let name = internalBatteryDisplayName( + rawName: description[kIOPSNameKey] as? String, + localization: localization + ) + + return [ + DeviceBatteryItem( + id: "internal-battery", + name: name, + model: nil, + kind: .internalBattery, + level: level, + chargeState: chargeState, + parentName: nil, + source: "IOPowerSources", + lastUpdated: referenceDate, + isConnected: true, + detail: internalBatteryDetail( + description: description, + chargeState: chargeState, + localization: localization + ), + componentIdentity: nil + ) + ] + } + + private static func batteryChargeState( + level: Int, + isCharging: Bool, + isCharged: Bool, + powerSource: String + ) -> DeviceBatteryChargeState { + if isCharged || level >= 100 { + return .charged + } + if isCharging { + return .charging + } + if powerSource == "AC Power" { + return .plugged + } + return .normal + } + + private static func internalBatteryDetail( + description: [String: Any], + chargeState: DeviceBatteryChargeState, + localization: PluginLocalization + ) -> String { + let timeKey = chargeState == .charging ? kIOPSTimeToFullChargeKey : kIOPSTimeToEmptyKey + guard let minutes = description[timeKey] as? Int, minutes > 0 else { + return chargeState.title(localization: localization) + } + + return localization.format( + "batteryDetail.remainingTime", + defaultValue: "%@ %d小时%d分", + chargeState.title(localization: localization), + minutes / 60, + minutes % 60 + ) + } + + private static func internalBatteryDisplayName( + rawName: String?, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> String { + let cleanedName = rawName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lowercasedName = cleanedName.lowercased() + let isSystemIdentifier = cleanedName.isEmpty + || lowercasedName.contains("internal") + || lowercasedName == "battery" + || lowercasedName.contains("battery-") + + if !isSystemIdentifier { + return cleanedName + } + + let hostName = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return hostName.isEmpty + ? localization.string("deviceName.internalBattery", defaultValue: "Mac 电池") + : hostName + } + + private static func collectBluetoothProfile() -> BluetoothProfile { + guard let output = runCommand( + path: "/usr/sbin/system_profiler", + arguments: ["SPBluetoothDataType", "-json"], + timeout: 3 + ) else { + return BluetoothProfile(connectedDevices: [], batteryDevices: []) + } + + return bluetoothProfile(fromSystemProfilerOutput: output) + } + + private static func bluetoothProfile( + fromSystemProfilerOutput output: String + ) -> BluetoothProfile { + guard + let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawSections = json["SPBluetoothDataType"] as? [[String: Any]], + let section = rawSections.first + else { + return BluetoothProfile(connectedDevices: [], batteryDevices: []) + } + + let connectedDevices = parseBluetoothDevices( + from: section["device_connected"], + isConnected: true + ) + let disconnectedBatteryDevices = parseBluetoothDevices( + from: section["device_not_connected"], + isConnected: false + ) + .filter { hasBluetoothBatteryFields($0.info) || isAppleHeadphoneBatteryCandidate($0) } + + return BluetoothProfile( + connectedDevices: connectedDevices, + batteryDevices: mergedBatteryDevices( + connectedDevices + disconnectedBatteryDevices + ) + ) + } + + static func bluetoothProfileBatteryItems( + fromSystemProfilerOutput output: String, + referenceDate: Date + ) -> [DeviceBatteryItem] { + collectBluetoothProfileBatteryItems( + from: bluetoothProfile(fromSystemProfilerOutput: output), + referenceDate: referenceDate + ) + } + + private static func parseBluetoothDevices( + from value: Any?, + isConnected: Bool + ) -> [BluetoothProfileDevice] { + guard let rawDevices = value as? [[String: Any]] else { + return [] + } + + return rawDevices.compactMap { rawDevice in + guard let name = rawDevice.keys.first, + let info = rawDevice[name] as? [String: Any] + else { + return nil + } + + return BluetoothProfileDevice( + name: name, + info: info, + isConnected: isConnected + ) + } + } + + private static func hasBluetoothBatteryFields(_ info: [String: Any]) -> Bool { + [ + "device_batteryLevelMain", + "device_batteryLevelCase", + "device_batteryLevelLeft", + "device_batteryLevelRight" + ] + .contains { batteryValue(from: info[$0]) != nil } + } + + private static func isAppleHeadphoneBatteryCandidate(_ device: BluetoothProfileDevice) -> Bool { + guard normalizedHexIdentifier(stringValue(device.info["device_vendorID"])) == "004C" else { + return false + } + + let productID = stringValue(device.info["device_productID"]) + let model = productID.flatMap { HeadphoneModelCatalog.modelName(forProductID: $0) } + let haystack = [ + device.name, + model, + device.info["device_minorType"] as? String + ] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + return haystack.contains("airpods") + || haystack.contains("beats") + || haystack.contains("headphone") + || haystack.contains("headset") + } + + private static func mergedBatteryDevices(_ devices: [BluetoothProfileDevice]) -> [BluetoothProfileDevice] { + var mergedByKey: [String: BluetoothProfileDevice] = [:] + var orderedKeys: [String] = [] + + for device in devices { + let key = normalizedIdentifier(device.info["device_address"]) + ?? normalizedDeviceName(device.name) + if let existing = mergedByKey[key] { + if !existing.isConnected, device.isConnected { + mergedByKey[key] = device + } + } else { + mergedByKey[key] = device + orderedKeys.append(key) + } + } + + return orderedKeys.compactMap { mergedByKey[$0] } + } + + private static func collectBluetoothPowerLogDevices( + targets allTargets: [BluetoothBatteryTarget], + existingItems: [DeviceBatteryItem], + referenceDate: Date, + localization: PluginLocalization + ) -> [DeviceBatteryItem] { + let targets = allTargets.filter { target in + needsBluetoothPowerLogFallback(target: target, existingItems: existingItems) + } + guard !targets.isEmpty else { + return [] + } + + let componentTargets = targets.filter { supportsComponentPowerLog(target: $0) } + let regularTargets = targets.filter { !supportsComponentPowerLog(target: $0) } + let recentReadings = collectBluetoothPowerLogReadings( + targets: targets, + lookback: bluetoothPowerLogLookback, + timeout: bluetoothPowerLogTimeout + ) + + var items: [DeviceBatteryItem] = [] + if !componentTargets.isEmpty { + let componentReadings = recentReadings.filter { reading in + matchingBluetoothPowerLogTarget(reading, in: componentTargets) != nil + } + items.append(contentsOf: bluetoothPowerLogItems( + from: componentReadings, + targets: componentTargets, + referenceDate: referenceDate, + localization: localization + )) + } + + if !regularTargets.isEmpty { + let regularReadings = recentReadings.filter { reading in + matchingBluetoothPowerLogTarget(reading, in: regularTargets) != nil + } + + let regularItems = bluetoothPowerLogItems( + from: regularReadings, + targets: regularTargets, + referenceDate: referenceDate, + localization: localization + ) + if !regularItems.isEmpty { + items.append(contentsOf: regularItems) + } + } + + return DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items) + } + + private static let bluetoothPowerLogPredicate = #"subsystem == "com.apple.bluetooth" AND category == "CBPowerSource" AND eventMessage CONTAINS "Battery""# + + private static func collectBluetoothPowerLogReadings( + targets: [BluetoothBatteryTarget], + lookback: String, + timeout: TimeInterval + ) -> [DeviceBatteryBluetoothPowerLogReading] { + guard let output = runCommand( + path: "/usr/bin/nice", + arguments: [ + "-n", + "19", + "/usr/bin/log", + "show", + "--process", + "bluetoothd", + "--info", + "--last", + lookback, + "--style", + "compact", + "--predicate", + bluetoothPowerLogPredicate(for: targets) + ], + timeout: timeout, + outputLineFilter: { line in + isBluetoothPowerLogLine(line, matching: targets) + } + ) else { + return [] + } + + return DeviceBatteryBluetoothPowerLogParser.readings(from: output) + } + + private static func collectBatteryCenterLogDevices( + shouldReadLog: Bool, + targets: [BluetoothBatteryTarget], + referenceDate: Date + ) -> [DeviceBatteryItem] { + guard shouldReadLog else { + return [] + } + + let recentReadings = collectBatteryCenterLogReadings( + lookback: batteryCenterLogLookback, + timeout: batteryCenterLogTimeout + ) + + return batteryCenterLogItems( + from: recentReadings, + targets: targets, + referenceDate: referenceDate + ) + } + + private static func collectBatteryCenterLogReadings( + lookback: String, + timeout: TimeInterval + ) -> [DeviceBatteryBatteryCenterLogReading] { + guard let output = runCommand( + path: "/usr/bin/nice", + arguments: [ + "-n", + "19", + "/usr/bin/log", + "show", + "--info", + "--last", + lookback, + "--style", + "compact", + "--predicate", + #"subsystem == "com.apple.BatteryCenter" AND eventMessage CONTAINS "BCBatteryDevice" AND eventMessage CONTAINS "percentCharge""# + ], + timeout: timeout, + outputLineFilter: { line in + line.contains("BCBatteryDevice") && line.contains("percentCharge") + } + ) else { + return [] + } + + return DeviceBatteryBatteryCenterLogParser.readings(from: output) + } + + private static func batteryCenterLogItems( + from readings: [DeviceBatteryBatteryCenterLogReading], + targets: [BluetoothBatteryTarget], + referenceDate: Date + ) -> [DeviceBatteryItem] { + readings.compactMap { reading in + if let target = matchingBatteryCenterLogTarget(reading, in: targets) { + return DeviceBatteryItem( + id: "batterycenter-\(target.address ?? target.id)", + name: target.name, + model: firstNonEmpty(reading.model, target.model), + kind: target.kind, + level: reading.level, + chargeState: reading.chargeState, + parentName: nil, + source: "BatteryCenter", + lastUpdated: referenceDate, + isConnected: reading.isConnected ?? target.isConnected, + detail: firstNonEmpty(reading.category, target.detail), + componentIdentity: componentAggregateIdentity( + groupID: target.componentGroupID, + kind: target.kind + ) + ) + } + + guard reading.isInternal != true, + let name = firstNonEmptyOptional(reading.name, reading.groupName) + else { + return nil + } + + let kind = batteryCenterKind(reading: reading) + return DeviceBatteryItem( + id: "batterycenter-\(reading.accessoryID ?? normalizedDeviceName(name))", + name: name, + model: reading.model, + kind: kind, + level: reading.level, + chargeState: reading.chargeState, + parentName: nil, + source: "BatteryCenter", + lastUpdated: referenceDate, + isConnected: reading.isConnected ?? true, + detail: firstNonEmpty(reading.category, reading.transportType), + componentIdentity: componentAggregateIdentity( + groupID: reading.accessoryID ?? normalizedDeviceName(name), + kind: kind + ) + ) + } + } + + private static func batteryCenterKind(reading: DeviceBatteryBatteryCenterLogReading) -> DeviceBatteryKind { + let haystack = [ + reading.name, + reading.groupName, + reading.model, + reading.category + ] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + if haystack.contains("iphone") || haystack.contains("mobilephone") || haystack.contains("phone") { + return .phone + } + if haystack.contains("ipad") || haystack.contains("tablet") { + return .tablet + } + if haystack.contains("ipod") { + return .mediaPlayer + } + if haystack.contains("watch") { + return .watch + } + if haystack.contains("vision") || haystack.contains("realitydevice") { + return .spatialComputer + } + if haystack.contains("airpods") + || haystack.contains("beats") + || haystack.contains("headphone") + || haystack.contains("headset") { + return .airPodsPart + } + if haystack.contains("magic") + || haystack.contains("keyboard") + || haystack.contains("mouse") + || haystack.contains("trackpad") { + return .magicAccessory + } + return .bluetooth + } + + private static func firstNonEmptyOptional(_ values: String?...) -> String? { + for value in values { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + return trimmed + } + } + + return nil + } + + fileprivate static func canUseUnmatchedBatteryCenterReading( + _ reading: DeviceBatteryBatteryCenterLogReading + ) -> Bool { + reading.isInternal != true && firstNonEmptyOptional(reading.name, reading.groupName) != nil + } + + fileprivate static func matchingBatteryCenterLogTarget( + _ reading: DeviceBatteryBatteryCenterLogReading, + in targets: [BluetoothBatteryTarget] + ) -> BluetoothBatteryTarget? { + let nameMatchedTargets = targets.filter { target in + batteryCenterNamesMatch(reading: reading, target: target) + && batteryCenterProductIdentifiersMatch(reading: reading, target: target) + } + if nameMatchedTargets.count == 1 { + return nameMatchedTargets[0] + } + + guard let readingProductID = normalizedProductIdentifier(reading.productID) else { + return nil + } + + let productMatchedTargets = targets.filter { target in + normalizedProductIdentifier(target.productID) == readingProductID + } + return productMatchedTargets.count == 1 ? productMatchedTargets[0] : nil + } + + private static func batteryCenterNamesMatch( + reading: DeviceBatteryBatteryCenterLogReading, + target: BluetoothBatteryTarget + ) -> Bool { + let readingNames = [reading.name, reading.groupName] + .compactMap { $0 } + .map(normalizedDeviceName) + return readingNames.contains(normalizedDeviceName(target.name)) + } + + private static func batteryCenterProductIdentifiersMatch( + reading: DeviceBatteryBatteryCenterLogReading, + target: BluetoothBatteryTarget + ) -> Bool { + guard let readingProductID = normalizedProductIdentifier(reading.productID), + let targetProductID = normalizedProductIdentifier(target.productID) + else { + return true + } + + return readingProductID == targetProductID + } + + private static func bluetoothPowerLogPredicate(for targets: [BluetoothBatteryTarget]) -> String { + let targetPredicates = targets.flatMap { target -> [String] in + var predicates: [String] = [] + + let name = target.name.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + predicates.append(#"eventMessage CONTAINS[c] "\#(escapedPredicateString(name))""#) + } + + let vendorID = target.vendorID?.trimmingCharacters(in: .whitespacesAndNewlines) + let productID = target.productID?.trimmingCharacters(in: .whitespacesAndNewlines) + if let vendorID, !vendorID.isEmpty, let productID, !productID.isEmpty { + predicates.append( + #"(eventMessage CONTAINS "\#(escapedPredicateString(vendorID))" AND eventMessage CONTAINS "\#(escapedPredicateString(productID))")"# + ) + } else if let productID, !productID.isEmpty { + predicates.append(#"eventMessage CONTAINS "\#(escapedPredicateString(productID))""#) + } + + return predicates + } + + guard !targetPredicates.isEmpty else { + return bluetoothPowerLogPredicate + } + + return "\(bluetoothPowerLogPredicate) AND (\(targetPredicates.joined(separator: " OR ")))" + } + + private static func escapedPredicateString(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + private static func isBluetoothPowerLogLine( + _ line: String, + matching targets: [BluetoothBatteryTarget] + ) -> Bool { + guard line.contains("CBPowerSource"), + line.contains("Power source updated"), + line.contains("Battery") + else { + return false + } + + return targets.contains { target in + bluetoothPowerLogLine(line, matches: target) + } + } + + static func isBluetoothPowerLogLine( + _ line: String, + matchingName name: String, + vendorID: String?, + productID: String? + ) -> Bool { + guard line.contains("CBPowerSource"), + line.contains("Power source updated"), + line.contains("Battery") + else { + return false + } + + if line.localizedCaseInsensitiveContains(name) { + return true + } + + let normalizedLine = line.uppercased() + if let vendorID = normalizedHexIdentifier(vendorID), + let productID = normalizedHexIdentifier(productID), + normalizedLine.contains("0X\(vendorID)"), + normalizedLine.contains("0X\(productID)") { + return true + } + + return false + } + + private static func bluetoothPowerLogLine( + _ line: String, + matches target: BluetoothBatteryTarget + ) -> Bool { + isBluetoothPowerLogLine( + line, + matchingName: target.name, + vendorID: target.vendorID, + productID: target.productID + ) + } + + private static func bluetoothPowerLogItems( + from readings: [DeviceBatteryBluetoothPowerLogReading], + targets: [BluetoothBatteryTarget], + referenceDate: Date, + localization: PluginLocalization + ) -> [DeviceBatteryItem] { + readings.compactMap { reading in + guard let target = matchingBluetoothPowerLogTarget(reading, in: targets) else { + return nil + } + + return DeviceBatteryItem( + id: powerLogItemID(reading: reading, target: target), + name: powerLogItemName(reading: reading, target: target, localization: localization), + model: target.model, + kind: reading.component == nil ? target.kind : .airPodsPart, + level: reading.level, + chargeState: reading.chargeState, + parentName: powerLogParentName(reading: reading, target: target, localization: localization), + source: "BluetoothPowerLog", + lastUpdated: referenceDate, + isConnected: target.isConnected, + detail: reading.deviceType ?? target.detail, + componentIdentity: powerLogComponentIdentity(reading: reading, target: target) + ) + } + } + + private static func needsBluetoothPowerLogFallback( + target: BluetoothBatteryTarget, + existingItems: [DeviceBatteryItem] + ) -> Bool { + if supportsComponentPowerLog(target: target) { + return true + } + + guard target.isConnected else { + return false + } + + if normalizedHexIdentifier(target.vendorID) == "004C" { + return false + } + + let targetName = normalizedDeviceName(target.name) + return !existingItems.contains { item in + item.parentName == nil + && normalizedDeviceName(item.name) == targetName + && item.clampedLevel != nil + } + } + + private static func powerLogItemName( + reading: DeviceBatteryBluetoothPowerLogReading, + target: BluetoothBatteryTarget, + localization: PluginLocalization + ) -> String { + guard let component = reading.component else { + return target.name + } + + return localization.format( + "deviceName.withPart", + defaultValue: "%@ %@", + target.name, + component.title(localization: localization) + ) + } + + private static func powerLogItemID( + reading: DeviceBatteryBluetoothPowerLogReading, + target: BluetoothBatteryTarget + ) -> String { + let baseID = target.address ?? target.id + guard let component = reading.component else { + return "bluetooth-powerlog-\(baseID)" + } + + return "bluetooth-powerlog-\(baseID)-\(component.idSuffix)" + } + + private static func powerLogParentName( + reading: DeviceBatteryBluetoothPowerLogReading, + target: BluetoothBatteryTarget, + localization: PluginLocalization + ) -> String? { + switch reading.component { + case nil: + return nil + case .chargingCase: + return target.name + case .left, .right: + return localization.format( + "deviceName.withPart", + defaultValue: "%@ %@", + target.name, + DeviceBatteryBluetoothPowerLogComponent.chargingCase.title(localization: localization) + ) + } + } + + fileprivate static func powerLogItemNameForReader( + component: DeviceBatteryBluetoothPowerLogComponent, + targetName: String, + localization: PluginLocalization + ) -> String { + localization.format( + "deviceName.withPart", + defaultValue: "%@ %@", + targetName, + component.title(localization: localization) + ) + } + + fileprivate static func powerLogParentNameForReader( + component: DeviceBatteryBluetoothPowerLogComponent, + targetName: String, + localization: PluginLocalization + ) -> String? { + switch component { + case .chargingCase: + return targetName + case .left, .right: + return localization.format( + "deviceName.withPart", + defaultValue: "%@ %@", + targetName, + DeviceBatteryBluetoothPowerLogComponent.chargingCase.title(localization: localization) + ) + } + } + + private static func powerLogComponentIdentity( + reading: DeviceBatteryBluetoothPowerLogReading, + target: BluetoothBatteryTarget + ) -> DeviceBatteryComponentIdentity? { + guard supportsComponentPowerLog(target: target) || reading.component != nil else { + return nil + } + + return DeviceBatteryComponentIdentity( + groupID: target.componentGroupID, + role: reading.component?.componentRole ?? .aggregate + ) + } + + private static func supportsComponentPowerLog(target: BluetoothBatteryTarget) -> Bool { + guard normalizedHexIdentifier(target.vendorID) == "004C" else { + return false + } + + let haystack = [ + target.name, + target.model, + target.detail + ] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + return target.kind == .airPodsPart + || haystack.contains("airpods") + || haystack.contains("beats") + || haystack.contains("headphone") + || haystack.contains("headset") + } + + private static func collectBluetoothDevices( + from profile: BluetoothProfile, + referenceDate: Date, + localization: PluginLocalization + ) -> [DeviceBatteryItem] { + var items = collectBluetoothProfileBatteryItems( + from: profile, + referenceDate: referenceDate, + localization: localization + ) + items.append(contentsOf: collectIOBluetoothBattery(from: profile, referenceDate: referenceDate)) + return items + } + + private static func collectBluetoothProfileBatteryItems( + from profile: BluetoothProfile, + referenceDate: Date, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> [DeviceBatteryItem] { + var items: [DeviceBatteryItem] = [] + + for device in profile.batteryDevices { + let productID = stringValue(device.info["device_productID"]) + let model = productID.flatMap { HeadphoneModelCatalog.modelName(forProductID: $0) } + let parentID = normalizedIdentifier(device.info["device_address"]) ?? normalizedIdentifier(productID) ?? device.name + + appendBluetoothLevel( + to: &items, + name: device.name, + suffix: nil, + fieldName: "device_batteryLevelMain", + device: device, + id: parentID, + kind: inferredKind(device: device, field: "main"), + model: model, + parentName: nil, + componentRole: hasBluetoothComponentBatteryFields(device.info) ? .aggregate : nil, + referenceDate: referenceDate + ) + let caseName = bluetoothPartName( + deviceName: device.name, + component: .chargingCase, + localization: localization + ) + appendBluetoothLevel( + to: &items, + name: caseName, + suffix: "case", + fieldName: "device_batteryLevelCase", + device: device, + id: parentID, + kind: .airPodsPart, + model: model, + parentName: device.name, + componentRole: .chargingCase, + referenceDate: referenceDate + ) + appendBluetoothLevel( + to: &items, + name: bluetoothPartName(deviceName: device.name, component: .left, localization: localization), + suffix: "left", + fieldName: "device_batteryLevelLeft", + device: device, + id: parentID, + kind: .airPodsPart, + model: model, + parentName: caseName, + componentRole: .left, + referenceDate: referenceDate + ) + appendBluetoothLevel( + to: &items, + name: bluetoothPartName(deviceName: device.name, component: .right, localization: localization), + suffix: "right", + fieldName: "device_batteryLevelRight", + device: device, + id: parentID, + kind: .airPodsPart, + model: model, + parentName: caseName, + componentRole: .right, + referenceDate: referenceDate + ) + } + + return DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items) + } + + private static func bluetoothPartName( + deviceName: String, + component: DeviceBatteryBluetoothPowerLogComponent, + localization: PluginLocalization + ) -> String { + localization.format( + "deviceName.withPart", + defaultValue: "%@ %@", + deviceName, + component.title(localization: localization) + ) + } + + private static func appendBluetoothLevel( + to items: inout [DeviceBatteryItem], + name: String, + suffix: String?, + fieldName: String, + device: BluetoothProfileDevice, + id: String, + kind: DeviceBatteryKind, + model: String?, + parentName: String?, + componentRole: DeviceBatteryComponentRole?, + referenceDate: Date + ) { + guard let batteryValue = batteryValue(from: device.info[fieldName]) else { + return + } + + let itemID = suffix.map { "\(id)-\($0)" } ?? id + items.append( + DeviceBatteryItem( + id: "bluetooth-\(itemID)", + name: name, + model: model, + kind: kind, + level: batteryValue.level, + chargeState: batteryValue.chargeState, + parentName: parentName, + source: "system_profiler", + lastUpdated: referenceDate, + isConnected: device.isConnected, + detail: device.info["device_minorType"] as? String, + componentIdentity: componentRole.map { + DeviceBatteryComponentIdentity(groupID: id, role: $0) + } + ) + ) + } + + private static func hasBluetoothComponentBatteryFields(_ info: [String: Any]) -> Bool { + [ + "device_batteryLevelCase", + "device_batteryLevelLeft", + "device_batteryLevelRight" + ] + .contains { batteryValue(from: info[$0]) != nil } + } + + private static func collectIOBluetoothBattery( + from profile: BluetoothProfile, + referenceDate: Date + ) -> [DeviceBatteryItem] { + guard let devices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + return [] + } + + return devices.compactMap { device in + guard device.isConnected(), + let level = device.pluginBatteryPercentSingle, + level > 0, + let name = device.name, + !name.isEmpty + else { + return nil + } + + let address = normalizedIdentifier(device.addressString) ?? name + let profileDevice = profile.connectedDevices.first { normalizedIdentifier($0.info["device_address"]) == address } + let kind = profileDevice.map { inferredKind(device: $0, field: "single") } ?? .bluetooth + return DeviceBatteryItem( + id: "iobluetooth-\(address)", + name: name, + model: nil, + kind: kind, + level: level, + chargeState: .normal, + parentName: nil, + source: "IOBluetooth", + lastUpdated: referenceDate, + isConnected: true, + detail: profileDevice?.info["device_minorType"] as? String, + componentIdentity: componentAggregateIdentity(groupID: address, kind: kind) + ) + } + } + + private static func collectMagicAccessoryDevices( + from profile: BluetoothProfile, + referenceDate: Date, + localization: PluginLocalization + ) -> [DeviceBatteryItem] { + let classes = [ + "AppleDeviceManagementHIDEventService", + "AppleBluetoothHIDKeyboard", + "BNBTrackpadDevice", + "BNBMouseDevice" + ] + + return classes.flatMap { serviceClass in + collectIORegistryBatteryDevices( + matchingService: serviceClass, + profile: profile, + referenceDate: referenceDate, + localization: localization + ) + } + } + + private static func collectIORegistryBatteryDevices( + matchingService serviceClass: String, + profile: BluetoothProfile, + referenceDate: Date, + localization: PluginLocalization + ) -> [DeviceBatteryItem] { + var iterator = io_iterator_t() + let result = IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceMatching(serviceClass), + &iterator + ) + guard result == KERN_SUCCESS else { + return [] + } + defer { IOObjectRelease(iterator) } + + var items: [DeviceBatteryItem] = [] + while true { + let object = IOIteratorNext(iterator) + guard object != 0 else { + break + } + defer { IOObjectRelease(object) } + + guard let item = makeIORegistryBatteryItem( + object: object, + serviceClass: serviceClass, + profile: profile, + referenceDate: referenceDate, + localization: localization + ) else { + continue + } + + items.append(item) + } + + return items + } + + private static func makeIORegistryBatteryItem( + object: io_object_t, + serviceClass: String, + profile: BluetoothProfile, + referenceDate: Date, + localization: PluginLocalization + ) -> DeviceBatteryItem? { + guard let level = intProperty("BatteryPercent", object: object), + (0...100).contains(level) + else { + return nil + } + + let rawAddress = stringProperty("DeviceAddress", object: object) + let address = normalizedIdentifier(rawAddress) + let productName = stringProperty("Product", object: object) + guard productName?.localizedCaseInsensitiveContains("Internal") != true else { + return nil + } + + let matchedProfileDevice = profile.connectedDevices.first { profileDevice in + normalizedIdentifier(profileDevice.info["device_address"]) == address + } + let name = firstNonEmpty( + matchedProfileDevice?.name, + productName, + serviceClass, + localization: localization + ) + let statusFlags = intProperty("BatteryStatusFlags", object: object) + let chargeState: DeviceBatteryChargeState = statusFlags == 4 ? .normal : (statusFlags ?? 0) > 0 ? .charging : .normal + let kind = inferredMagicKind(productName: productName, profileDevice: matchedProfileDevice) + + return DeviceBatteryItem( + id: "ioregistry-\(address ?? name)-\(serviceClass)", + name: name, + model: productName, + kind: kind, + level: level, + chargeState: chargeState, + parentName: nil, + source: "IORegistry", + lastUpdated: referenceDate, + isConnected: true, + detail: matchedProfileDevice?.info["device_minorType"] as? String, + componentIdentity: nil + ) + } + + fileprivate static func componentAggregateIdentity( + groupID: String, + kind: DeviceBatteryKind + ) -> DeviceBatteryComponentIdentity? { + guard kind == .airPodsPart else { + return nil + } + + return DeviceBatteryComponentIdentity(groupID: groupID, role: .aggregate) + } + + private static func inferredKind( + device: BluetoothProfileDevice, + field: String + ) -> DeviceBatteryKind { + inferredBluetoothKind( + name: device.name, + minorType: device.info["device_minorType"] as? String, + vendorID: device.info["device_vendorID"] as? String, + field: field + ) + } + + static func inferredBluetoothKind( + name: String, + minorType: String?, + vendorID: String?, + field: String + ) -> DeviceBatteryKind { + let name = name.lowercased() + let type = (minorType ?? "").lowercased() + let vendorID = (vendorID ?? "").lowercased() + + if field == "case" || field == "left" || field == "right" { + return .airPodsPart + } + if field != "main" || name.contains("airpods") || name.contains("beats") { + if name.contains("airpods") + || name.contains("beats") + || type.contains("headphone") + || type.contains("headset") { + return .airPodsPart + } + } + if vendorID == "0x004c" { + return .magicAccessory + } + return .bluetooth + } + + private static func inferredMagicKind( + productName: String?, + profileDevice: BluetoothProfileDevice? + ) -> DeviceBatteryKind { + let combined = [productName, profileDevice?.info["device_minorType"] as? String] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + if combined.contains("mouse") || combined.contains("keyboard") || combined.contains("trackpad") { + if combined.contains("apple") || combined.contains("magic") { + return .magicAccessory + } + if profileDevice?.info["device_vendorID"] as? String == "0x004c" { + return .magicAccessory + } + return .bluetooth + } + if combined.contains("magic") { + return .magicAccessory + } + return .bluetooth + } + + private static func bluetoothBatteryTargets(from profile: BluetoothProfile) -> [BluetoothBatteryTarget] { + profile.batteryDevices.map { device in + let productID = stringValue(device.info["device_productID"]) + let vendorID = stringValue(device.info["device_vendorID"]) + let model = productID.flatMap { HeadphoneModelCatalog.modelName(forProductID: $0) } + return BluetoothBatteryTarget( + id: normalizedIdentifier(device.info["device_address"]) ?? device.name, + name: device.name, + address: normalizedIdentifier(device.info["device_address"]), + vendorID: vendorID, + productID: productID, + model: model, + kind: inferredKind(device: device, field: "single"), + detail: device.info["device_minorType"] as? String, + isConnected: device.isConnected + ) + } + } + + fileprivate static func matchingBluetoothPowerLogTarget( + _ reading: DeviceBatteryBluetoothPowerLogReading, + in targets: [BluetoothBatteryTarget] + ) -> BluetoothBatteryTarget? { + let candidates = targets.filter { target in + normalizedDeviceName(target.name) == normalizedDeviceName(reading.name) + && bluetoothIdentifiersMatch(reading: reading, target: target) + } + + if candidates.count == 1 { + return candidates[0] + } + + return candidates.first { target in + normalizedHexIdentifier(target.vendorID) == normalizedHexIdentifier(reading.vendorID) + && normalizedHexIdentifier(target.productID) == normalizedHexIdentifier(reading.productID) + } + } + + private static func bluetoothIdentifiersMatch( + reading: DeviceBatteryBluetoothPowerLogReading, + target: BluetoothBatteryTarget + ) -> Bool { + if let readingVendorID = normalizedHexIdentifier(reading.vendorID), + let targetVendorID = normalizedHexIdentifier(target.vendorID), + readingVendorID != targetVendorID { + return false + } + + if let readingProductID = normalizedHexIdentifier(reading.productID), + let targetProductID = normalizedHexIdentifier(target.productID), + readingProductID != targetProductID { + return false + } + + return true + } + + private static func normalizedDeviceName(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + } + + private static func normalizedHexIdentifier(_ value: String?) -> String? { + let cleaned = value? + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "0x", with: "", options: .caseInsensitive) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let cleaned, !cleaned.isEmpty else { + return nil + } + + return cleaned.uppercased() + } + + fileprivate static func normalizedHexIdentifierForReader(_ value: String?) -> String? { + normalizedHexIdentifier(value) + } + + private static func normalizedProductIdentifier(_ value: String?) -> String? { + guard let value else { + return nil + } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return nil + } + + let rawValue = trimmed.split(separator: " ").first.map(String.init) ?? trimmed + if rawValue.hasPrefix("0x") || rawValue.hasPrefix("0X") { + return normalizedHexIdentifier(rawValue) + } + if let decimal = Int(rawValue) { + return String(decimal, radix: 16).uppercased() + } + return normalizedHexIdentifier(rawValue) + } + + static func deduplicated(_ items: [DeviceBatteryItem]) -> [DeviceBatteryItem] { + var bestByNameAndKind: [String: DeviceBatteryItem] = [:] + var orderedKeys: [String] = [] + + for item in DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items) { + let key = "\(item.kind)-\(item.name.lowercased())-\(item.parentName ?? "")" + if let existing = bestByNameAndKind[key] { + bestByNameAndKind[key] = preferredItem(existing, item) + } else { + bestByNameAndKind[key] = item + orderedKeys.append(key) + } + } + + return orderedKeys.compactMap { bestByNameAndKind[$0] } + } + + private static func preferredItem( + _ left: DeviceBatteryItem, + _ right: DeviceBatteryItem + ) -> DeviceBatteryItem { + if left.source == "MobileDevice" || right.source == "MobileDevice" { + return left.source == "MobileDevice" ? left : right + } + + if left.chargeState.isActiveChargingState != right.chargeState.isActiveChargingState { + return left.chargeState.isActiveChargingState ? left : right + } + + let sourceRank = [ + "IORegistry": 0, + "IOPowerSources": 0, + "MobileDevice": 0, + "CoreBluetooth": 1, + "AppleHeadphoneAdvertisement": 2, + "BatteryCenter": 3, + "BluetoothPowerLog": 4, + "IOBluetooth": 5, + "system_profiler": 6 + ] + let leftRank = sourceRank[left.source] ?? 10 + let rightRank = sourceRank[right.source] ?? 10 + if leftRank != rightRank { + return leftRank < rightRank ? left : right + } + + return left.lastUpdated ?? .distantPast >= right.lastUpdated ?? .distantPast ? left : right + } + + private static func batteryLevel(from value: Any?) -> Int? { + batteryValue(from: value)?.level + } + + private static func batteryValue(from value: Any?) -> (level: Int, chargeState: DeviceBatteryChargeState)? { + if let number = value as? NSNumber { + let level = number.intValue + guard (0...100).contains(level) else { + return nil + } + return (level, .normal) + } + + guard let string = value as? String else { + return nil + } + + let digits = string + .replacingOccurrences(of: "%", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let level = Int(digits), (0...100).contains(level) else { + return nil + } + + return (level, digits.hasPrefix("+") ? .charging : .normal) + } + + private static func normalizedIdentifier(_ value: Any?) -> String? { + guard let rawValue = stringValue(value)?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawValue.isEmpty + else { + return nil + } + + return rawValue + .replacingOccurrences(of: "-", with: ":") + .uppercased() + } + + private static func stringValue(_ value: Any?) -> String? { + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + return nil + } + + private static func firstNonEmpty( + _ values: String?..., + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> String { + for value in values { + if let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return value + } + } + + return localization.string("deviceName.bluetoothFallback", defaultValue: "蓝牙设备") + } + + private static func intProperty(_ key: String, object: io_object_t) -> Int? { + guard let value = IORegistryEntryCreateCFProperty(object, key as CFString, kCFAllocatorDefault, 0) else { + return nil + } + + return value.takeRetainedValue() as? Int + } + + private static func stringProperty(_ key: String, object: io_object_t) -> String? { + guard let value = IORegistryEntryCreateCFProperty(object, key as CFString, kCFAllocatorDefault, 0) else { + return nil + } + + return value.takeRetainedValue() as? String + } + + private static func runCommand( + path: String, + arguments: [String], + timeout: TimeInterval, + outputLineFilter: ((String) -> Bool)? = nil + ) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + let outputAccumulator = CommandOutputAccumulator(lineFilter: outputLineFilter) + outputPipe.fileHandleForReading.readabilityHandler = { handle in + outputAccumulator.append(handle.availableData) + } + errorPipe.fileHandleForReading.readabilityHandler = { handle in + _ = handle.availableData + } + + do { + try process.run() + } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + outputPipe.fileHandleForReading.closeFile() + errorPipe.fileHandleForReading.closeFile() + return nil + } + + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + Thread.sleep(forTimeInterval: 0.03) + } + + guard !process.isRunning else { + process.terminate() + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + outputAccumulator.append(outputPipe.fileHandleForReading.readDataToEndOfFile()) + _ = errorPipe.fileHandleForReading.readDataToEndOfFile() + outputPipe.fileHandleForReading.closeFile() + errorPipe.fileHandleForReading.closeFile() + let partialOutput = outputAccumulator.output() + return outputLineFilter == nil || partialOutput.isEmpty ? nil : partialOutput + } + + process.waitUntilExit() + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + outputAccumulator.append(outputPipe.fileHandleForReading.readDataToEndOfFile()) + _ = errorPipe.fileHandleForReading.readDataToEndOfFile() + outputPipe.fileHandleForReading.closeFile() + errorPipe.fileHandleForReading.closeFile() + + return outputAccumulator.output() + } +} + +private final class CommandOutputAccumulator: @unchecked Sendable { + private let lock = NSLock() + private let lineFilter: ((String) -> Bool)? + private var bufferedOutput = "" + private var pendingLine = "" + + init(lineFilter: ((String) -> Bool)?) { + self.lineFilter = lineFilter + } + + func append(_ data: Data) { + guard !data.isEmpty, + let chunk = String(data: data, encoding: .utf8) + else { + return + } + + lock.lock() + if let lineFilter { + appendFiltered(chunk, lineFilter: lineFilter) + } else { + bufferedOutput.append(chunk) + } + lock.unlock() + } + + func output() -> String { + lock.lock() + if let lineFilter, + !pendingLine.isEmpty, + lineFilter(pendingLine) { + bufferedOutput.append(pendingLine) + bufferedOutput.append("\n") + } + pendingLine = "" + let currentOutput = bufferedOutput + lock.unlock() + return currentOutput + } + + private func appendFiltered( + _ chunk: String, + lineFilter: (String) -> Bool + ) { + pendingLine.append(chunk) + let lines = pendingLine.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count > 1 else { + return + } + + for line in lines.dropLast() { + let text = String(line) + if lineFilter(text) { + bufferedOutput.append(text) + bufferedOutput.append("\n") + } + } + + pendingLine = String(lines.last ?? "") + } +} + +private struct BluetoothProfile { + let connectedDevices: [BluetoothProfileDevice] + let batteryDevices: [BluetoothProfileDevice] +} + +private struct BluetoothProfileDevice { + let name: String + let info: [String: Any] + let isConnected: Bool +} + +private struct DeviceBatteryBaseSample: Sendable { + let items: [DeviceBatteryItem] + let bluetoothBatteryTargets: [BluetoothBatteryTarget] + let shouldReadBatteryCenterLog: Bool +} + +fileprivate struct BluetoothBatteryTarget: Sendable { + let id: String + let name: String + let address: String? + let vendorID: String? + let productID: String? + let model: String? + let kind: DeviceBatteryKind + let detail: String? + let isConnected: Bool + + var componentGroupID: String { + address ?? id + } +} + +struct DeviceBatteryBluetoothPowerLogReading: Equatable, Sendable { + let name: String + let vendorID: String? + let productID: String? + let deviceType: String? + let component: DeviceBatteryBluetoothPowerLogComponent? + let level: Int + let chargeState: DeviceBatteryChargeState +} + +enum DeviceBatteryBluetoothPowerLogComponent: String, Sendable { + case left + case right + case chargingCase + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .left: + return localization.string("component.left", defaultValue: "左耳") + case .right: + return localization.string("component.right", defaultValue: "右耳") + case .chargingCase: + return localization.string("component.chargingCase", defaultValue: "充电盒") + } + } + + var idSuffix: String { + switch self { + case .left: + return "left" + case .right: + return "right" + case .chargingCase: + return "case" + } + } + + var componentRole: DeviceBatteryComponentRole { + switch self { + case .left: + return .left + case .right: + return .right + case .chargingCase: + return .chargingCase + } + } +} + +enum DeviceBatteryBluetoothPowerLogParser { + static func readings(from output: String) -> [DeviceBatteryBluetoothPowerLogReading] { + var latestByIdentity: [String: DeviceBatteryBluetoothPowerLogReading] = [:] + for line in output.split(whereSeparator: \.isNewline) { + for reading in readings(fromLine: String(line)) { + latestByIdentity[identityKey(for: reading)] = reading + } + } + + return latestByIdentity.keys.sorted().compactMap { latestByIdentity[$0] } + } + + static func reading(from line: String) -> DeviceBatteryBluetoothPowerLogReading? { + readings(fromLine: line).first { $0.component == nil } + } + + static func readings(fromLine line: String) -> [DeviceBatteryBluetoothPowerLogReading] { + guard line.contains("CBPowerSource"), + line.contains("Power source updated"), + let name = stringValue(after: "Nm '", before: "'", in: line) + else { + return [] + } + + let vendorID = stringValue(after: "VID ", before: " ", in: line) + let productID = stringValue(after: "PID ", before: " ", in: line) + let deviceType = stringValue(after: "AcCa ", before: ",", in: line) + var readings: [DeviceBatteryBluetoothPowerLogReading] = [] + + if let batteryValue = batteryPercentValue(after: "Battery ", in: line) + ?? batteryPercentValue(after: "Battery M ", in: line) { + readings.append(DeviceBatteryBluetoothPowerLogReading( + name: name, + vendorID: vendorID, + productID: productID, + deviceType: deviceType, + component: nil, + level: min(max(abs(batteryValue.level), 0), 100), + chargeState: batteryValue.isCharging ? .charging : .normal + )) + } + + readings.append(contentsOf: componentBatteryValues(in: line).map { component, batteryValue in + DeviceBatteryBluetoothPowerLogReading( + name: name, + vendorID: vendorID, + productID: productID, + deviceType: deviceType, + component: component, + level: min(max(abs(batteryValue.level), 0), 100), + chargeState: batteryValue.isCharging ? .charging : .normal + ) + }) + + return readings + } + + private static func componentBatteryValues( + in line: String + ) -> [(DeviceBatteryBluetoothPowerLogComponent, (level: Int, isCharging: Bool))] { + [ + (.left, "Left "), + (.right, "Right "), + (.chargingCase, "Case ") + ] + .compactMap { component, prefix in + guard let batteryValue = batteryPercentValue(after: prefix, in: line) else { + return nil + } + return (component, batteryValue) + } + } + + private static func identityKey(for reading: DeviceBatteryBluetoothPowerLogReading) -> String { + [ + reading.name, + reading.vendorID ?? "", + reading.productID ?? "", + reading.component?.rawValue ?? "main" + ] + .joined(separator: "|") + .lowercased() + } + + private static func stringValue( + after prefix: String, + before suffix: Character, + in text: String + ) -> String? { + guard let startRange = text.range(of: prefix) else { + return nil + } + + let start = startRange.upperBound + guard let end = text[start...].firstIndex(of: suffix) else { + return nil + } + + let value = String(text[start.. (level: Int, isCharging: Bool)? { + guard let startRange = text.range(of: prefix) else { + return nil + } + + let remainder = text[startRange.upperBound...] + var value = "" + for character in remainder { + if character == "+" || character == "-" || character.isNumber { + value.append(character) + continue + } + break + } + + guard !value.isEmpty else { + return nil + } + + guard let level = Int(value) else { + return nil + } + + return (level: level, isCharging: value.hasPrefix("+")) + } +} + +struct DeviceBatteryBatteryCenterLogReading: Equatable, Sendable { + let name: String? + let groupName: String? + let productID: String? + let model: String? + let category: String? + let accessoryID: String? + let transportType: String? + let level: Int + let chargeState: DeviceBatteryChargeState + let isConnected: Bool? + let isInternal: Bool? +} + +enum DeviceBatteryBatteryCenterLogParser { + static func readings(from output: String) -> [DeviceBatteryBatteryCenterLogReading] { + output + .split(whereSeparator: \.isNewline) + .compactMap { reading(fromLine: String($0)) } + } + + static func reading(fromLine line: String) -> DeviceBatteryBatteryCenterLogReading? { + guard line.contains("BCBatteryDevice"), + let payload = stringValue(after: "", in: line), + let level = intField("percentCharge", in: payload), + (0...100).contains(level) + else { + return nil + } + + return DeviceBatteryBatteryCenterLogReading( + name: field("name", in: payload), + groupName: field("groupName", in: payload), + productID: field("productIdentifier", in: payload), + model: field("modelNumber", in: payload), + category: field("accessoryCategory", in: payload), + accessoryID: field("accessoryIdentifier", in: payload), + transportType: field("transportType", in: payload), + level: level, + chargeState: boolField("charging", in: payload) == true ? .charging : .normal, + isConnected: boolField("connected", in: payload), + isInternal: boolField("internal", in: payload) + ) + } + + private static func field(_ key: String, in text: String) -> String? { + for segment in text.split(separator: ";", omittingEmptySubsequences: false) { + let parts = segment.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == key + else { + continue + } + + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "(null)" else { + return nil + } + return value + } + + return nil + } + + private static func intField(_ key: String, in text: String) -> Int? { + field(key, in: text).flatMap(Int.init) + } + + private static func boolField(_ key: String, in text: String) -> Bool? { + switch field(key, in: text)?.uppercased() { + case "YES": + return true + case "NO": + return false + default: + return nil + } + } + + private static func stringValue( + after prefix: String, + before suffix: Character, + in text: String + ) -> String? { + guard let startRange = text.range(of: prefix) else { + return nil + } + + let start = startRange.upperBound + guard let end = text[start...].firstIndex(of: suffix) else { + return nil + } + + let value = String(text[start.. [DeviceBatteryAppleHeadphoneAdvertisementReading] { + let bytes = [UInt8](manufacturerData) + guard bytes.count >= 2, bytes[0] == 0x4C, bytes[1] == 0x00 else { + return [] + } + + switch bytes.count { + case 29 where bytes[2] == 0x07: + return openCaseReadings(from: bytes) + case 25 where bytes[2] == 0x12: + return closedCaseReadings(from: bytes) + default: + return [] + } + } + + private static func openCaseReadings(from bytes: [UInt8]) -> [DeviceBatteryAppleHeadphoneAdvertisementReading] { + let flip = (bytes[7] & 0x02) == 0 + return [ + reading(component: .chargingCase, rawLevel: bytes[16]), + reading(component: .left, rawLevel: bytes[flip ? 15 : 14]), + reading(component: .right, rawLevel: bytes[flip ? 14 : 15]) + ] + .compactMap { $0 } + } + + private static func closedCaseReadings(from bytes: [UInt8]) -> [DeviceBatteryAppleHeadphoneAdvertisementReading] { + [ + reading(component: .chargingCase, rawLevel: bytes[12]), + reading(component: .left, rawLevel: bytes[13]), + reading(component: .right, rawLevel: bytes[14]) + ] + .compactMap { $0 } + } + + private static func reading( + component: DeviceBatteryBluetoothPowerLogComponent, + rawLevel: UInt8 + ) -> DeviceBatteryAppleHeadphoneAdvertisementReading? { + guard rawLevel != 0xFF else { + return nil + } + + let isCharging = rawLevel > 100 + let level = Int(rawLevel & 0x7F) + guard (0...100).contains(level) else { + return nil + } + + return DeviceBatteryAppleHeadphoneAdvertisementReading( + component: component, + level: level, + chargeState: isCharging ? .charging : .normal + ) + } +} + +@MainActor +private final class DeviceBatteryAppleHeadphoneAdvertisementReader: NSObject, @preconcurrency CBCentralManagerDelegate { + private let targets: [BluetoothBatteryTarget] + private let targetsByName: [String: BluetoothBatteryTarget] + private let referenceDate: Date + private let localization: PluginLocalization + private var centralManager: CBCentralManager? + private var continuations: [CheckedContinuation<[DeviceBatteryItem], Never>] = [] + private var readingsByTargetID: [String: [DeviceBatteryBluetoothPowerLogComponent: DeviceBatteryAppleHeadphoneAdvertisementReading]] = [:] + private var timeoutTask: Task? + private var didFinish = false + + static func collectBatteryDevices( + targets: [BluetoothBatteryTarget], + referenceDate: Date, + localization: PluginLocalization + ) async -> [DeviceBatteryItem] { + let eligibleTargets = targets.filter { target in + target.vendorID.flatMap(DeviceBatterySampler.normalizedHexIdentifierForReader) == "004C" + && DeviceBatteryAppleHeadphoneAdvertisementReader.supportsAdvertisementBattery(target: target) + } + guard !eligibleTargets.isEmpty else { + return [] + } + + let reader = DeviceBatteryAppleHeadphoneAdvertisementReader( + targets: eligibleTargets, + referenceDate: referenceDate, + localization: localization + ) + return await reader.collect() + } + + init( + targets: [BluetoothBatteryTarget], + referenceDate: Date, + localization: PluginLocalization + ) { + self.targets = targets + self.targetsByName = Dictionary(uniqueKeysWithValues: targets.map { ($0.name.lowercased(), $0) }) + self.referenceDate = referenceDate + self.localization = localization + super.init() + } + + private func collect() async -> [DeviceBatteryItem] { + await withCheckedContinuation { continuation in + continuations.append(continuation) + centralManager = CBCentralManager(delegate: self, queue: .main) + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(2500)) + self?.finish() + } + } + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard central.state == .poweredOn else { + finish() + return + } + + central.scanForPeripherals( + withServices: nil, + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + guard let name = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines), + let target = targetsByName[name.lowercased()], + let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + else { + return + } + + let readings = DeviceBatteryAppleHeadphoneAdvertisementParser.readings(from: manufacturerData) + guard !readings.isEmpty else { + return + } + + var targetReadings = readingsByTargetID[target.id] ?? [:] + for reading in readings { + targetReadings[reading.component] = reading + } + readingsByTargetID[target.id] = targetReadings + + let hasChargingState = targetReadings.values.contains { $0.chargeState == .charging } + if hasChargingState || readingsByTargetID.count == targets.count { + finish() + } + } + + private func finish() { + guard !didFinish else { + return + } + + didFinish = true + timeoutTask?.cancel() + centralManager?.stopScan() + let items = batteryItems() + let pendingContinuations = continuations + continuations.removeAll() + for continuation in pendingContinuations { + continuation.resume(returning: items) + } + } + + private func batteryItems() -> [DeviceBatteryItem] { + readingsByTargetID.flatMap { targetID, readingsByComponent -> [DeviceBatteryItem] in + guard let target = targets.first(where: { $0.id == targetID }) else { + return [] + } + + return readingsByComponent.values.map { reading in + DeviceBatteryItem( + id: "apple-headphone-advertisement-\(target.componentGroupID)-\(reading.component.idSuffix)", + name: DeviceBatterySampler.powerLogItemNameForReader( + component: reading.component, + targetName: target.name, + localization: localization + ), + model: target.model, + kind: .airPodsPart, + level: reading.level, + chargeState: reading.chargeState, + parentName: DeviceBatterySampler.powerLogParentNameForReader( + component: reading.component, + targetName: target.name, + localization: localization + ), + source: "AppleHeadphoneAdvertisement", + lastUpdated: referenceDate, + isConnected: target.isConnected, + detail: target.detail, + componentIdentity: DeviceBatteryComponentIdentity( + groupID: target.componentGroupID, + role: reading.component.componentRole + ) + ) + } + } + } + + private static func supportsAdvertisementBattery(target: BluetoothBatteryTarget) -> Bool { + let haystack = [ + target.name, + target.model, + target.detail + ] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + return haystack.contains("airpods") || haystack.contains("beats") + } +} + +@MainActor +private final class DeviceBatteryBLEBatteryReader: NSObject, @preconcurrency CBCentralManagerDelegate, @preconcurrency CBPeripheralDelegate { + private static let batteryService = CBUUID(string: "180F") + private static let deviceInformationService = CBUUID(string: "180A") + private static let batteryLevelCharacteristic = CBUUID(string: "2A19") + private static let modelNumberCharacteristic = CBUUID(string: "2A24") + private static let manufacturerNameCharacteristic = CBUUID(string: "2A29") + + private let targetsByName: [String: BluetoothBatteryTarget] + private let referenceDate: Date + private let localization: PluginLocalization + private var centralManager: CBCentralManager? + private var continuations: [CheckedContinuation<[DeviceBatteryItem], Never>] = [] + private var peripheralsByID: [UUID: CBPeripheral] = [:] + private var pendingPeripheralIDs: Set = [] + private var readingByID: [UUID: BluetoothBatteryReading] = [:] + private var discoveredNames: Set = [] + private var timeoutTask: Task? + private var didFinish = false + + static func collectBatteryDevices( + targets: [BluetoothBatteryTarget], + referenceDate: Date, + localization: PluginLocalization + ) async -> [DeviceBatteryItem] { + let eligibleTargets = targets.filter { target in + target.isConnected && (target.kind == .bluetooth || target.kind == .magicAccessory) + } + guard !eligibleTargets.isEmpty else { + return [] + } + + let reader = DeviceBatteryBLEBatteryReader( + targets: eligibleTargets, + referenceDate: referenceDate, + localization: localization + ) + return await reader.collect() + } + + init( + targets: [BluetoothBatteryTarget], + referenceDate: Date, + localization: PluginLocalization + ) { + self.targetsByName = Dictionary( + uniqueKeysWithValues: targets.map { ($0.name.lowercased(), $0) } + ) + self.referenceDate = referenceDate + self.localization = localization + super.init() + } + + private func collect() async -> [DeviceBatteryItem] { + await withCheckedContinuation { continuation in + continuations.append(continuation) + centralManager = CBCentralManager(delegate: self, queue: .main) + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(3)) + self?.finish() + } + } + } + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard central.state == .poweredOn else { + finish() + return + } + + let connectedPeripherals = central.retrieveConnectedPeripherals( + withServices: [Self.batteryService] + ) + for peripheral in connectedPeripherals { + register(peripheral, central: central) + } + + central.scanForPeripherals( + withServices: [Self.batteryService], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + + if connectedPeripherals.isEmpty { + timeoutTask?.cancel() + timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(2)) + self?.finish() + } + } + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + register(peripheral, central: central) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + discoverServices(for: peripheral) + } + + func centralManager( + _ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error? + ) { + pendingPeripheralIDs.remove(peripheral.identifier) + finishIfSettled() + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard error == nil, let services = peripheral.services else { + pendingPeripheralIDs.remove(peripheral.identifier) + finishIfSettled() + return + } + + let wantedServices = services.filter { service in + service.uuid == Self.batteryService || service.uuid == Self.deviceInformationService + } + guard !wantedServices.isEmpty else { + pendingPeripheralIDs.remove(peripheral.identifier) + finishIfSettled() + return + } + + for service in wantedServices { + peripheral.discoverCharacteristics( + [ + Self.batteryLevelCharacteristic, + Self.modelNumberCharacteristic, + Self.manufacturerNameCharacteristic + ], + for: service + ) + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + guard error == nil, let characteristics = service.characteristics else { + pendingPeripheralIDs.remove(peripheral.identifier) + finishIfSettled() + return + } + + var didRead = false + for characteristic in characteristics { + if characteristic.uuid == Self.batteryLevelCharacteristic + || characteristic.uuid == Self.modelNumberCharacteristic + || characteristic.uuid == Self.manufacturerNameCharacteristic { + didRead = true + peripheral.readValue(for: characteristic) + } + } + + if !didRead, service.uuid == Self.batteryService { + pendingPeripheralIDs.remove(peripheral.identifier) + finishIfSettled() + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didUpdateValueFor characteristic: CBCharacteristic, + error: Error? + ) { + defer { + if readingByID[peripheral.identifier]?.level != nil { + pendingPeripheralIDs.remove(peripheral.identifier) + finishIfSettled() + } + } + + guard error == nil, let value = characteristic.value else { + return + } + + var reading = readingByID[peripheral.identifier] ?? BluetoothBatteryReading() + switch characteristic.uuid { + case Self.batteryLevelCharacteristic: + guard let level = value.first, level <= 100 else { + return + } + reading.level = Int(level) + case Self.modelNumberCharacteristic: + reading.model = String(data: value, encoding: .utf8) + case Self.manufacturerNameCharacteristic: + reading.manufacturer = String(data: value, encoding: .utf8) + default: + return + } + readingByID[peripheral.identifier] = reading + } + + private func register(_ peripheral: CBPeripheral, central: CBCentralManager) { + guard let name = peripheral.name?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty, + targetsByName[name.lowercased()] != nil, + !discoveredNames.contains(name.lowercased()) + else { + return + } + + discoveredNames.insert(name.lowercased()) + peripheralsByID[peripheral.identifier] = peripheral + pendingPeripheralIDs.insert(peripheral.identifier) + peripheral.delegate = self + + if peripheral.state == .connected { + discoverServices(for: peripheral) + } else { + central.connect(peripheral, options: nil) + } + } + + private func discoverServices(for peripheral: CBPeripheral) { + peripheral.discoverServices([Self.batteryService, Self.deviceInformationService]) + } + + private func finishIfSettled() { + if !pendingPeripheralIDs.isEmpty { + return + } + + finish() + } + + private func finish() { + guard !didFinish else { + return + } + + didFinish = true + timeoutTask?.cancel() + centralManager?.stopScan() + let items = batteryItems() + let pendingContinuations = continuations + continuations.removeAll() + for continuation in pendingContinuations { + continuation.resume(returning: items) + } + } + + private func batteryItems() -> [DeviceBatteryItem] { + readingByID.compactMap { peripheralID, reading in + guard let peripheral = peripheralsByID[peripheralID], + let name = peripheral.name, + let target = targetsByName[name.lowercased()], + let level = reading.level + else { + return nil + } + + return DeviceBatteryItem( + id: "corebluetooth-\(target.address ?? target.id)", + name: target.name, + model: firstNonEmpty(reading.model, target.model), + kind: target.kind, + level: level, + chargeState: .normal, + parentName: nil, + source: "CoreBluetooth", + lastUpdated: referenceDate, + isConnected: true, + detail: firstNonEmpty(reading.manufacturer, target.detail), + componentIdentity: DeviceBatterySampler.componentAggregateIdentity( + groupID: target.componentGroupID, + kind: target.kind + ) + ) + } + } + + private func firstNonEmpty(_ values: String?...) -> String? { + for value in values { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { + return trimmed + } + } + + return nil + } +} + +private struct BluetoothBatteryReading { + var level: Int? + var model: String? + var manufacturer: String? +} + +private extension IOBluetoothDevice { + var pluginBatteryPercentSingle: Int? { + pluginValue(forKey: "batteryPercentSingle") as? Int + } + + func pluginValue(forKey key: String) -> Any? { + guard responds(to: Selector(key)) else { + return nil + } + + return value(forKey: key) + } +} + +enum HeadphoneModelCatalog { + private static let modelNamesByProductID: [String: String] = [ + "2002": "AirPods", + "2003": "Powerbeats3", + "2005": "BeatsX", + "2006": "Beats Solo3", + "200e": "AirPods Pro", + "200a": "AirPods Max", + "200b": "Powerbeats Pro", + "200c": "Beats Solo Pro", + "200d": "Powerbeats4", + "200f": "AirPods 2", + "2010": "Beats Flex", + "2011": "Beats Studio Buds", + "2012": "Beats Fit Pro", + "2013": "AirPods 3", + "2014": "AirPods Pro 2", + "2016": "Beats Studio Buds+", + "2017": "Beats Studio Pro", + "2019": "AirPods 4", + "201b": "AirPods 4", + "201d": "AirPods Pro 2", + "201f": "AirPods Max", + "2024": "AirPods Pro 2", + "2026": "Beats Solo Buds", + "2027": "Powerbeats Pro 2" + ] + + static func modelName(forProductID productID: String) -> String? { + let normalized = productID + .replacingOccurrences(of: "0x", with: "") + .lowercased() + return modelNamesByProductID[normalized] + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatterySettingsView.swift b/Plugins/DeviceBattery/Sources/DeviceBatterySettingsView.swift new file mode 100644 index 0000000..6e0ee26 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatterySettingsView.swift @@ -0,0 +1,332 @@ +import SwiftUI +import MacToolsPluginKit + +struct DeviceBatterySettingsView: View { + @ObservedObject var store: DeviceBatteryStore + let localization: PluginLocalization + let onChange: () -> Void + let onNotificationSettingsChange: () -> Void + @State private var thresholdText: String + + init( + store: DeviceBatteryStore, + localization: PluginLocalization = PluginLocalization(bundle: .main), + onChange: @escaping () -> Void, + onNotificationSettingsChange: @escaping () -> Void = {} + ) { + self.store = store + self.localization = localization + self.onChange = onChange + self.onNotificationSettingsChange = onNotificationSettingsChange + _thresholdText = State(initialValue: "\(store.lowBatteryNotificationThreshold)") + } + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + layoutSection + sourceSection + notificationSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: store.lowBatteryNotificationThreshold) { _, newValue in + thresholdText = "\(newValue)" + } + } + + private var layoutSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader( + systemName: "rectangle.grid.2x2", + title: localization.string("settings.layout.title", defaultValue: "组件布局") + ) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + ForEach(DeviceBatteryLayoutMode.allCases, id: \.self) { mode in + DeviceBatteryLayoutModeButton( + mode: mode, + isSelected: store.layoutMode == mode, + iconSystemName: iconName(for: mode), + localization: localization, + action: { + store.setLayoutMode(mode) + onChange() + } + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(PluginSettingsTheme.Spacing.cardContent) + .pluginSettingsCardBackground(.host) + } + } + + private var sourceSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader( + systemName: "bolt.horizontal.circle", + title: localization.string("settings.sources.title", defaultValue: "显示内容") + ) + + VStack(spacing: 0) { + sourceToggle( + title: localization.string("settings.source.internal.title", defaultValue: "Mac 内置电池"), + description: localization.string( + "settings.source.internal.description", + defaultValue: "显示本机电量、充电状态和剩余时间。" + ), + isOn: store.showInternalBattery, + isFirst: true, + action: store.setShowInternalBattery + ) + sourceToggle( + title: localization.string( + "settings.source.appleMobile.title", + defaultValue: "iPhone、iPad 与 Apple Watch" + ), + description: localization.string( + "settings.source.appleMobile.description", + defaultValue: "读取已信任并通过 USB 或 Wi-Fi 连接的 Apple 移动设备。" + ), + isOn: store.showAppleMobileDevices, + action: store.setShowAppleMobileDevices + ) + sourceToggle( + title: localization.string("settings.source.bluetooth.title", defaultValue: "蓝牙与 Apple 外设"), + description: localization.string( + "settings.source.bluetooth.description", + defaultValue: "读取系统可见的蓝牙设备、AirPods 分体电量和 Magic 外设。" + ), + isOn: store.showBluetoothDevices, + action: store.setShowBluetoothDevices + ) + sourceToggle( + title: localization.string("settings.source.rapoo.title", defaultValue: "厂商 HID 鼠标"), + description: localization.string( + "settings.source.rapoo.description", + defaultValue: "读取已适配鼠标的电量、充电状态、设备型号和名称。" + ), + isOn: store.showRapooDevices, + isLast: true, + action: store.setShowRapooDevices + ) + } + .pluginSettingsCardBackground(.plugin) + } + } + + private var notificationSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader( + systemName: "bell.badge", + title: localization.string("settings.notification.title", defaultValue: "低电量通知") + ) + + VStack(spacing: 0) { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("settings.notification.lowBattery.title", defaultValue: "发送低电量通知")) + .font(PluginSettingsTheme.Typography.rowTitle) + Text(localization.string( + "settings.notification.lowBattery.description", + defaultValue: "设备电量低于阈值时发送系统通知。" + )) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 12) + + Toggle("", isOn: Binding( + get: { store.lowBatteryNotificationEnabled }, + set: { newValue in + store.setLowBatteryNotificationEnabled(newValue) + onNotificationSettingsChange() + } + )) + .toggleStyle(.switch) + .labelsHidden() + } + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) + + if store.lowBatteryNotificationEnabled { + Divider() + .padding(.leading, PluginSettingsTheme.Spacing.rowHorizontal) + + thresholdRow + } + } + .pluginSettingsCardBackground(.plugin) + } + } + + private var thresholdRow: some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("settings.notification.threshold.title", defaultValue: "通知阈值")) + .font(PluginSettingsTheme.Typography.rowTitle) + Text(localization.string( + "settings.notification.threshold.description", + defaultValue: "低于此百分比时提醒。" + )) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 12) + + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + TextField( + "", + text: Binding( + get: { thresholdText }, + set: { newValue in + thresholdText = newValue.filter(\.isNumber) + guard let threshold = Int(thresholdText) else { + return + } + + store.setLowBatteryNotificationThreshold(threshold) + onNotificationSettingsChange() + } + ) + ) + .textFieldStyle(.roundedBorder) + .font(PluginSettingsTheme.Typography.monospacedValue) + .multilineTextAlignment(.trailing) + .frame(width: 58) + .onSubmit(commitThresholdText) + + Text("%") + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + + Stepper("", value: Binding( + get: { store.lowBatteryNotificationThreshold }, + set: { newValue in + store.setLowBatteryNotificationThreshold(newValue) + onNotificationSettingsChange() + } + ), in: DeviceBatteryLowBatteryThresholds.minimum...DeviceBatteryLowBatteryThresholds.maximum) + .labelsHidden() + .controlSize(.small) + } + } + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) + } + + private func sourceToggle( + title: String, + description: String, + isOn: Bool, + isFirst: Bool = false, + isLast: Bool = false, + action: @escaping (Bool) -> Void + ) -> some View { + VStack(spacing: 0) { + if !isFirst { + Divider() + .padding(.leading, PluginSettingsTheme.Spacing.rowHorizontal) + } + + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(title) + .font(PluginSettingsTheme.Typography.rowTitle) + Text(description) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 12) + + Toggle("", isOn: Binding( + get: { isOn }, + set: { + action($0) + onChange() + } + )) + .toggleStyle(.switch) + .labelsHidden() + } + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) + } + } + + private func sectionHeader(systemName: String, title: String) -> some View { + Label { + Text(title) + .font(PluginSettingsTheme.Typography.sectionTitle) + } icon: { + Image(systemName: systemName) + } + .foregroundStyle(.secondary) + } + + private func commitThresholdText() { + let threshold = Int(thresholdText) ?? store.lowBatteryNotificationThreshold + store.setLowBatteryNotificationThreshold(threshold) + thresholdText = "\(store.lowBatteryNotificationThreshold)" + onNotificationSettingsChange() + } + + private func iconName(for mode: DeviceBatteryLayoutMode) -> String { + switch mode { + case .grid: + return "list.bullet.rectangle" + case .list: + return "gauge.with.dots.needle.67percent" + } + } +} + +private struct DeviceBatteryLayoutModeButton: View { + let mode: DeviceBatteryLayoutMode + let isSelected: Bool + let iconSystemName: String + let localization: PluginLocalization + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + Image(systemName: iconSystemName) + .pluginSettingsRowIconStyle(isSelected ? Color.accentColor : Color.primary) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(mode.title(localization: localization)) + .font(PluginSettingsTheme.Typography.secondaryLabel.weight(.semibold)) + .lineLimit(1) + + Text(mode.subtitle(localization: localization)) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(isSelected ? Color.accentColor.opacity(0.78) : .secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + .frame(height: PluginSettingsTheme.Size.controlHeight * 2) + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .foregroundStyle(isSelected ? Color.accentColor : Color.primary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous) + .fill( + isSelected + ? PluginSettingsTheme.Palette.activeControlBackground + : PluginSettingsTheme.Palette.recessedControlBackground + ) + ) + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift new file mode 100644 index 0000000..c087001 --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryStore.swift @@ -0,0 +1,126 @@ +import Foundation +import MacToolsPluginKit + +@MainActor +final class DeviceBatteryStore: ObservableObject { + private enum Key { + static let layoutMode = "layout-mode" + static let showInternalBattery = "show-internal-battery" + static let showBluetoothDevices = "show-bluetooth-devices" + static let showAppleMobileDevices = "show-apple-mobile-devices" + static let showRapooDevices = "show-rapoo-devices" + static let lowBatteryNotificationEnabled = "low-battery-notification-enabled" + static let lowBatteryNotificationThreshold = "low-battery-notification-threshold" + } + + @Published private(set) var layoutMode: DeviceBatteryLayoutMode + @Published private(set) var showInternalBattery: Bool + @Published private(set) var showBluetoothDevices: Bool + @Published private(set) var showAppleMobileDevices: Bool + @Published private(set) var showRapooDevices: Bool + @Published private(set) var lowBatteryNotificationEnabled: Bool + @Published private(set) var lowBatteryNotificationThreshold: Int + + private let storage: any PluginStorage + + init(storage: any PluginStorage) { + self.storage = storage + layoutMode = DeviceBatteryLayoutMode( + rawValue: storage.string(forKey: Key.layoutMode) ?? DeviceBatteryLayoutMode.grid.rawValue + ) ?? .grid + showInternalBattery = Self.boolValue(storage, key: Key.showInternalBattery, defaultValue: true) + showBluetoothDevices = Self.boolValue(storage, key: Key.showBluetoothDevices, defaultValue: true) + showAppleMobileDevices = Self.boolValue(storage, key: Key.showAppleMobileDevices, defaultValue: true) + showRapooDevices = Self.boolValue(storage, key: Key.showRapooDevices, defaultValue: true) + lowBatteryNotificationEnabled = Self.boolValue( + storage, + key: Key.lowBatteryNotificationEnabled, + defaultValue: false + ) + lowBatteryNotificationThreshold = Self.normalizedLowBatteryNotificationThreshold( + storage.object(forKey: Key.lowBatteryNotificationThreshold) == nil + ? DeviceBatteryLowBatteryThresholds.defaultValue + : storage.integer(forKey: Key.lowBatteryNotificationThreshold) + ) + } + + func setLayoutMode(_ mode: DeviceBatteryLayoutMode) { + guard layoutMode != mode else { + return + } + + layoutMode = mode + storage.set(mode.rawValue, forKey: Key.layoutMode) + } + + func setShowInternalBattery(_ isShown: Bool) { + guard showInternalBattery != isShown else { + return + } + + showInternalBattery = isShown + storage.set(isShown, forKey: Key.showInternalBattery) + } + + func setShowBluetoothDevices(_ isShown: Bool) { + guard showBluetoothDevices != isShown else { + return + } + + showBluetoothDevices = isShown + storage.set(isShown, forKey: Key.showBluetoothDevices) + } + + func setShowAppleMobileDevices(_ isShown: Bool) { + guard showAppleMobileDevices != isShown else { + return + } + + showAppleMobileDevices = isShown + storage.set(isShown, forKey: Key.showAppleMobileDevices) + } + + func setShowRapooDevices(_ isShown: Bool) { + guard showRapooDevices != isShown else { + return + } + + showRapooDevices = isShown + storage.set(isShown, forKey: Key.showRapooDevices) + } + + func setLowBatteryNotificationEnabled(_ isEnabled: Bool) { + guard lowBatteryNotificationEnabled != isEnabled else { + return + } + + lowBatteryNotificationEnabled = isEnabled + storage.set(isEnabled, forKey: Key.lowBatteryNotificationEnabled) + } + + func setLowBatteryNotificationThreshold(_ threshold: Int) { + let normalizedThreshold = Self.normalizedLowBatteryNotificationThreshold(threshold) + guard lowBatteryNotificationThreshold != normalizedThreshold else { + return + } + + lowBatteryNotificationThreshold = normalizedThreshold + storage.set(normalizedThreshold, forKey: Key.lowBatteryNotificationThreshold) + } + + static func normalizedLowBatteryNotificationThreshold(_ threshold: Int) -> Int { + DeviceBatteryLowBatteryThresholds.normalized(threshold) + } + + private static func boolValue( + _ storage: any PluginStorage, + key: String, + defaultValue: Bool + ) -> Bool { + guard storage.object(forKey: key) != nil else { + return defaultValue + } + + return storage.bool(forKey: key) + } +} diff --git a/Plugins/DeviceBattery/Sources/DeviceBatteryViewModel.swift b/Plugins/DeviceBattery/Sources/DeviceBatteryViewModel.swift new file mode 100644 index 0000000..2e90c1a --- /dev/null +++ b/Plugins/DeviceBattery/Sources/DeviceBatteryViewModel.swift @@ -0,0 +1,286 @@ +import Combine +import Foundation +import MacToolsPluginKit + +@MainActor +final class DeviceBatteryViewModel: ObservableObject { + private enum MobileRefreshInterval { + static let componentVisible: TimeInterval = 90 + static let background: TimeInterval = 5 * 60 + } + + @Published private(set) var snapshot: DeviceBatterySnapshot = .idle { + didSet { + guard oldValue != snapshot else { + return + } + onSnapshotChange?() + } + } + + private let sampler: any DeviceBatterySampling + private let rapooMonitor: any RapooBatteryMonitoring + private let localization: PluginLocalization + private var samplingTask: Task? + private var refreshTask: Task? + private var systemItems: [DeviceBatteryItem] = [] + private var rapooSnapshot = RapooMouseBatterySnapshot.idle + private var lastSystemUpdate: Date? + private var isCollecting = false + private var pendingCollectRequest = false + private var isStarted = false + private var includeInternalBattery = true + private var includeBluetoothDevices = true + private var includeAppleMobileDevices = true + private var includeRapooDevices = true + private var isComponentPanelVisible = false + + var onSnapshotChange: (() -> Void)? + + convenience init() { + let localization = PluginLocalization(bundle: .main) + self.init( + sampler: DeviceBatterySampler(localization: localization), + rapooMonitor: RapooHIDBatteryMonitor(localization: localization), + localization: localization + ) + } + + init( + sampler: any DeviceBatterySampling, + rapooMonitor: any RapooBatteryMonitoring, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.sampler = sampler + self.rapooMonitor = rapooMonitor + self.localization = localization + rapooSnapshot = rapooMonitor.snapshot + } + + func start( + includeInternalBattery: Bool, + includeBluetoothDevices: Bool, + includeAppleMobileDevices: Bool, + includeRapooDevices: Bool + ) { + updateOptions( + includeInternalBattery: includeInternalBattery, + includeBluetoothDevices: includeBluetoothDevices, + includeAppleMobileDevices: includeAppleMobileDevices, + includeRapooDevices: includeRapooDevices + ) + + if isStarted { + collectNow() + return + } + + isStarted = true + rapooMonitor.onSnapshotChange = { [weak self] snapshot in + self?.rapooSnapshot = snapshot + self?.rebuildSnapshot() + } + + if includeRapooDevices { + rapooMonitor.start() + rapooSnapshot = rapooMonitor.snapshot + } + + samplingTask = Task { @MainActor [weak self] in + await self?.runSamplingLoop() + } + } + + func stop() { + samplingTask?.cancel() + refreshTask?.cancel() + samplingTask = nil + refreshTask = nil + isComponentPanelVisible = false + rapooMonitor.stop() + rapooMonitor.onSnapshotChange = nil + isStarted = false + } + + func refresh( + includeInternalBattery: Bool, + includeBluetoothDevices: Bool, + includeAppleMobileDevices: Bool, + includeRapooDevices: Bool + ) { + updateOptions( + includeInternalBattery: includeInternalBattery, + includeBluetoothDevices: includeBluetoothDevices, + includeAppleMobileDevices: includeAppleMobileDevices, + includeRapooDevices: includeRapooDevices + ) + + if includeRapooDevices { + rapooMonitor.refresh() + rapooSnapshot = rapooMonitor.snapshot + } else { + rapooMonitor.stop() + rapooSnapshot = .idle + } + + collectNow() + } + + func setComponentPanelVisible(_ isVisible: Bool) { + guard isComponentPanelVisible != isVisible else { + return + } + + isComponentPanelVisible = isVisible + if isVisible, isStarted { + collectNow() + } + } + + var appleMobileRefreshInterval: TimeInterval { + isComponentPanelVisible + ? MobileRefreshInterval.componentVisible + : MobileRefreshInterval.background + } + + private func updateOptions( + includeInternalBattery: Bool, + includeBluetoothDevices: Bool, + includeAppleMobileDevices: Bool, + includeRapooDevices: Bool + ) { + self.includeInternalBattery = includeInternalBattery + self.includeBluetoothDevices = includeBluetoothDevices + self.includeAppleMobileDevices = includeAppleMobileDevices + self.includeRapooDevices = includeRapooDevices + } + + private func collectNow() { + if isCollecting { + pendingCollectRequest = true + return + } + + refreshTask?.cancel() + refreshTask = Task { @MainActor [weak self] in + await self?.collectOnce() + } + } + + private func runSamplingLoop() async { + while !Task.isCancelled { + await collectOnce() + + do { + try await Task.sleep(for: .seconds(30)) + } catch { + return + } + } + } + + private func collectOnce() async { + if isCollecting { + pendingCollectRequest = true + return + } + + isCollecting = true + defer { + isCollecting = false + } + + repeat { + pendingCollectRequest = false + rebuildSnapshot(accessOverride: .scanning) + + let referenceDate = Date() + let collectedItems = await sampler.collectSystemDevices( + referenceDate: referenceDate, + options: DeviceBatterySamplingOptions( + includeInternalBattery: includeInternalBattery, + includeBluetoothDevices: includeBluetoothDevices, + includeAppleMobileDevices: includeAppleMobileDevices, + appleMobileRefreshInterval: appleMobileRefreshInterval + ) + ) + guard !Task.isCancelled else { + pendingCollectRequest = false + return + } + + systemItems = collectedItems + lastSystemUpdate = referenceDate + rebuildSnapshot() + } while pendingCollectRequest && !Task.isCancelled + } + + private func rebuildSnapshot( + accessOverride: DeviceBatteryAccessState? = nil + ) { + var items = systemItems.filter { item in + switch item.kind { + case .internalBattery: + return includeInternalBattery + case .phone, .tablet, .mediaPlayer, .watch, .spatialComputer: + return includeAppleMobileDevices + case .bluetooth, .magicAccessory, .airPodsPart, .other: + return includeBluetoothDevices + case .rapooMouse: + return includeRapooDevices + } + } + + if includeRapooDevices, let rapooItem = rapooSnapshot.batteryItem(localization: localization) { + items.append(rapooItem) + } + + let accessState = accessOverride ?? resolvedAccessState(items: items) + snapshot = DeviceBatterySnapshot( + accessState: accessState, + items: deduplicated(items), + lastUpdated: lastSystemUpdate ?? rapooSnapshot.lastUpdated, + rapooState: includeRapooDevices ? rapooSnapshot.accessState : .idle + ) + } + + private func resolvedAccessState(items: [DeviceBatteryItem]) -> DeviceBatteryAccessState { + if rapooSnapshot.accessState == .permissionDenied { + return items.isEmpty ? .permissionDenied : .ready + } + + if case let .failed(message) = rapooSnapshot.accessState, items.isEmpty { + return .failed(message) + } + + return items.isEmpty ? .noDevices : .ready + } + + private func deduplicated(_ items: [DeviceBatteryItem]) -> [DeviceBatteryItem] { + var bestByKey: [String: DeviceBatteryItem] = [:] + var orderedKeys: [String] = [] + + for item in DeviceBatteryItemNormalizer.removingRedundantComponentAggregates(items) { + let key = "\(item.kind)-\(item.name.lowercased())-\(item.parentName ?? "")" + if let existing = bestByKey[key] { + bestByKey[key] = preferredItem(existing, item) + } else { + bestByKey[key] = item + orderedKeys.append(key) + } + } + + return orderedKeys.compactMap { bestByKey[$0] } + } + + private func preferredItem( + _ left: DeviceBatteryItem, + _ right: DeviceBatteryItem + ) -> DeviceBatteryItem { + if left.chargeState.isActiveChargingState != right.chargeState.isActiveChargingState { + return left.chargeState.isActiveChargingState ? left : right + } + + return left.lastUpdated ?? .distantPast >= right.lastUpdated ?? .distantPast ? left : right + } +} diff --git a/Plugins/DeviceBattery/Sources/RapooBatteryMonitor.swift b/Plugins/DeviceBattery/Sources/RapooBatteryMonitor.swift new file mode 100644 index 0000000..5d3a4ab --- /dev/null +++ b/Plugins/DeviceBattery/Sources/RapooBatteryMonitor.swift @@ -0,0 +1,599 @@ +import Foundation +@preconcurrency import IOKit.hid +import MacToolsPluginKit + +enum RapooBatteryAccessState: Equatable, Sendable { + case idle + case scanning + case waitingForReport + case connected + case noDevice + case permissionDenied + case failed(String) + + var isError: Bool { + switch self { + case .permissionDenied, .failed: + return true + case .idle, .scanning, .waitingForReport, .connected, .noDevice: + return false + } + } +} + +enum RapooBatteryChargeState: Equatable, Sendable { + case unknown + case invalid + case normal + case charging +} + +struct RapooBatteryReading: Equatable, Sendable { + let level: Int? + let chargeState: RapooBatteryChargeState + let statusCode: UInt8 +} + +struct RapooMouseDeviceInfo: Equatable, Sendable { + let productID: Int + let modelName: String + let displayName: String + let serialNumber: String? + let locationID: Int? + + var stableKey: String { + let serial = serialNumber?.isEmpty == false ? serialNumber! : "no-serial" + let location = locationID.map(String.init) ?? "no-location" + return "\(productID)-\(serial)-\(location)" + } +} + +struct RapooMouseBatterySnapshot: Equatable, Sendable { + var accessState: RapooBatteryAccessState + var device: RapooMouseDeviceInfo? + var reading: RapooBatteryReading? + var lastUpdated: Date? + + static let idle = RapooMouseBatterySnapshot( + accessState: .idle, + device: nil, + reading: nil, + lastUpdated: nil + ) + + func batteryItem(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> DeviceBatteryItem? { + guard let device else { + return nil + } + + return DeviceBatteryItem( + id: "rapoo-\(device.stableKey)", + name: device.modelName, + model: device.displayName == device.modelName ? nil : device.displayName, + kind: .rapooMouse, + level: reading?.level, + chargeState: deviceChargeState, + parentName: nil, + source: "Rapoo HID", + lastUpdated: lastUpdated, + isConnected: true, + detail: itemDetail(localization: localization), + componentIdentity: nil + ) + } + + var deviceChargeState: DeviceBatteryChargeState { + switch reading?.chargeState ?? .unknown { + case .unknown: + return .unknown + case .invalid: + return .invalid + case .normal: + return .normal + case .charging: + return .charging + } + } + + func itemDetail(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch accessState { + case .idle: + return localization.string("rapoo.detail.idle", defaultValue: "等待检测") + case .scanning: + return localization.string("rapoo.detail.scanning", defaultValue: "检测中") + case .waitingForReport: + return localization.string("rapoo.detail.waitingForReport", defaultValue: "等待电量上报") + case .connected: + return deviceChargeState.title(localization: localization) + case .noDevice: + return localization.string("rapoo.detail.noDevice", defaultValue: "未检测到") + case .permissionDenied: + return localization.string("rapoo.detail.permissionDenied", defaultValue: "需要输入监控权限") + case let .failed(message): + return message + } + } +} + +@MainActor +protocol RapooBatteryMonitoring: AnyObject { + var snapshot: RapooMouseBatterySnapshot { get } + var onSnapshotChange: ((RapooMouseBatterySnapshot) -> Void)? { get set } + + func start() + func stop() + func refresh() +} + +@MainActor +final class RapooHIDBatteryMonitor: RapooBatteryMonitoring { + private static let permissionDeniedReturnCode: UInt32 = 0xE00002E2 + + private var manager: IOHIDManager? + private var sessions: [String: RapooHIDDeviceSession] = [:] + private let localization: PluginLocalization + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + } + + private(set) var snapshot = RapooMouseBatterySnapshot.idle { + didSet { + guard oldValue != snapshot else { + return + } + onSnapshotChange?(snapshot) + } + } + + var onSnapshotChange: ((RapooMouseBatterySnapshot) -> Void)? + + func start() { + guard manager == nil else { + refresh() + return + } + + snapshot = RapooMouseBatterySnapshot( + accessState: .scanning, + device: nil, + reading: nil, + lastUpdated: nil + ) + + let hidManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + IOHIDManagerSetDeviceMatching(hidManager, matchingDictionary as CFDictionary) + + let context = Unmanaged.passUnretained(self).toOpaque() + IOHIDManagerRegisterDeviceMatchingCallback(hidManager, rapooHIDDeviceMatchedCallback, context) + IOHIDManagerRegisterDeviceRemovalCallback(hidManager, rapooHIDDeviceRemovedCallback, context) + IOHIDManagerScheduleWithRunLoop(hidManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + + let openResult = IOHIDManagerOpen(hidManager, IOOptionBits(kIOHIDOptionsTypeNone)) + guard openResult == kIOReturnSuccess else { + IOHIDManagerUnscheduleFromRunLoop(hidManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + snapshot = failureSnapshot(for: openResult) + return + } + + manager = hidManager + refresh() + } + + func stop() { + guard let manager else { + sessions.removeAll() + return + } + + IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + self.manager = nil + sessions.removeAll() + } + + func refresh() { + guard let manager else { + start() + return + } + + syncDevices(from: manager) + if sessions.isEmpty { + snapshot = RapooMouseBatterySnapshot( + accessState: .noDevice, + device: nil, + reading: nil, + lastUpdated: nil + ) + return + } + + let currentDeviceKey = snapshot.device?.stableKey + if currentDeviceKey == nil + || currentDeviceKey.map({ sessions[$0] == nil }) == true + || snapshot.accessState == .scanning + || snapshot.accessState == .noDevice { + let firstDevice = sessions.values.sorted { $0.deviceInfo.stableKey < $1.deviceInfo.stableKey }.first?.deviceInfo + snapshot = RapooMouseBatterySnapshot( + accessState: .waitingForReport, + device: firstDevice, + reading: nil, + lastUpdated: nil + ) + } + } + + fileprivate func handleDeviceMatched(_ device: IOHIDDevice) { + guard let deviceInfo = makeDeviceInfo(from: device), + sessions[deviceInfo.stableKey] == nil + else { + return + } + + let session = RapooHIDDeviceSession(device: device, deviceInfo: deviceInfo, monitor: self) + sessions[deviceInfo.stableKey] = session + + IOHIDDeviceScheduleWithRunLoop(device, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) + IOHIDDeviceRegisterInputReportCallback( + device, + session.reportBuffer, + RapooDeviceCatalog.reportLength, + rapooHIDInputReportCallback, + Unmanaged.passUnretained(session).toOpaque() + ) + + snapshot = RapooMouseBatterySnapshot( + accessState: .waitingForReport, + device: deviceInfo, + reading: nil, + lastUpdated: nil + ) + } + + fileprivate func handleDeviceRemoved(_ device: IOHIDDevice) { + guard let deviceInfo = makeDeviceInfo(from: device) else { + return + } + + sessions.removeValue(forKey: deviceInfo.stableKey) + refresh() + } + + fileprivate func handleInputReport( + session: RapooHIDDeviceSession, + result: IOReturn, + reportID: Int, + bytes: [UInt8] + ) { + guard result == kIOReturnSuccess, + let reading = RapooBatteryParser.parseInputReport(reportID: reportID, bytes: bytes) + else { + return + } + + snapshot = RapooMouseBatterySnapshot( + accessState: .connected, + device: session.deviceInfo, + reading: reading, + lastUpdated: Date() + ) + } + + private var matchingDictionary: [String: Int] { + [ + kIOHIDVendorIDKey as String: RapooDeviceCatalog.vendorID, + kIOHIDPrimaryUsagePageKey as String: RapooDeviceCatalog.vendorUsagePage, + kIOHIDPrimaryUsageKey as String: RapooDeviceCatalog.vendorUsage + ] + } + + private func syncDevices(from manager: IOHIDManager) { + guard let deviceSet = IOHIDManagerCopyDevices(manager) as? Set else { + return + } + + for device in deviceSet { + handleDeviceMatched(device) + } + } + + private func makeDeviceInfo(from device: IOHIDDevice) -> RapooMouseDeviceInfo? { + guard let productID = intProperty(kIOHIDProductIDKey, from: device), + RapooDeviceCatalog.isSupportedMouseProductID(productID) + else { + return nil + } + + let modelName = RapooDeviceCatalog.modelName(forProductID: productID) + ?? localization.string("rapoo.deviceName.fallback", defaultValue: "Rapoo 鼠标") + let productName = stringProperty(kIOHIDProductKey, from: device) + let displayName = productName?.isEmpty == false ? productName! : modelName + + return RapooMouseDeviceInfo( + productID: productID, + modelName: modelName, + displayName: displayName, + serialNumber: stringProperty(kIOHIDSerialNumberKey, from: device), + locationID: intProperty(kIOHIDLocationIDKey, from: device) + ) + } + + private func intProperty(_ key: String, from device: IOHIDDevice) -> Int? { + if let number = IOHIDDeviceGetProperty(device, key as CFString) as? NSNumber { + return number.intValue + } + + return nil + } + + private func stringProperty(_ key: String, from device: IOHIDDevice) -> String? { + IOHIDDeviceGetProperty(device, key as CFString) as? String + } + + private func failureSnapshot(for result: IOReturn) -> RapooMouseBatterySnapshot { + let accessState: RapooBatteryAccessState + if UInt32(bitPattern: result) == Self.permissionDeniedReturnCode { + accessState = .permissionDenied + } else { + let code = String(UInt32(bitPattern: result), radix: 16, uppercase: false) + accessState = .failed( + localization.format("rapoo.error.openFailed", defaultValue: "HID 打开失败:0x%@", code) + ) + } + + return RapooMouseBatterySnapshot( + accessState: accessState, + device: nil, + reading: nil, + lastUpdated: nil + ) + } +} + +private final class RapooHIDDeviceSession { + let device: IOHIDDevice + let deviceInfo: RapooMouseDeviceInfo + let reportBuffer: UnsafeMutablePointer + + weak var monitor: RapooHIDBatteryMonitor? + + init(device: IOHIDDevice, deviceInfo: RapooMouseDeviceInfo, monitor: RapooHIDBatteryMonitor) { + self.device = device + self.deviceInfo = deviceInfo + self.monitor = monitor + reportBuffer = UnsafeMutablePointer.allocate(capacity: RapooDeviceCatalog.reportLength) + reportBuffer.initialize(repeating: 0, count: RapooDeviceCatalog.reportLength) + } + + deinit { + reportBuffer.deinitialize(count: RapooDeviceCatalog.reportLength) + reportBuffer.deallocate() + } +} + +extension RapooHIDDeviceSession: @unchecked Sendable {} + +enum RapooBatteryParser { + private static let supportedReportIDs: Set = [0, RapooDeviceCatalog.inputReportID] + private static let candidateOffsets: [(status: Int, level: Int)] = [ + (status: 6, level: 7), + (status: 7, level: 8) + ] + + static func parseInputReport(reportID: Int, bytes: [UInt8]) -> RapooBatteryReading? { + guard supportedReportIDs.contains(reportID) else { + return nil + } + + var invalidReading: RapooBatteryReading? + for candidate in candidateOffsets { + guard bytes.indices.contains(candidate.status), + bytes.indices.contains(candidate.level) + else { + continue + } + + let status = bytes[candidate.status] + let level = bytes[candidate.level] + + guard let chargeState = chargeState(forStatus: status), + level <= 100 + else { + continue + } + + if chargeState == .invalid { + invalidReading = RapooBatteryReading(level: nil, chargeState: .invalid, statusCode: status) + continue + } + + return RapooBatteryReading(level: Int(level), chargeState: chargeState, statusCode: status) + } + + return invalidReading + } + + private static func chargeState(forStatus status: UInt8) -> RapooBatteryChargeState? { + switch status { + case 0: + return .invalid + case 1: + return .normal + case 2: + return .charging + default: + return nil + } + } +} + +enum RapooDeviceCatalog { + static let vendorID = 0x24AE + static let vendorUsagePage = 0xFF00 + static let vendorUsage = 0x0001 + static let inputReportID = 7 + static let featureReportID = 8 + static let reportLength = 512 + + private static let receiverProductIDToModel: [Int: String] = [ + 5136: "VT0", + 5137: "VT3S", + 5138: "VT3", + 5139: "VT7", + 5140: "VT9", + 5141: "VT1", + 5142: "VT7MAX", + 5143: "VT3MAX", + 5144: "VT9MAX", + 5145: "VT7Air", + 5146: "VT3Air", + 5147: "VT9Air", + 5148: "VT7Air MAX", + 5149: "VT3Air MAX", + 5150: "VT9Air MAX", + 5152: "VT0 MAX", + 5153: "VT3S MAX", + 5154: "VT2", + 5155: "VT2 MAX", + 5188: "VT1 MAX", + 5191: "ESM612MAX", + 5194: "ESM612PRO", + 5195: "VT1 Air MAX", + 5201: "VT7S", + 5202: "VT2S", + 5203: "VT4", + 5209: "VT7S V2", + 5211: "VT4 V2", + 5213: "VT2S V2", + 5216: "VT7 V2", + 5218: "VT3 V2", + 5220: "VT3S V2", + 5222: "VT9 V2", + 5224: "VT2 V2", + 5229: "VT3 MAX MASTER V2", + 5230: "VT3S MAX MASTER V2", + 5232: "VT7 MAX MASTER V2", + 5233: "VT7S MAX MASTER V2", + 5281: "VT0Air MAX", + 5282: "VT3SAir MAX", + 5284: "VT2K MAX" + ] + + private static let receiverToWebProductID: [Int: Int] = [ + 5136: 17936, + 5137: 17937, + 5138: 17938, + 5139: 17939, + 5140: 17940, + 5141: 17941, + 5142: 17942, + 5143: 17943, + 5144: 17944, + 5145: 17945, + 5146: 17946, + 5147: 17947, + 5148: 17948, + 5149: 17949, + 5150: 17950, + 5152: 17952, + 5153: 17953, + 5154: 17954, + 5155: 17955, + 5188: 17988, + 5191: 17991, + 5194: 17994, + 5195: 17995, + 5201: 18001, + 5202: 18002, + 5203: 18003, + 5209: 18009, + 5211: 18011, + 5213: 18013, + 5216: 18016, + 5218: 18018, + 5220: 18020, + 5222: 18022, + 5224: 18024, + 5229: 18029, + 5230: 18030, + 5232: 18032, + 5233: 18033, + 5281: 18081, + 5282: 18082, + 5284: 18084 + ] + + static func modelName(forProductID productID: Int) -> String? { + if let model = receiverProductIDToModel[productID] { + return model + } + + if let receiverProductID = receiverToWebProductID.first(where: { $0.value == productID })?.key { + return receiverProductIDToModel[receiverProductID] + } + + return nil + } + + static func isSupportedMouseProductID(_ productID: Int) -> Bool { + modelName(forProductID: productID) != nil + } +} + +private func rapooHIDDeviceMatchedCallback( + context: UnsafeMutableRawPointer?, + result: IOReturn, + sender: UnsafeMutableRawPointer?, + device: IOHIDDevice +) { + guard result == kIOReturnSuccess, let context else { + return + } + + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + MainActor.assumeIsolated { + monitor.handleDeviceMatched(device) + } +} + +private func rapooHIDDeviceRemovedCallback( + context: UnsafeMutableRawPointer?, + result: IOReturn, + sender: UnsafeMutableRawPointer?, + device: IOHIDDevice +) { + guard result == kIOReturnSuccess, let context else { + return + } + + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + MainActor.assumeIsolated { + monitor.handleDeviceRemoved(device) + } +} + +private func rapooHIDInputReportCallback( + context: UnsafeMutableRawPointer?, + result: IOReturn, + sender: UnsafeMutableRawPointer?, + type: IOHIDReportType, + reportID: UInt32, + report: UnsafeMutablePointer, + reportLength: CFIndex +) { + guard let context, reportLength > 0 else { + return + } + + let session = Unmanaged.fromOpaque(context).takeUnretainedValue() + let bytes = Array(UnsafeBufferPointer(start: report, count: reportLength)) + MainActor.assumeIsolated { + session.monitor?.handleInputReport( + session: session, + result: result, + reportID: Int(reportID), + bytes: bytes + ) + } +} diff --git a/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift b/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift new file mode 100644 index 0000000..f411f34 --- /dev/null +++ b/Plugins/DeviceBattery/Tests/DeviceBatteryPluginTests.swift @@ -0,0 +1,663 @@ +import XCTest +import MacToolsPluginKit +@testable import DeviceBatteryPlugin + +@MainActor +final class DeviceBatteryPluginTests: XCTestCase { + func testPluginDescriptorUsesExpandedFullWidthSpan() { + let plugin = DeviceBatteryPlugin( + context: makeContext(), + viewModel: DeviceBatteryViewModel( + sampler: StubDeviceBatterySampler(items: []), + rapooMonitor: StubRapooBatteryMonitor() + ), + inputMonitoringAuthorizationStatus: { .unknown } + ) + + XCTAssertEqual(plugin.metadata.id, "device-battery") + XCTAssertEqual(plugin.metadata.title, "设备电量") + XCTAssertEqual(plugin.descriptor.span, PluginComponentSpan(width: 4, height: 15)!) + } + + func testLayoutSpanAccountsForVisibleDeviceCount() { + XCTAssertEqual(DeviceBatteryComponentLayout.spanHeight(mode: .list, visibleItemCount: 1), 14) + XCTAssertEqual(DeviceBatteryComponentLayout.spanHeight(mode: .list, visibleItemCount: 9), 31) + } + + func testStorePersistsLayoutAndSources() { + let storage = DeviceBatteryMemoryStorage() + let store = DeviceBatteryStore(storage: storage) + + store.setLayoutMode(.list) + store.setShowBluetoothDevices(false) + store.setShowAppleMobileDevices(false) + store.setShowRapooDevices(false) + + let reloaded = DeviceBatteryStore(storage: storage) + XCTAssertEqual(reloaded.layoutMode, .list) + XCTAssertTrue(reloaded.showInternalBattery) + XCTAssertFalse(reloaded.showBluetoothDevices) + XCTAssertFalse(reloaded.showAppleMobileDevices) + XCTAssertFalse(reloaded.showRapooDevices) + } + + func testStorePersistsLowBatteryNotificationSettings() { + let storage = DeviceBatteryMemoryStorage() + let store = DeviceBatteryStore(storage: storage) + + XCTAssertFalse(store.lowBatteryNotificationEnabled) + XCTAssertEqual(store.lowBatteryNotificationThreshold, 20) + + store.setLowBatteryNotificationEnabled(true) + store.setLowBatteryNotificationThreshold(15) + + let reloaded = DeviceBatteryStore(storage: storage) + XCTAssertTrue(reloaded.lowBatteryNotificationEnabled) + XCTAssertEqual(reloaded.lowBatteryNotificationThreshold, 15) + } + + func testAppleMobileRefreshIntervalTracksComponentVisibility() { + let viewModel = DeviceBatteryViewModel( + sampler: StubDeviceBatterySampler(items: []), + rapooMonitor: StubRapooBatteryMonitor() + ) + let plugin = DeviceBatteryPlugin( + context: makeContext(), + viewModel: viewModel, + inputMonitoringAuthorizationStatus: { .unknown } + ) + + XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) + + plugin.panelSurfaceDidBecomeVisible(.primary) + XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) + + plugin.panelSurfaceDidBecomeVisible(.component) + XCTAssertEqual(viewModel.appleMobileRefreshInterval, 90) + + viewModel.stop() + XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) + + plugin.panelSurfaceDidBecomeVisible(.component) + XCTAssertEqual(viewModel.appleMobileRefreshInterval, 90) + + plugin.panelSurfaceDidBecomeHidden(.component) + XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) + } + + func testStoreClampsLowBatteryNotificationThreshold() { + let store = DeviceBatteryStore(storage: DeviceBatteryMemoryStorage()) + + store.setLowBatteryNotificationThreshold(0) + XCTAssertEqual(store.lowBatteryNotificationThreshold, 1) + + store.setLowBatteryNotificationThreshold(120) + XCTAssertEqual(store.lowBatteryNotificationThreshold, 99) + } + + func testBluetoothPowerLogParserReadsConnectedMouseBattery() { + let line = """ + 2026-06-02 14:05:52.648 Df bluetoothd[616:f85de1] [com.apple.bluetooth:CBPowerSource] Power source updated CBPowerSource Nm 'MX Anywhere 3S', SID 49354549, AcCa Mouse, AcID 0532E370-EA18-11C7-44F9-6D7E86E35891, PID 0xB037 (?), VID 0x046D (?), VIDSrc USB, Type 'Accessory Source', TPT Bluetooth LE, CF 0x1 < Attributes >, IF 0x2 < IOKit >, Present yes, MaxC 100%, Battery -80% + """ + + let reading = DeviceBatteryBluetoothPowerLogParser.reading(from: line) + + XCTAssertEqual(reading?.name, "MX Anywhere 3S") + XCTAssertEqual(reading?.vendorID, "0x046D") + XCTAssertEqual(reading?.productID, "0xB037") + XCTAssertEqual(reading?.deviceType, "Mouse") + XCTAssertEqual(reading?.level, 80) + XCTAssertEqual(reading?.chargeState, .normal) + } + + func testBluetoothPowerLogParserReadsAirPodsComponents() { + let line = """ + 2026-06-02 16:43:44.978 Df bluetoothd[616:ff000b] [com.apple.bluetooth:CBPowerSource] Power source updated CBPowerSource Nm 'ggbond AirPods 4', SID 70391692, AcCa Headphone, PID 0x201B (Device1,8219), VID 0x004C (Apple), Battery 68% (Unknown), Components (Y): Left +100%, CF 0x1 < Attributes >, Right +100%, CF 0x1 < Attributes >, Case +83%, CF 0x1 < Attributes > + """ + + let readings = DeviceBatteryBluetoothPowerLogParser.readings(fromLine: line) + + XCTAssertEqual(readings.first { $0.component == nil }?.level, 68) + XCTAssertEqual(readings.first { $0.component == .left }?.chargeState, .charging) + XCTAssertEqual(readings.first { $0.component == .chargingCase }?.level, 83) + } + + func testBatteryCenterLogParserReadsChargingState() { + let line = """ + 2026-06-12 21:43:32.313 Df NotificationCenter[1199:36289f6] [com.apple.BatteryCenter:PowerSourceController] Found device: + """ + + let reading = DeviceBatteryBatteryCenterLogParser.reading(fromLine: line) + + XCTAssertEqual(reading?.name, "ggbond AirPods") + XCTAssertEqual(reading?.model, "AirPods Pro 2") + XCTAssertEqual(reading?.level, 24) + XCTAssertEqual(reading?.chargeState, .charging) + XCTAssertEqual(reading?.isConnected, true) + } + + func testAppleHeadphoneAdvertisementParserReadsChargingParts() { + var data = [UInt8](repeating: 0, count: 25) + data[0] = 0x4C + data[1] = 0x00 + data[2] = 0x12 + data[12] = 0x80 | 24 + data[13] = 0x80 | 100 + data[14] = 100 + + let readings = DeviceBatteryAppleHeadphoneAdvertisementParser.readings(from: Data(data)) + + XCTAssertEqual(readings.first { $0.component == .chargingCase }?.chargeState, .charging) + XCTAssertEqual(readings.first { $0.component == .left }?.level, 100) + XCTAssertEqual(readings.first { $0.component == .right }?.chargeState, .normal) + } + + func testRapooParserReadsProtocolOneBatteryReport() { + let report = [UInt8](repeating: 0, count: 16).setting(1, at: 6).setting(83, at: 7) + + XCTAssertEqual( + RapooBatteryParser.parseInputReport(reportID: 7, bytes: report), + RapooBatteryReading(level: 83, chargeState: .normal, statusCode: 1) + ) + } + + func testMobileBatteryParserReadsChargingIPhone() throws { + let record = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( + identifier: "phone-id", + name: "测试 iPhone", + productType: "iPhone18,1", + deviceClass: "iPhone", + connectionType: "Wi-Fi", + battery: [ + "BatteryCurrentCapacity": 67, + "BatteryIsCharging": true, + "ExternalConnected": true + ] + )) + + XCTAssertEqual(record.category, .phone) + XCTAssertEqual(record.level, 67) + XCTAssertEqual(record.chargeState, .charging) + XCTAssertEqual(record.batteryItem(referenceDate: Date()).kind, .phone) + } + + func testMobileBatteryParserDistinguishesPluggedAndFullyCharged() throws { + let plugged = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( + identifier: "tablet-id", + name: "iPad", + productType: "iPad17,2", + deviceClass: "iPad", + connectionType: "USB", + battery: [ + "BatteryCurrentCapacity": 80, + "BatteryIsCharging": false, + "ExternalConnected": true + ] + )) + let charged = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( + identifier: "watch-id", + name: "Apple Watch", + productType: "Watch7,5", + deviceClass: "Watch", + connectionType: "Wi-Fi", + battery: [ + "BatteryCurrentCapacity": 100, + "FullyCharged": true, + "ExternalConnected": true + ] + )) + + XCTAssertEqual(plugged.category, .tablet) + XCTAssertEqual(plugged.chargeState, .plugged) + XCTAssertEqual(charged.category, .watch) + XCTAssertEqual(charged.chargeState, .charged) + } + + func testMobileBatteryParserNormalizesIORegistryCapacity() throws { + let record = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( + identifier: "vision-id", + name: "Vision Pro", + productType: "RealityDevice15,1", + deviceClass: "RealityDevice", + connectionType: "Wi-Fi", + battery: [ + "AppleRawCurrentCapacity": 3_200, + "AppleRawMaxCapacity": 4_000, + "IsCharging": false + ] + )) + + XCTAssertEqual(record.category, .spatialComputer) + XCTAssertEqual(record.level, 80) + XCTAssertEqual(record.chargeState, .normal) + } + + func testMobileBatteryParserRecognizesIPodTouch() throws { + let record = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( + identifier: "ipod-id", + name: "iPod touch", + productType: "iPod9,1", + deviceClass: "iPod", + connectionType: "USB", + battery: ["BatteryCurrentCapacity": 42] + )) + + XCTAssertEqual(record.category, .mediaPlayer) + XCTAssertEqual(record.batteryItem(referenceDate: Date()).kind, .mediaPlayer) + } + + func testMobileDeviceReadingWinsOverStaleChargingLog() throws { + let now = Date() + let mobile = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( + identifier: "phone-id", + name: "测试 iPhone", + productType: "iPhone18,1", + deviceClass: "iPhone", + connectionType: "Wi-Fi", + battery: [ + "BatteryCurrentCapacity": 66, + "BatteryIsCharging": false, + "ExternalConnected": false + ] + )).batteryItem(referenceDate: now) + let staleLog = DeviceBatteryItem( + id: "batterycenter-phone-id", + name: "测试 iPhone", + model: "iPhone18,1", + kind: .phone, + level: 65, + chargeState: .charging, + parentName: nil, + source: "BatteryCenter", + lastUpdated: now, + isConnected: true, + detail: nil + ) + + let item = try XCTUnwrap(DeviceBatterySampler.deduplicated([staleLog, mobile]).first) + XCTAssertEqual(item.source, "MobileDevice") + XCTAssertEqual(item.level, 66) + XCTAssertEqual(item.chargeState, .normal) + } + + func testItemNormalizerDropsAirPodsAggregateWhenComponentsExist() { + let aggregate = makeAirPodsItem(id: "main", role: .aggregate) + let caseItem = makeAirPodsItem(id: "case", role: .chargingCase) + + XCTAssertEqual( + DeviceBatteryItemNormalizer.removingRedundantComponentAggregates([aggregate, caseItem]).map(\.id), + ["case"] + ) + } + + func testLowBatteryNotificationMergesMultipleDevices() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + let snapshot = makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 12), + makeBatteryItem(id: "keyboard", name: "Keyboard", level: 18), + makeBatteryItem(id: "trackpad", name: "Trackpad", level: 38) + ]) + + controller.evaluate( + snapshot: snapshot, + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 1) + XCTAssertEqual(notifier.notifications[0].deviceIDs, ["mouse", "keyboard"]) + XCTAssertEqual(notifier.notifications[0].title, "2 台设备电量偏低") + XCTAssertTrue(notifier.notifications[0].body.contains("Mouse 12%")) + XCTAssertTrue(notifier.notifications[0].body.contains("Keyboard 18%")) + XCTAssertFalse(notifier.notifications[0].body.contains("Trackpad")) + } + + func testLowBatteryNotificationDoesNotRepeatUntilDeviceRecovers() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + let lowSnapshot = makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 12) + ]) + + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + XCTAssertEqual(notifier.notifications.count, 1) + + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 35) + ]), + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 2) + } + + func testLowBatteryNotificationDoesNotRepeatWhenSourceIDChanges() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem( + id: "bluetooth-powerlog-airpods-left", + name: "AirPods 左耳", + level: 7, + kind: .airPodsPart, + parentName: "AirPods 充电盒", + componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods-address", role: .left) + ) + ]), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem( + id: "apple-headphone-advertisement-airpods-left", + name: "AirPods 左耳", + level: 6, + kind: .airPodsPart, + parentName: "AirPods 充电盒", + componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods-advertisement", role: .left) + ) + ]), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 1) + } + + func testLowBatteryNotificationDoesNotRepeatWhenDeviceKindChanges() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem( + id: "bluetooth-mx-anywhere", + name: "MX Anywhere 3S", + level: 9, + kind: .bluetooth + ) + ]), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem( + id: "batterycenter-mx-anywhere", + name: "MX Anywhere 3S", + level: 8, + kind: .magicAccessory + ) + ]), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 1) + } + + func testLowBatteryNotificationDoesNotRepeatWhenAggregateRoleChanges() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem( + id: "corebluetooth-airpods", + name: "AirPods", + level: 9, + kind: .airPodsPart, + componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods", role: .aggregate) + ) + ]), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem( + id: "batterycenter-airpods", + name: "AirPods", + level: 8, + kind: .airPodsPart + ) + ]), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 1) + } + + func testLowBatteryNotificationDoesNotResetWhenDeviceTemporarilyMissing() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + let lowSnapshot = makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 8) + ]) + + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: makeSnapshot(items: []), + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 10, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 1) + } + + func testLowBatteryNotificationResetsAfterDeviceIsCharged() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + let lowSnapshot = makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 12) + ]) + + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 100, chargeState: .charged) + ]), + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + controller.evaluate( + snapshot: lowSnapshot, + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertEqual(notifier.notifications.count, 2) + } + + func testLowBatteryNotificationIgnoresChargingDevicesAndBoundaryValue() { + let notifier = RecordingLowBatteryNotifier() + let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) + + controller.evaluate( + snapshot: makeSnapshot(items: [ + makeBatteryItem(id: "mouse", name: "Mouse", level: 20), + makeBatteryItem(id: "keyboard", name: "Keyboard", level: 12, chargeState: .charging), + makeBatteryItem(id: "trackpad", name: "Trackpad", level: 12, isConnected: false) + ]), + isEnabled: true, + threshold: 20, + localization: PluginLocalization(bundle: .main) + ) + + XCTAssertTrue(notifier.notifications.isEmpty) + } + + private func makeContext() -> PluginRuntimeContext { + PluginRuntimeContext(pluginID: "device-battery", storage: DeviceBatteryMemoryStorage()) + } + + private func makeSnapshot(items: [DeviceBatteryItem]) -> DeviceBatterySnapshot { + DeviceBatterySnapshot( + accessState: .ready, + items: items, + lastUpdated: Date(), + rapooState: .idle + ) + } + + private func makeAirPodsItem(id: String, role: DeviceBatteryComponentRole) -> DeviceBatteryItem { + DeviceBatteryItem( + id: id, + name: id, + model: "AirPods 4", + kind: .airPodsPart, + level: 80, + chargeState: .normal, + parentName: nil, + source: "test", + lastUpdated: Date(), + isConnected: true, + detail: "Headphones", + componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods", role: role) + ) + } + + private func makeBatteryItem( + id: String, + name: String, + level: Int, + kind: DeviceBatteryKind = .bluetooth, + chargeState: DeviceBatteryChargeState = .normal, + isConnected: Bool = true, + parentName: String? = nil, + componentIdentity: DeviceBatteryComponentIdentity? = nil + ) -> DeviceBatteryItem { + DeviceBatteryItem( + id: id, + name: name, + model: nil, + kind: kind, + level: level, + chargeState: chargeState, + parentName: parentName, + source: "test", + lastUpdated: Date(), + isConnected: isConnected, + detail: nil, + componentIdentity: componentIdentity + ) + } +} + +@MainActor +private final class DeviceBatteryMemoryStorage: 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[legacyKey] else { return } + values[key] = value + values.removeValue(forKey: legacyKey) + } +} + +private struct StubDeviceBatterySampler: DeviceBatterySampling { + let items: [DeviceBatteryItem] + + func collectSystemDevices( + referenceDate: Date, + options: DeviceBatterySamplingOptions + ) async -> [DeviceBatteryItem] { + items + } +} + +@MainActor +private final class RecordingLowBatteryNotifier: DeviceBatteryLowBatteryNotifying { + private(set) var notifications: [DeviceBatteryLowBatteryNotification] = [] + + func notifyLowBatteryDevices( + _ items: [DeviceBatteryItem], + threshold: Int, + localization: PluginLocalization + ) { + notifications.append( + DeviceBatteryLowBatteryNotificationContent.make( + items: items, + threshold: threshold, + localization: localization + ) + ) + } +} + +@MainActor +private final class StubRapooBatteryMonitor: RapooBatteryMonitoring { + var snapshot = RapooMouseBatterySnapshot.idle + var onSnapshotChange: ((RapooMouseBatterySnapshot) -> Void)? + + func start() {} + func stop() {} + func refresh() {} +} + +private extension Array where Element == UInt8 { + func setting(_ value: UInt8, at index: Int) -> [UInt8] { + var copy = self + copy[index] = value + return copy + } +} diff --git a/Plugins/DeviceBattery/plugin.json b/Plugins/DeviceBattery/plugin.json new file mode 100644 index 0000000..86668c7 --- /dev/null +++ b/Plugins/DeviceBattery/plugin.json @@ -0,0 +1,66 @@ +{ + "id": "device-battery", + "displayName": "设备电量", + "summary": "查看 Mac、Apple 移动设备、蓝牙外设和雷柏鼠标电量", + "localizedMetadata": { + "ar": { + "displayName": "بطارية الأجهزة", + "summary": "عرض مستويات البطارية لجهاز Mac وأجهزة Apple المحمولة وملحقات Bluetooth وأجهزة الماوس Rapoo." + }, + "de": { + "displayName": "Gerätebatterie", + "summary": "Zeigen Sie den Akkustand Ihres Mac, mobiler Apple-Geräte, von Bluetooth-Zubehör und Rapoo-Mäusen an." + }, + "en": { + "displayName": "Device Battery", + "summary": "View battery levels for your Mac, Apple mobile devices, Bluetooth accessories, and Rapoo mice." + }, + "es": { + "displayName": "Batería de dispositivos", + "summary": "Consulta la batería del Mac, dispositivos móviles Apple, accesorios Bluetooth y ratones Rapoo." + }, + "fr": { + "displayName": "Batterie des appareils", + "summary": "Affichez la batterie du Mac, des appareils mobiles Apple, des accessoires Bluetooth et des souris Rapoo." + }, + "ja": { + "displayName": "デバイスのバッテリー", + "summary": "Mac、Apple モバイルデバイス、Bluetooth アクセサリ、Rapoo マウスのバッテリー残量を表示します。" + }, + "ko": { + "displayName": "기기 배터리", + "summary": "Mac, Apple 모바일 기기, Bluetooth 액세서리 및 Rapoo 마우스의 배터리를 확인합니다." + }, + "pt": { + "displayName": "Bateria dos dispositivos", + "summary": "Veja a bateria do Mac, dispositivos móveis Apple, acessórios Bluetooth e mouses Rapoo." + }, + "ru": { + "displayName": "Батарея устройств", + "summary": "Просматривайте заряд Mac, мобильных устройств Apple, Bluetooth-аксессуаров и мышей Rapoo." + }, + "zh-Hans": { + "displayName": "设备电量", + "summary": "查看 Mac、Apple 移动设备、蓝牙外设和雷柏鼠标电量" + }, + "zh-Hant": { + "displayName": "裝置電量", + "summary": "查看 Mac、Apple 行動裝置、藍牙外設和雷柏滑鼠電量" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "DeviceBattery.bundle", + "factoryClass": "DeviceBatteryPlugin.DeviceBatteryPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "DeviceBatteryPlugin" + }, + "capabilities": { + "primaryPanel": false, + "componentPanel": true, + "configuration": true + }, + "permissions": [] +} diff --git a/Plugins/DeviceBattery/project.yml b/Plugins/DeviceBattery/project.yml new file mode 100644 index 0000000..8c0bdb1 --- /dev/null +++ b/Plugins/DeviceBattery/project.yml @@ -0,0 +1,3 @@ +settings: + base: + OTHER_LDFLAGS: -framework IOKit -framework IOBluetooth -framework CoreBluetooth diff --git a/Plugins/DiskClean/Bundle/DiskCleanPluginBundleEntrypoint.swift b/Plugins/DiskClean/Bundle/DiskCleanPluginBundleEntrypoint.swift new file mode 100644 index 0000000..9eac1fe --- /dev/null +++ b/Plugins/DiskClean/Bundle/DiskCleanPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import DiskCleanPlugin + +private let diskCleanPluginFactoryAnchor: Any.Type = DiskCleanPluginFactory.self diff --git a/Plugins/DiskClean/Resources/Localizable.xcstrings b/Plugins/DiskClean/Resources/Localizable.xcstrings new file mode 100644 index 0000000..6528cfd --- /dev/null +++ b/Plugins/DiskClean/Resources/Localizable.xcstrings @@ -0,0 +1,3698 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "choice.browser.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف ذاكرة التخزين المؤقت للمتصفح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Cache-Bereinigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser cache cleanup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpieza de caché del navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage du cache du navigateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザキャッシュのクリーンアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 캐시 정리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza do cache do navegador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка кэша браузера" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器缓存清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器緩存清理" + } + } + } + }, + "choice.cache.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف ذاكرة التخزين المؤقت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cache-Bereinigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cache cleanup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpieza de caché" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage du cache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャッシュのクリーンアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐시 정리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza de cache" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка кэша" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缓存清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緩存清理" + } + } + } + }, + "choice.developer.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف ذاكرة التخزين المؤقت للمطورين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entwickler-Cache-Bereinigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Developer cache cleanup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpieza de caché del desarrollador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage du cache du développeur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開発者キャッシュのクリーンアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개발자 캐시 정리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza de cache do desenvolvedor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка кэша разработчика" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开发者缓存清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開發者緩存清理" + } + } + } + }, + "controller.subtitle.cleaning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reinigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleaning" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "掃除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "청소" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在清理" + } + } + } + }, + "controller.subtitle.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتملت عملية التنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigung abgeschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup complete" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpieza completa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage terminé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップが完了しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 완료" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza concluída" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка завершена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理完成" + } + } + } + }, + "controller.subtitle.idle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر نطاق التنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungsbereich auswählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose cleanup scope" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija el alcance de la limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir l'étendue du nettoyage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ範囲の選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 범위 선택" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha o escopo da limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите область очистки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择清理范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇清理範圍" + } + } + } + }, + "controller.subtitle.scanned": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتمل الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan abgeschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan complete" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo completado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse terminée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンが完了しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 완료" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação concluída" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование завершено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描完成" + } + } + } + }, + "controller.subtitle.scannedCount": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d عناصر قابلة للتنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente reinigbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d items cleanable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d elementos que se pueden limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d éléments nettoyables" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d アイテムがクリーニング可能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 청소 가능한 항목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d itens que podem ser limpos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d элементы можно очистить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 项可清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 項可清理" + } + } + } + }, + "controller.subtitle.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scannen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Numérisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Digitalizando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描" + } + } + } + }, + "controller.subtitle.stale": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تغيير نطاق التنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungsbereich geändert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup scope changed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcance de limpieza cambiado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Étendue du nettoyage modifiée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ範囲が変更されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 범위가 변경됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escopo da limpeza alterado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Область очистки изменена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理范围已变化" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理範圍已變化" + } + } + } + }, + "detail.action.clean": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reinigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "깨끗함" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理" + } + } + } + }, + "detail.action.scan": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scannen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканировать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描" + } + } + } + }, + "detail.action.stop": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توقف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Stopp" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detener" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Parar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Стоп" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + } + }, + "detail.candidates.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على عناصر قابلة للتنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine reinigbaren Elemente gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No cleanable items found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontraron elementos que se puedan limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun élément nettoyable trouvé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーニング可能なアイテムが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "청소 가능한 항목이 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum item limpável encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не найдено предметов, подлежащих чистке" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有发现可清理项目" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有發現可清理項目" + } + } + } + }, + "detail.scanLog.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر التقدم المباشر بعد المسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Live-Fortschritt wird nach dem Scannen angezeigt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Live progress appears after scanning" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El progreso en vivo aparece después del escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La progression en direct apparaît après la numérisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン後にライブ進行状況が表示される" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 후 실시간 진행 상황이 나타납니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O progresso ao vivo aparece após a verificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После сканирования отображается прогресс в реальном времени" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击扫描后显示实时进度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊掃描後顯示實時進度" + } + } + } + }, + "detail.scanLog.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجل المسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan-Protokoll" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan Log" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registro de escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Journal d'analyse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンログ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 로그" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Registro de verificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Журнал сканирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描日誌" + } + } + } + }, + "detail.summary.cleanable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قابل للتنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reinigbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiable" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "掃除可能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "청소 가능" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Livável" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очищаемый" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可清理" + } + } + } + }, + "detail.summary.estimatedRelease": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مقدر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschätzt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Estimated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estimado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Estimé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "推定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예상" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Estimado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приблизительно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预计释放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預計釋放" + } + } + } + }, + "detail.summary.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + } + } + }, + "detail.summary.protected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محمي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschützt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Protected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protegido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protégé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保護されています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보호됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Protegido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защищено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保护" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已保護" + } + } + } + }, + "detail.summary.released": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الإصدار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Freigegeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Released" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Liberado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Publié" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リリース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lançado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выпущен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已释放" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已釋放" + } + } + } + }, + "detail.summary.removed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محذوفة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gelöscht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deleted" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluído" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удален" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已删除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已刪除" + } + } + } + }, + "detail.summary.skipped": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التخطي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übersprungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Omitido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignoré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건너뛰었습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ignorado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропущено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已跳过" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已跳過" + } + } + } + }, + "detail.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف القرص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenträgerbereinigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disk Cleanup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Liberador de espacio en disco" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage de disque" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスク クリーンアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스크 정리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza de disco" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка диска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "磁盘清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "磁盤清理" + } + } + } + }, + "item.count": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d العناصر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d elementos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d éléments" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d アイテム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 항목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d itens" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d элементов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 項" + } + } + } + }, + "list.separator": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "، " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": ", " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "、" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "、" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف ذاكرة التخزين المؤقت، وذاكرة التخزين المؤقت للمطورين، وذاكرة التخزين المؤقت للمتصفح." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Caches, Entwickler-Caches und Browser-Caches bereinigen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean caches, developer caches, and browser caches." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar cachés, cachés de desarrolladores y cachés de navegador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer les caches, les caches des développeurs et les caches du navigateur." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャッシュ、開発者キャッシュ、ブラウザ キャッシュをクリーンアップします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "캐시, 개발자 캐시 및 브라우저 캐시를 정리합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpe caches, caches de desenvolvedores e caches de navegador." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка кеша, кеша разработчика и кеша браузера." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "缓存、开发者缓存和浏览器缓存清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緩存、開發者緩存和瀏覽器緩存清理" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف القرص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenträgerbereinigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disk Cleanup" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Liberador de espacio en disco" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage de disque" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスク クリーンアップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스크 정리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpeza de disco" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистка диска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "磁盘清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "磁盤清理" + } + } + } + }, + "panel.action.clean": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reinigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "깨끗함" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理" + } + } + } + }, + "panel.action.openDetails": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح التفاصيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les détails" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부정보 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir detalhes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть подробности" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開詳情" + } + } + } + }, + "panel.action.scan": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scannen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканировать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描" + } + } + } + }, + "panel.subtitle.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ bereinigt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleaned %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiado %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyé %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ をクリーニングしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 정리됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpo %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очищено %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已清理 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已清理 %@" + } + } + } + }, + "panel.subtitle.scanned": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d العناصر، %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente, %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d items, %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d elementos, %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d éléments, %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d アイテム、%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 항목, %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d itens, %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d предметов, %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 项,%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 項,%@" + } + } + } + }, + "safety.allowed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسموح بالتنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reinigung erlaubt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allowed to clean" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitido limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisé à nettoyer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーニングを許可されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "청소가 허용됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permitido limpar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешено чистить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許清理" + } + } + } + }, + "safety.inUse": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد الاستخدام: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Verwendung: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In use: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En uso: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d'utilisation : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用中: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 중: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Em uso: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在使用:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在使用:%@" + } + } + } + }, + "safety.invalid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حماية سلامة المسار: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pfadsicherheitsschutz: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Path safety protection: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protección de seguridad del camino: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protection de sécurité du chemin : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "経路安全保護: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경로 안전 보호: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proteção de segurança de caminho: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защита безопасности пути: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "路径安全保护:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "路徑安全保護:%@" + } + } + } + }, + "safety.protected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حماية البيانات الحساسة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schutz sensibler Daten: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensitive data protection: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protección de datos sensibles: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protection des données sensibles : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "機密データの保護: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "민감한 데이터 보호: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proteção de dados confidenciais: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защита конфиденциальных данных: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "敏感数据保护:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "敏感數據保護:%@" + } + } + } + }, + "safety.requiresAdmin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مطلوب إذن المسؤول: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Administratorberechtigung erforderlich: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator permission required: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere permiso de administrador: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisation d'administrateur requise : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理者権限が必要です: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관리자 권한 필요: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "É necessária permissão de administrador: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется разрешение администратора: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要管理员权限:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要管理員權限:%@" + } + } + } + }, + "safety.whitelisted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حماية القائمة البيضاء: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Whitelist-Schutz: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Whitelist protection: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protección de lista blanca: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protection de la liste blanche : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホワイトリスト保護: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화이트리스트 보호: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proteção da lista de permissões: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защита белого списка: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "白名单保护:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "白名單保護:%@" + } + } + } + }, + "scanLog.candidate.allowed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قابل للتنظيف: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reinigbar: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanable: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiable: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyable : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーニング可能: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "청소 가능: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Livável: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очищаемый: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可清理:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可清理:%@" + } + } + } + }, + "scanLog.candidate.inUse": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد الاستخدام (%@): %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Verwendung (%@): %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In use (%@): %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En uso (%@): %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d'utilisation (%@) : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用中 (%@): %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 중(%@): %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Em uso (%@): %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется (%@): %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在使用(%@):%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在使用(%@):%@" + } + } + } + }, + "scanLog.candidate.invalid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حماية سلامة المسار: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Pfadsicherheitsschutz: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Path safety protection: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protección de seguridad del camino: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protection de sécurité du chemin : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "経路安全保護: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경로 안전 보호: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proteção de segurança do caminho: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защита безопасности пути: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "路径安全保护:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "路徑安全保護:%@" + } + } + } + }, + "scanLog.candidate.protected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حماية البيانات الحساسة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schutz sensibler Daten: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sensitive data protection: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protección de datos sensibles: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protection des données sensibles : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "機密データの保護: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "민감한 데이터 보호: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proteção de dados confidenciais: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защита конфиденциальных данных: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "敏感数据保护:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "敏感數據保護:%@" + } + } + } + }, + "scanLog.candidate.requiresAdmin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مطلوب إذن المسؤول: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Administratorberechtigung erforderlich: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator permission required: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere permiso de administrador: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisation d'administrateur requise : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理者権限が必要です: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관리자 권한 필요: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "É necessária permissão de administrador: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуется разрешение администратора: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要管理员权限:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要管理員權限:%@" + } + } + } + }, + "scanLog.candidate.whitelisted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حماية القائمة البيضاء: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Whitelist-Schutz: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Whitelist protection: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protección de lista blanca: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protection de la liste blanche : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホワイトリスト保護: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화이트리스트 보호: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proteção da lista de permissões: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защита белого списка: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "白名单保护:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "白名單保護:%@" + } + } + } + }, + "scanLog.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتمل الفحص: %d من العناصر، %d قابلة للتنظيف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan abgeschlossen: %d Elemente, %d reinigbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan complete: %d items, %d cleanable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo completo: %d elementos, %d limpiables" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse terminée : %d éléments, %d nettoyables" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン完了: %d アイテム、%d クリーニング可能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 완료: %d 항목, %d 청소 가능" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação concluída: %d itens, %d limpáveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование завершено: %d элементов, %d можно очистить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描完成:%d 项,%d 项可清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描完成:%d 項,%d 項可清理" + } + } + } + }, + "scanLog.expandRule": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قاعدة التوسيع: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweiternde Regel: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Expanding rule: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Regla de expansión: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Règle d'expansion : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "展開ルール: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확장 규칙: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Regra de expansão: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Правило расширения: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开规则:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "展開規則:%@" + } + } + } + }, + "scanLog.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الفحص: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de escaneo: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'analyse : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na verificação: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование не удалось: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描失敗:%@" + } + } + } + }, + "scanLog.matched": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عناصر %d المطابقة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übereinstimmende %d Elemente: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Matched %d items: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Artículos %d coincidentes: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éléments %d correspondant : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致した %d アイテム: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일치하는 %d 항목: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Itens %d correspondentes: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Соответствует %d элементов: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "匹配 %d 项:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "匹配 %d 項:%@" + } + } + } + }, + "scanLog.scope": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النطاق: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geltungsbereich: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcance: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Portée : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "範囲: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "범위: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escopo: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Объем: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描范围:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描範圍:%@" + } + } + } + }, + "scanLog.started": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدء الفحص: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan gestartet: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Started scan: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo iniciado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse démarrée : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンを開始しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 시작됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação iniciada: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начато сканирование: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始扫描:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開始掃描:%@" + } + } + } + }, + "scanLog.stopped": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توقف الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan gestoppt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan stopped" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo detenido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse arrêtée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンが停止しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검사가 중지되었습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação interrompida" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование остановлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描已停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描已停止" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/DiskClean/Sources/DiskCleanController.swift b/Plugins/DiskClean/Sources/DiskCleanController.swift new file mode 100644 index 0000000..b70bc54 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanController.swift @@ -0,0 +1,474 @@ +import Combine +import Foundation +import MacToolsPluginKit + +enum DiskCleanControllerPhase: Equatable, Sendable { + case idle + case scanning + case scanned + case cleaning + case completed +} + +struct DiskCleanControllerSnapshot: Equatable, Sendable { + let phase: DiskCleanControllerPhase + let selectedChoices: Set + let scanResult: DiskCleanScanResult? + let executionResult: DiskCleanExecutionResult? + let isResultStale: Bool + let errorMessage: String? + let scanLogEntries: [DiskCleanScanLogEntry] + + init( + phase: DiskCleanControllerPhase, + selectedChoices: Set, + scanResult: DiskCleanScanResult?, + executionResult: DiskCleanExecutionResult?, + isResultStale: Bool, + errorMessage: String?, + scanLogEntries: [DiskCleanScanLogEntry] = [] + ) { + self.phase = phase + self.selectedChoices = selectedChoices + self.scanResult = scanResult + self.executionResult = executionResult + self.isResultStale = isResultStale + self.errorMessage = errorMessage + self.scanLogEntries = scanLogEntries + } + + var subtitle: String { + subtitle() + } + + func subtitle(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch phase { + case .idle: + return localization.string("controller.subtitle.idle", defaultValue: "选择清理范围") + case .scanning: + return localization.string("controller.subtitle.scanning", defaultValue: "正在扫描") + case .scanned: + if isResultStale { + return localization.string("controller.subtitle.stale", defaultValue: "清理范围已变化") + } + return scanResult.map { + localization.format( + "controller.subtitle.scannedCount", + defaultValue: "%d 项可清理", + $0.cleanableCandidates.count + ) + } ?? localization.string("controller.subtitle.scanned", defaultValue: "扫描完成") + case .cleaning: + return localization.string("controller.subtitle.cleaning", defaultValue: "正在清理") + case .completed: + return localization.string("controller.subtitle.completed", defaultValue: "清理完成") + } + } + + var isBusy: Bool { + phase == .scanning || phase == .cleaning + } + + var canScan: Bool { + !isBusy && !selectedChoices.isEmpty + } + + var canClean: Bool { + phase == .scanned + && !isResultStale + && scanResult?.cleanableCandidates.isEmpty == false + } + + static let initial = DiskCleanControllerSnapshot( + phase: .idle, + selectedChoices: Set(DiskCleanChoice.allCases), + scanResult: nil, + executionResult: nil, + isResultStale: false, + errorMessage: nil + ) +} + +@MainActor +protocol DiskCleanControlling: AnyObject { + var onStateChange: (() -> Void)? { get set } + var snapshot: DiskCleanControllerSnapshot { get } + + func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) + func scan() + func cleanSelected(candidateIDs: Set) + func cancelCurrentOperation() +} + +@MainActor +final class DiskCleanController: ObservableObject, DiskCleanControlling { + private static let scanLogFlushIntervalNanoseconds: UInt64 = 100_000_000 + + var onStateChange: (() -> Void)? + + @Published private(set) var snapshot: DiskCleanControllerSnapshot { + didSet { + onStateChange?() + } + } + + private let scanner: DiskCleanScanning + private let executor: DiskCleanExecuting + private let localization: PluginLocalization + + private var currentTask: Task? + private var currentOperationID: UUID? + private var scanLogFlushTask: Task? + private var nextLogEntryID = 1 + + init( + scanner: DiskCleanScanning = DiskCleanScanner(), + executor: DiskCleanExecuting = DiskCleanExecutor(), + initialSnapshot: DiskCleanControllerSnapshot = .initial, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.scanner = scanner + self.executor = executor + self.localization = localization + snapshot = initialSnapshot + } + + deinit { + currentTask?.cancel() + } + + func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) { + var nextChoices = snapshot.selectedChoices + if isSelected { + nextChoices.insert(choice) + } else { + nextChoices.remove(choice) + } + + snapshot = DiskCleanControllerSnapshot( + phase: snapshot.phase, + selectedChoices: nextChoices, + scanResult: snapshot.scanResult, + executionResult: snapshot.executionResult, + isResultStale: isStale(scanResult: snapshot.scanResult, selectedChoices: nextChoices), + errorMessage: snapshot.errorMessage, + scanLogEntries: snapshot.scanLogEntries + ) + } + + func scan() { + guard snapshot.canScan else { return } + + cancelTaskOnly() + + let selectedChoices = snapshot.selectedChoices + let operationID = UUID() + currentOperationID = operationID + nextLogEntryID = 1 + let scanLogBuffer = DiskCleanScanLogBuffer() + let initialLogEntries = [ + makeLogEntry( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.started", + defaultValue: "开始扫描:%@", + selectedChoiceTitleList(selectedChoices) + ), + tone: .info + ) + ) + ] + snapshot = DiskCleanControllerSnapshot( + phase: .scanning, + selectedChoices: selectedChoices, + scanResult: nil, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + scanLogEntries: initialLogEntries + ) + startScanLogFlushLoop(operationID: operationID, buffer: scanLogBuffer) + + currentTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + let result = try await scanner.scan(choices: selectedChoices) { message in + await scanLogBuffer.append(message) + } + guard isCurrentOperation(operationID) else { return } + await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer) + snapshot = DiskCleanControllerSnapshot( + phase: .scanned, + selectedChoices: selectedChoices, + scanResult: result, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + scanLogEntries: snapshot.scanLogEntries + ) + finishOperation(operationID) + } catch is CancellationError { + guard isCurrentOperation(operationID) else { return } + await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer) + snapshot = DiskCleanControllerSnapshot( + phase: .idle, + selectedChoices: selectedChoices, + scanResult: nil, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + scanLogEntries: snapshot.scanLogEntries + ) + finishOperation(operationID) + } catch { + guard isCurrentOperation(operationID) else { return } + await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer) + snapshot = DiskCleanControllerSnapshot( + phase: .idle, + selectedChoices: selectedChoices, + scanResult: nil, + executionResult: nil, + isResultStale: false, + errorMessage: Self.userFacingMessage(for: error), + scanLogEntries: scanLogEntries( + adding: [ + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.failed", + defaultValue: "扫描失败:%@", + Self.userFacingMessage(for: error) + ), + tone: .error + ) + ], + to: snapshot.scanLogEntries + ) + ) + finishOperation(operationID) + } + } + } + + func cleanSelected(candidateIDs: Set) { + guard snapshot.canClean, let scanResult = snapshot.scanResult else { return } + + cancelTaskOnly() + + let selectedChoices = snapshot.selectedChoices + let operationID = UUID() + currentOperationID = operationID + snapshot = DiskCleanControllerSnapshot( + phase: .cleaning, + selectedChoices: selectedChoices, + scanResult: scanResult, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + scanLogEntries: snapshot.scanLogEntries + ) + + currentTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + let executionResult = try await executor.clean( + candidates: scanResult.candidates, + selectedCandidateIDs: candidateIDs + ) + guard isCurrentOperation(operationID) else { return } + snapshot = DiskCleanControllerSnapshot( + phase: .completed, + selectedChoices: selectedChoices, + scanResult: scanResult, + executionResult: executionResult, + isResultStale: false, + errorMessage: nil, + scanLogEntries: snapshot.scanLogEntries + ) + finishOperation(operationID) + } catch is CancellationError { + guard isCurrentOperation(operationID) else { return } + snapshot = DiskCleanControllerSnapshot( + phase: .scanned, + selectedChoices: selectedChoices, + scanResult: scanResult, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + scanLogEntries: snapshot.scanLogEntries + ) + finishOperation(operationID) + } catch { + guard isCurrentOperation(operationID) else { return } + snapshot = DiskCleanControllerSnapshot( + phase: .scanned, + selectedChoices: selectedChoices, + scanResult: scanResult, + executionResult: nil, + isResultStale: false, + errorMessage: Self.userFacingMessage(for: error), + scanLogEntries: snapshot.scanLogEntries + ) + finishOperation(operationID) + } + } + } + + func cancelCurrentOperation() { + let phase = snapshot.phase + let selectedChoices = snapshot.selectedChoices + let scanResult = snapshot.scanResult + let isResultStale = snapshot.isResultStale + let scanLogEntries = snapshot.scanLogEntries + + cancelTaskOnly() + + switch phase { + case .scanning: + snapshot = DiskCleanControllerSnapshot( + phase: .idle, + selectedChoices: selectedChoices, + scanResult: nil, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + scanLogEntries: scanLogEntries + [ + makeLogEntry( + DiskCleanScanLogMessage( + text: localization.string("scanLog.stopped", defaultValue: "扫描已停止"), + tone: .warning + ) + ) + ] + ) + case .cleaning: + snapshot = DiskCleanControllerSnapshot( + phase: .scanned, + selectedChoices: selectedChoices, + scanResult: scanResult, + executionResult: nil, + isResultStale: isResultStale, + errorMessage: nil, + scanLogEntries: scanLogEntries + ) + case .idle, .scanned, .completed: + break + } + } + + private func cancelTaskOnly() { + currentTask?.cancel() + currentTask = nil + currentOperationID = nil + scanLogFlushTask?.cancel() + scanLogFlushTask = nil + } + + private func finishOperation(_ operationID: UUID) { + guard isCurrentOperation(operationID) else { return } + currentTask = nil + currentOperationID = nil + scanLogFlushTask?.cancel() + scanLogFlushTask = nil + } + + private func startScanLogFlushLoop(operationID: UUID, buffer: DiskCleanScanLogBuffer) { + scanLogFlushTask?.cancel() + scanLogFlushTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: Self.scanLogFlushIntervalNanoseconds) + } catch { + break + } + await self?.flushScanLogEntriesIfCurrent(operationID, buffer: buffer) + } + } + } + + private func flushScanLogEntriesIfCurrent( + _ operationID: UUID, + buffer: DiskCleanScanLogBuffer + ) async { + let messages = await buffer.drain() + guard isCurrentOperation(operationID) else { return } + appendScanLogs(messages) + } + + private func appendScanLogs(_ messages: [DiskCleanScanLogMessage]) { + guard !messages.isEmpty else { return } + + snapshot = DiskCleanControllerSnapshot( + phase: snapshot.phase, + selectedChoices: snapshot.selectedChoices, + scanResult: snapshot.scanResult, + executionResult: snapshot.executionResult, + isResultStale: snapshot.isResultStale, + errorMessage: snapshot.errorMessage, + scanLogEntries: scanLogEntries(adding: messages, to: snapshot.scanLogEntries) + ) + } + + private func scanLogEntries( + adding messages: [DiskCleanScanLogMessage], + to existingEntries: [DiskCleanScanLogEntry] + ) -> [DiskCleanScanLogEntry] { + var entries = existingEntries + entries.reserveCapacity(min(existingEntries.count + messages.count, 500)) + for message in messages { + entries.append(makeLogEntry(message)) + } + if entries.count > 500 { + entries.removeFirst(entries.count - 500) + } + return entries + } + + private func makeLogEntry(_ message: DiskCleanScanLogMessage) -> DiskCleanScanLogEntry { + defer { nextLogEntryID += 1 } + return DiskCleanScanLogEntry( + id: nextLogEntryID, + text: message.text, + tone: message.tone + ) + } + + private func selectedChoiceTitleList(_ choices: Set) -> String { + DiskCleanChoice.allCases + .filter { choices.contains($0) } + .map { $0.title(localization: localization) } + .joined(separator: localization.string("list.separator", defaultValue: "、")) + } + + private func isCurrentOperation(_ operationID: UUID) -> Bool { + currentOperationID == operationID + } + + private func isStale( + scanResult: DiskCleanScanResult?, + selectedChoices: Set + ) -> Bool { + guard let scanResult else { return false } + return scanResult.choices != selectedChoices + } + + private static func userFacingMessage(for error: Error) -> String { + if let localizedError = error as? LocalizedError, + let description = localizedError.errorDescription { + return description + } + return error.localizedDescription + } +} + +private actor DiskCleanScanLogBuffer { + private var messages: [DiskCleanScanLogMessage] = [] + + func append(_ message: DiskCleanScanLogMessage) { + messages.append(message) + } + + func drain() -> [DiskCleanScanLogMessage] { + defer { messages.removeAll(keepingCapacity: true) } + return messages + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanDetailView.swift b/Plugins/DiskClean/Sources/DiskCleanDetailView.swift new file mode 100644 index 0000000..7098f6e --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanDetailView.swift @@ -0,0 +1,317 @@ +import SwiftUI +import MacToolsPluginKit + +struct DiskCleanDetailView: View { + @ObservedObject var controller: DiskCleanController + private let localization: PluginLocalization + private let showsHeader: Bool + private let contentPadding: CGFloat + private let minimumContentHeight: CGFloat + + init( + controller: DiskCleanController, + localization: PluginLocalization = PluginLocalization(bundle: .main), + showsHeader: Bool = true, + contentPadding: CGFloat = 20, + minimumContentHeight: CGFloat = 420 + ) { + self.controller = controller + self.localization = localization + self.showsHeader = showsHeader + self.contentPadding = contentPadding + self.minimumContentHeight = minimumContentHeight + } + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + if showsHeader { + header + } + choiceControls + actionBar + scanLog + statusSummary + candidateList + } + .padding(contentPadding) + .frame(maxWidth: .infinity, minHeight: minimumContentHeight, alignment: .topLeading) + } + + private var snapshot: DiskCleanControllerSnapshot { + controller.snapshot + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(localization.string("detail.title", defaultValue: "磁盘清理")) + .font(PluginSettingsTheme.Typography.pageTitle) + + Text(snapshot.errorMessage ?? snapshot.subtitle(localization: localization)) + .font(PluginSettingsTheme.Typography.pageDescription.weight(.medium)) + .foregroundStyle(snapshot.errorMessage == nil ? Color.secondary : Color.red) + } + } + + private var choiceControls: some View { + HStack(spacing: 16) { + ForEach(DiskCleanChoice.allCases) { choice in + Toggle( + choice.title(localization: localization), + isOn: Binding( + get: { snapshot.selectedChoices.contains(choice) }, + set: { controller.setChoice(choice, isSelected: $0) } + ) + ) + .toggleStyle(.checkbox) + .disabled(snapshot.isBusy) + } + + Spacer() + } + } + + private var actionBar: some View { + HStack(spacing: 10) { + Button { + controller.scan() + } label: { + Label(localization.string("detail.action.scan", defaultValue: "扫描"), systemImage: "magnifyingglass") + } + .disabled(!snapshot.canScan) + + Button { + controller.cleanSelected(candidateIDs: cleanableCandidateIDs) + } label: { + Label(localization.string("detail.action.clean", defaultValue: "清理"), systemImage: "trash") + } + .disabled(!snapshot.canClean) + + if snapshot.isBusy { + Button { + controller.cancelCurrentOperation() + } label: { + Label(localization.string("detail.action.stop", defaultValue: "停止"), systemImage: "xmark.circle") + } + } + } + } + + private var scanLog: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(localization.string("detail.scanLog.title", defaultValue: "扫描日志")) + .font(PluginSettingsTheme.Typography.sectionTitle) + Spacer() + if snapshot.phase == .scanning { + ProgressView() + .controlSize(.small) + .scaleEffect(0.75) + } + } + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 6) { + if snapshot.scanLogEntries.isEmpty { + Text(localization.string("detail.scanLog.empty", defaultValue: "点击扫描后显示实时进度")) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(snapshot.scanLogEntries) { entry in + logRow(entry) + .id(entry.id) + } + } + } + .padding(10) + } + .frame(minHeight: 118, maxHeight: 160) + .pluginSettingsCardBackground(.recessed) + .onChange(of: snapshot.scanLogEntries.last?.id) { + guard let id = snapshot.scanLogEntries.last?.id else { return } + proxy.scrollTo(id, anchor: .bottom) + } + } + } + } + + private func logRow(_ entry: DiskCleanScanLogEntry) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: logIconName(entry.tone)) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(logColor(entry.tone)) + .frame(width: 14, height: 14) + + Text(entry.text) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .lineLimit(3) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var statusSummary: some View { + if let scanResult = snapshot.scanResult { + HStack(spacing: 16) { + summaryTile( + title: localization.string("detail.summary.cleanable", defaultValue: "可清理"), + value: itemCountText(scanResult.cleanableCandidates.count) + ) + summaryTile( + title: localization.string("detail.summary.estimatedRelease", defaultValue: "预计释放"), + value: byteText(scanResult.cleanableSizeBytes) + ) + summaryTile( + title: localization.string("detail.summary.protected", defaultValue: "已保护"), + value: itemCountText(scanResult.protectedCount) + ) + } + } + + if let executionResult = snapshot.executionResult { + HStack(spacing: 16) { + summaryTile( + title: localization.string("detail.summary.removed", defaultValue: "已删除"), + value: itemCountText(executionResult.removedCount) + ) + summaryTile( + title: localization.string("detail.summary.skipped", defaultValue: "已跳过"), + value: itemCountText(executionResult.skippedCount) + ) + summaryTile( + title: localization.string("detail.summary.failed", defaultValue: "失败"), + value: itemCountText(executionResult.failedCount) + ) + summaryTile( + title: localization.string("detail.summary.released", defaultValue: "已释放"), + value: byteText(executionResult.reclaimedBytes) + ) + } + } + } + + private func summaryTile(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + Text(value) + .font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold)) + } + .frame(minWidth: 96, alignment: .leading) + } + + @ViewBuilder + private var candidateList: some View { + if let scanResult = snapshot.scanResult { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(scanResult.candidates) { candidate in + candidateRow(candidate) + Divider() + } + } + } + .overlay { + if scanResult.candidates.isEmpty { + Text(localization.string("detail.candidates.empty", defaultValue: "没有发现可清理项目")) + .font(PluginSettingsTheme.Typography.rowTitle) + .foregroundStyle(.secondary) + } + } + } else { + EmptyView() + } + } + + private func candidateRow(_ candidate: DiskCleanCandidate) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: candidate.safety.isCleanable ? "checkmark.circle.fill" : "shield.fill") + .foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(candidate.title) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Spacer(minLength: 12) + Text(byteText(candidate.sizeBytes)) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + } + + Text(candidate.path) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + + Text(safetyText(candidate.safety)) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary) + } + } + .padding(.vertical, 9) + } + + private var cleanableCandidateIDs: Set { + Set(snapshot.scanResult?.cleanableCandidates.map(\.id) ?? []) + } + + private func safetyText(_ safety: DiskCleanSafetyStatus) -> String { + switch safety { + case .allowed: + return localization.string("safety.allowed", defaultValue: "允许清理") + case let .whitelisted(rule): + return localization.format("safety.whitelisted", defaultValue: "白名单保护:%@", rule) + case let .protected(reason): + return localization.format("safety.protected", defaultValue: "敏感数据保护:%@", reason) + case let .invalid(reason): + return localization.format("safety.invalid", defaultValue: "路径安全保护:%@", reason) + case let .requiresAdmin(reason): + return localization.format("safety.requiresAdmin", defaultValue: "需要管理员权限:%@", reason) + case let .inUse(processName): + return localization.format("safety.inUse", defaultValue: "正在使用:%@", processName) + } + } + + private func itemCountText(_ count: Int) -> String { + localization.format("item.count", defaultValue: "%d 项", count) + } + + private func byteText(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } + + private func logIconName(_ tone: DiskCleanScanLogTone) -> String { + switch tone { + case .info: + return "circle" + case .success: + return "checkmark.circle.fill" + case .warning: + return "shield.fill" + case .error: + return "xmark.octagon.fill" + } + } + + private func logColor(_ tone: DiskCleanScanLogTone) -> Color { + switch tone { + case .info: + return .secondary + case .success: + return .green + case .warning: + return .orange + case .error: + return .red + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanExecutor.swift b/Plugins/DiskClean/Sources/DiskCleanExecutor.swift new file mode 100644 index 0000000..d249cf2 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanExecutor.swift @@ -0,0 +1,129 @@ +import Foundation + +protocol DiskCleanExecuting: Sendable { + func clean( + candidates: [DiskCleanCandidate], + selectedCandidateIDs: Set + ) async throws -> DiskCleanExecutionResult +} + +struct DiskCleanExecutionItemResult: Equatable, Sendable { + enum Outcome: Equatable, Sendable { + case removed(reclaimedBytes: Int64) + case skipped(DiskCleanSafetyStatus) + case failed(message: String) + } + + let candidateID: DiskCleanCandidate.ID + let path: String + let outcome: Outcome + + var reclaimedBytes: Int64 { + if case let .removed(reclaimedBytes) = outcome { + return max(reclaimedBytes, 0) + } + return 0 + } +} + +struct DiskCleanExecutionResult: Equatable, Sendable { + let itemResults: [DiskCleanExecutionItemResult] + + var removedCount: Int { + itemResults.filter { + if case .removed = $0.outcome { return true } + return false + }.count + } + + var skippedCount: Int { + itemResults.filter { + if case .skipped = $0.outcome { return true } + return false + }.count + } + + var failedCount: Int { + itemResults.filter { + if case .failed = $0.outcome { return true } + return false + }.count + } + + var reclaimedBytes: Int64 { + itemResults.reduce(0) { $0 + $1.reclaimedBytes } + } +} + +struct DiskCleanExecutor: DiskCleanExecuting { + let fileSystem: DiskCleanFileSystemProviding + let safetyPolicy: DiskCleanSafetyPolicy + + init( + fileSystem: DiskCleanFileSystemProviding = LocalDiskCleanFileSystem(), + safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy() + ) { + self.fileSystem = fileSystem + self.safetyPolicy = safetyPolicy + } + + func clean( + candidates: [DiskCleanCandidate], + selectedCandidateIDs: Set + ) async throws -> DiskCleanExecutionResult { + var itemResults: [DiskCleanExecutionItemResult] = [] + + for candidate in candidates where selectedCandidateIDs.contains(candidate.id) { + try Task.checkCancellation() + itemResults.append(try clean(candidate)) + } + + return DiskCleanExecutionResult(itemResults: itemResults) + } + + private func clean(_ candidate: DiskCleanCandidate) throws -> DiskCleanExecutionItemResult { + guard candidate.safety.isCleanable else { + return itemResult(for: candidate, outcome: .skipped(candidate.safety)) + } + + guard let item = try fileSystem.itemInfo(at: candidate.path) else { + return itemResult( + for: candidate, + outcome: .skipped(.invalid(reason: "path no longer exists")) + ) + } + + let finalSafety = safetyPolicy.safetyStatus( + for: item.path, + isSymlink: item.isSymlink, + resolvedSymlinkTarget: item.resolvedSymlinkTarget + ) + guard finalSafety.isCleanable else { + return itemResult(for: candidate, outcome: .skipped(finalSafety)) + } + + do { + try fileSystem.removeItem(at: item.path) + return itemResult( + for: candidate, + outcome: .removed(reclaimedBytes: candidate.sizeBytes) + ) + } catch { + return itemResult( + for: candidate, + outcome: .failed(message: error.localizedDescription) + ) + } + } + + private func itemResult( + for candidate: DiskCleanCandidate, + outcome: DiskCleanExecutionItemResult.Outcome + ) -> DiskCleanExecutionItemResult { + DiskCleanExecutionItemResult( + candidateID: candidate.id, + path: candidate.path, + outcome: outcome + ) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift b/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift new file mode 100644 index 0000000..3ee4b2b --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift @@ -0,0 +1,196 @@ +import Darwin +import Foundation + +struct DiskCleanFileItem: Equatable, Sendable { + let path: String + let isDirectory: Bool + let isSymlink: Bool + let resolvedSymlinkTarget: String? +} + +protocol DiskCleanFileSystemProviding: Sendable { + func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] + func itemInfo(at path: String) throws -> DiskCleanFileItem? + func sizeOfItem(at path: String) throws -> Int64 + func removeItem(at path: String) throws + func deduplicatedParentChildPaths(_ paths: [String]) -> [String] +} + +struct LocalDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable { + private let fileManager: FileManager + private let homeDirectory: String + + init( + fileManager: FileManager = .default, + homeDirectory: String = NSHomeDirectory() + ) { + self.fileManager = fileManager + self.homeDirectory = Self.normalizeSlashes(homeDirectory) + } + + func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] { + let expandedPattern = Self.normalizeSlashes(expandHome(in: pattern)) + guard Self.containsGlob(expandedPattern) else { + guard let item = try itemInfo(at: expandedPattern) else { return [] } + return [item] + } + + let matchedPaths = try Self.globPaths(matching: expandedPattern) + return try deduplicatedParentChildPaths(matchedPaths) + .compactMap { try itemInfo(at: $0) } + } + + func itemInfo(at path: String) throws -> DiskCleanFileItem? { + let expandedPath = Self.normalizeSlashes(expandHome(in: path)) + guard fileManager.fileExists(atPath: expandedPath) || isSymlink(at: expandedPath) else { + return nil + } + + let attributes = try fileManager.attributesOfItem(atPath: expandedPath) + let fileType = attributes[.type] as? FileAttributeType + let isSymlink = fileType == .typeSymbolicLink + let isDirectory = fileType == .typeDirectory + + return DiskCleanFileItem( + path: expandedPath, + isDirectory: isDirectory, + isSymlink: isSymlink, + resolvedSymlinkTarget: isSymlink ? try? fileManager.destinationOfSymbolicLink(atPath: expandedPath) : nil + ) + } + + func sizeOfItem(at path: String) throws -> Int64 { + let expandedPath = Self.normalizeSlashes(expandHome(in: path)) + let attributes = try fileManager.attributesOfItem(atPath: expandedPath) + let fileType = attributes[.type] as? FileAttributeType + + if fileType != .typeDirectory || fileType == .typeSymbolicLink { + return Int64((attributes[.size] as? NSNumber)?.int64Value ?? 0) + } + + var total: Int64 = 0 + if let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: expandedPath), + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey, .totalFileAllocatedSizeKey], + options: [.skipsPackageDescendants] + ) { + for case let url as URL in enumerator { + let itemAttributes = try fileManager.attributesOfItem(atPath: url.path) + guard (itemAttributes[.type] as? FileAttributeType) != .typeDirectory else { + continue + } + total += Int64((itemAttributes[.size] as? NSNumber)?.int64Value ?? 0) + } + } + + return total + } + + func removeItem(at path: String) throws { + let expandedPath = Self.normalizeSlashes(expandHome(in: path)) + guard fileManager.fileExists(atPath: expandedPath) || isSymlink(at: expandedPath) else { + return + } + try fileManager.removeItem(atPath: expandedPath) + } + + func deduplicatedParentChildPaths(_ paths: [String]) -> [String] { + let uniqueSortedPaths = Array(Set(paths.map(Self.normalizeSlashes))).sorted() + + var kept: [String] = [] + var keptPathSet: Set = [] + for path in uniqueSortedPaths { + if Self.hasKeptParent(for: path, in: keptPathSet) { + continue + } + kept.append(path) + keptPathSet.insert(path) + } + + return kept + } + + private func expandHome(in pattern: String) -> String { + if pattern == "~" || pattern == "$HOME" || pattern == "${HOME}" { + return homeDirectory + } + if pattern.hasPrefix("~/") { + return homeDirectory + String(pattern.dropFirst()) + } + if pattern.hasPrefix("$HOME/") { + return homeDirectory + String(pattern.dropFirst("$HOME".count)) + } + if pattern.hasPrefix("${HOME}/") { + return homeDirectory + String(pattern.dropFirst("${HOME}".count)) + } + return pattern + } + + private func isSymlink(at path: String) -> Bool { + guard let attributes = try? fileManager.attributesOfItem(atPath: path) else { + return false + } + return (attributes[.type] as? FileAttributeType) == .typeSymbolicLink + } + + private static func containsGlob(_ pattern: String) -> Bool { + pattern.contains { "*?[".contains($0) } + } + + private static func globPaths(matching pattern: String) throws -> [String] { + var globResult = glob_t() + defer { globfree(&globResult) } + + let status = pattern.withCString { glob($0, 0, nil, &globResult) } + if status == GLOB_NOMATCH { + return [] + } + guard status == 0 else { + throw DiskCleanFileSystemError.globFailed(pattern: pattern, status: status) + } + guard let pathVector = globResult.gl_pathv else { + return [] + } + + return (0..) -> Bool { + var currentPath = path + while let slashIndex = currentPath.lastIndex(of: "/") { + currentPath = String(currentPath[.. String { + var normalized = path + while normalized.contains("//") { + normalized = normalized.replacingOccurrences(of: "//", with: "/") + } + return normalized + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanModels.swift b/Plugins/DiskClean/Sources/DiskCleanModels.swift new file mode 100644 index 0000000..eb2c484 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanModels.swift @@ -0,0 +1,102 @@ +import Foundation +import MacToolsPluginKit + +enum DiskCleanChoice: String, CaseIterable, Identifiable, Equatable, Sendable { + case cache + case developer + case browser + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .cache: + return localization.string("choice.cache.title", defaultValue: "缓存清理") + case .developer: + return localization.string("choice.developer.title", defaultValue: "开发者缓存清理") + case .browser: + return localization.string("choice.browser.title", defaultValue: "浏览器缓存清理") + } + } +} + +enum DiskCleanRisk: Equatable, Sendable { + case low + case medium + case high +} + +enum DiskCleanSafetyStatus: Equatable, Sendable { + case allowed + case whitelisted(rule: String) + case protected(reason: String) + case invalid(reason: String) + case requiresAdmin(reason: String) + case inUse(processName: String) + + var isCleanable: Bool { + if case .allowed = self { + return true + } + return false + } +} + +enum DiskCleanScanLogTone: Equatable, Sendable { + case info + case success + case warning + case error +} + +struct DiskCleanScanLogMessage: Equatable, Sendable { + let text: String + let tone: DiskCleanScanLogTone +} + +struct DiskCleanScanLogEntry: Identifiable, Equatable, Sendable { + let id: Int + let text: String + let tone: DiskCleanScanLogTone +} + +struct DiskCleanCandidate: Identifiable, Equatable, Sendable { + let id: String + let ruleID: String + let choice: DiskCleanChoice + let title: String + let path: String + let sizeBytes: Int64 + let safety: DiskCleanSafetyStatus + let risk: DiskCleanRisk +} + +struct DiskCleanScanResult: Equatable, Sendable { + let choices: Set + let candidates: [DiskCleanCandidate] + let scannedAt: Date + + var cleanableCandidates: [DiskCleanCandidate] { + candidates.filter { $0.safety.isCleanable } + } + + var cleanableSizeBytes: Int64 { + cleanableCandidates.reduce(0) { $0 + max($1.sizeBytes, 0) } + } + + var protectedCount: Int { + candidates.filter { + if case .protected = $0.safety { + return true + } + if case .whitelisted = $0.safety { + return true + } + return false + }.count + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanPlugin.swift b/Plugins/DiskClean/Sources/DiskCleanPlugin.swift new file mode 100644 index 0000000..3ae7421 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanPlugin.swift @@ -0,0 +1,236 @@ +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class DiskCleanPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + DiskCleanPluginProvider(context: context) + } +} + +@MainActor +private struct DiskCleanPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + let localization = PluginLocalization(bundle: context.resourceBundle) + let controller = DiskCleanController( + scanner: DiskCleanScanner(localization: localization), + executor: DiskCleanExecutor(), + localization: localization + ) + return [DiskCleanPlugin(controller: controller, localization: localization)] + } +} + +@MainActor +final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { + enum ControlID { + static let scan = "disk-clean-scan" + static let clean = "disk-clean-clean" + static let openDetails = "disk-clean-open-details" + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let controller: DiskCleanControlling + private let localization: PluginLocalization + private var isExpanded = false + + init( + controller: DiskCleanControlling = DiskCleanController(), + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.controller = controller + self.localization = localization + self.metadata = PluginMetadata( + id: "disk-clean", + title: localization.string("metadata.title", defaultValue: "磁盘清理"), + iconName: "internaldrive", + iconTint: Color(nsColor: .systemGreen), + order: 90, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "缓存、开发者缓存和浏览器缓存清理" + ) + ) + self.controller.onStateChange = { [weak self] in + self?.onStateChange?() + } + } + + var primaryPanelState: PluginPanelState { + let snapshot = controller.snapshot + + return PluginPanelState( + subtitle: subtitle(for: snapshot), + isOn: snapshot.isBusy, + isExpanded: isExpanded, + isEnabled: true, + isVisible: true, + detail: isExpanded ? buildDetail(for: snapshot) : nil, + errorMessage: snapshot.errorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + var configuration: PluginConfiguration? { + guard let controller = controller as? DiskCleanController else { + return nil + } + + let localization = localization + return PluginConfiguration(description: metadata.defaultDescription) { _ in + DiskCleanDetailView( + controller: controller, + localization: localization, + showsHeader: false, + contentPadding: 0, + minimumContentHeight: 0 + ) + } + } + + func refresh() {} + + func deactivate(reason: PluginDeactivationReason) { + controller.cancelCurrentOperation() + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(value): + isExpanded = value + onStateChange?() + case let .invokeAction(controlID): + handleInvoke(controlID: controlID) + case .setSwitch, + .setSelection, + .setNavigationSelection, + .clearNavigationSelection, + .setDate, + .setSlider: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + private func handleInvoke(controlID: String) { + switch controlID { + case ControlID.scan: + controller.scan() + case ControlID.clean: + controller.cleanSelected(candidateIDs: cleanableCandidateIDs) + case ControlID.openDetails: + break + default: + break + } + } + + private func buildDetail(for snapshot: DiskCleanControllerSnapshot) -> PluginPanelDetail { + let scanControl = PluginPanelControl( + id: ControlID.scan, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.scan", defaultValue: "扫描"), + actionIconSystemName: "magnifyingglass", + isEnabled: snapshot.canScan + ) + + let cleanControl = PluginPanelControl( + id: ControlID.clean, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.clean", defaultValue: "清理"), + actionIconSystemName: "trash", + showsLeadingDivider: true, + isEnabled: snapshot.canClean + ) + + let openDetailsControl = PluginPanelControl( + id: ControlID.openDetails, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.openDetails", defaultValue: "打开详情"), + actionIconSystemName: "arrow.up.right.square", + actionBehavior: .dismissBeforeHandling, + isEnabled: true + ) + + return PluginPanelDetail( + primaryControls: [scanControl, cleanControl, openDetailsControl], + secondaryPanel: nil + ) + } + + private func subtitle(for snapshot: DiskCleanControllerSnapshot) -> String { + if snapshot.phase == .scanned, + !snapshot.isResultStale, + let result = snapshot.scanResult { + return localization.format( + "panel.subtitle.scanned", + defaultValue: "%d 项,%@", + result.cleanableCandidates.count, + byteText(result.cleanableSizeBytes) + ) + } + + if snapshot.phase == .completed, + let result = snapshot.executionResult { + return localization.format( + "panel.subtitle.completed", + defaultValue: "已清理 %@", + byteText(result.reclaimedBytes) + ) + } + + return snapshot.subtitle(localization: localization) + } + + private var cleanableCandidateIDs: Set { + Set(controller.snapshot.scanResult?.cleanableCandidates.map(\.id) ?? []) + } + + private func byteText(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanRuleCatalog.swift b/Plugins/DiskClean/Sources/DiskCleanRuleCatalog.swift new file mode 100644 index 0000000..5e1a3b1 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanRuleCatalog.swift @@ -0,0 +1,872 @@ +import Foundation + +enum DiskCleanDynamicRule: String, Equatable, Sendable { + case xcodeDerivedData + case unavailableSimulators + case npmCache + case pnpmStore + case bunCache + case pipCache + case goBuildCache + case goModuleCache + case miseCache + case jetbrainsToolboxOldVersions + case aiAgentOldVersions + case serviceWorkerCache + case oldBrowserVersions +} + +struct DiskCleanRule: Identifiable, Equatable, Sendable { + enum Target: Equatable, Sendable { + case path(String) + case dynamic(DiskCleanDynamicRule) + } + + let id: String + let choice: DiskCleanChoice + let title: String + let risk: DiskCleanRisk + let targets: [Target] + let skipWhenProcessIsRunning: [String] + let requiresAdmin: Bool + + init( + id: String, + choice: DiskCleanChoice, + title: String, + risk: DiskCleanRisk, + targets: [Target], + skipWhenProcessIsRunning: [String] = [], + requiresAdmin: Bool = false + ) { + self.id = id + self.choice = choice + self.title = title + self.risk = risk + self.targets = targets + self.skipWhenProcessIsRunning = skipWhenProcessIsRunning + self.requiresAdmin = requiresAdmin + } +} + +struct DiskCleanRuleCatalog: Equatable, Sendable { + let rules: [DiskCleanRule] + + static let moleFirstVersion = DiskCleanRuleCatalog( + rules: cacheRules + developerRules + browserRules + ) + + func rules(for choice: DiskCleanChoice) -> [DiskCleanRule] { + rules.filter { $0.choice == choice } + } + + private static let cacheRules: [DiskCleanRule] = [ + rule( + id: "cache.user-essentials", + choice: .cache, + title: "User caches and logs", + targets: [ + "~/Library/Caches/*", + "~/Library/Logs/*" + ] + ), + rule( + id: "cache.macos-app-state", + choice: .cache, + title: "macOS app state caches", + targets: [ + "~/Library/Saved Application State/*", + "~/Library/Caches/com.apple.photoanalysisd", + "~/Library/Caches/com.apple.akd", + "~/Library/Caches/com.apple.WebKit.Networking/*", + "~/Library/DiagnosticReports/*", + "~/Library/Caches/com.apple.QuickLook.thumbnailcache", + "~/Library/Caches/Quick Look/*", + "~/Library/Caches/com.apple.iconservices*", + "~/Library/Autosave Information/*", + "~/Library/IdentityCaches/*", + "~/Library/Suggestions/*", + "~/Library/Calendars/Calendar Cache", + "~/Library/Application Support/AddressBook/Sources/*/Photos.cache" + ] + ), + rule( + id: "cache.apple-sandboxed-apps", + choice: .cache, + title: "Apple sandboxed app caches", + targets: [ + "~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/*", + "~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/*", + "~/Library/Containers/com.apple.mediaanalysisd/Data/tmp/*", + "~/Library/Containers/com.apple.AppStore/Data/Library/Caches/*", + "~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/*", + "~/Library/Containers/com.apple.wallpaper.extension.aerials/Data/tmp/*", + "~/Library/Containers/com.apple.geod/Data/tmp/*", + "~/Library/Containers/com.apple.stocks/Data/Library/Caches/*", + "~/Library/Application Support/com.apple.wallpaper/aerials/thumbnails/*", + "~/Library/Caches/com.apple.helpd/*", + "~/Library/Caches/GeoServices/*", + "~/Library/Containers/com.apple.AvatarUI.AvatarPickerMemojiPicker/Data/Library/Caches/*", + "~/Library/Containers/com.apple.AMPArtworkAgent/Data/Library/Caches/*", + "~/Library/Containers/com.apple.CoreDevice.CoreDeviceService/Data/Library/Caches/*", + "~/Library/Containers/com.apple.NeptuneOneExtension/Data/Library/Caches/*", + "~/Library/Containers/com.apple.AppleMediaServicesUI.UtilityExtension/Data/tmp/*", + "~/Library/Group Containers/group.com.apple.contentdelivery/Logs/*", + "~/Library/Group Containers/group.com.apple.contentdelivery/Library/Logs/*" + ] + ), + rule( + id: "cache.cloud-storage", + choice: .cache, + title: "Cloud storage caches", + targets: [ + "~/Library/Caches/com.dropbox.*", + "~/Library/Caches/com.getdropbox.dropbox", + "~/Library/Caches/com.google.GoogleDrive", + "~/Library/Caches/com.baidu.netdisk", + "~/Library/Caches/com.alibaba.teambitiondisk", + "~/Library/Caches/com.box.desktop", + "~/Library/Caches/com.microsoft.OneDrive" + ] + ), + rule( + id: "cache.office-apps", + choice: .cache, + title: "Office and mail app caches", + targets: [ + "~/Library/Caches/com.microsoft.Word", + "~/Library/Containers/com.microsoft.Word/Data/Library/Caches/*", + "~/Library/Containers/com.microsoft.Word/Data/tmp/*", + "~/Library/Containers/com.microsoft.Word/Data/Library/Logs/*", + "~/Library/Caches/com.microsoft.Excel", + "~/Library/Containers/com.microsoft.Excel/Data/Library/Caches/*", + "~/Library/Containers/com.microsoft.Excel/Data/tmp/*", + "~/Library/Containers/com.microsoft.Excel/Data/Library/Logs/*", + "~/Library/Caches/com.microsoft.Powerpoint", + "~/Library/Caches/com.microsoft.Outlook/*", + "~/Library/Caches/com.apple.iWork.*", + "~/Library/Caches/com.kingsoft.wpsoffice.mac", + "~/Library/Caches/org.mozilla.thunderbird/*", + "~/Library/Caches/com.apple.mail/*" + ] + ), + rule( + id: "cache.virtualization", + choice: .cache, + title: "Virtualization caches", + targets: [ + "~/Library/Caches/com.vmware.fusion", + "~/Library/Caches/com.parallels.*", + "~/VirtualBox VMs/.cache", + "~/.vagrant.d/tmp/*" + ] + ), + rule( + id: "cache.communication", + choice: .cache, + title: "Communication app caches", + targets: [ + "~/Library/Application Support/discord/Cache/*", + "~/Library/Application Support/legcord/Cache/*", + "~/Library/Application Support/Slack/Cache/*", + "~/Library/Caches/us.zoom.xos/*", + "~/Library/Caches/com.tencent.xinWeChat/*", + "~/Library/Caches/ru.keepcoder.Telegram/*", + "~/Library/Caches/com.microsoft.teams2/*", + "~/Library/Caches/net.whatsapp.WhatsApp/*", + "~/Library/Caches/com.skype.skype/*", + "~/Library/Caches/com.tencent.meeting/*", + "~/Library/Caches/com.tencent.WeWorkMac/*", + "~/Library/Caches/com.feishu.*/*", + "~/Library/Application Support/Microsoft/Teams/Cache/*", + "~/Library/Application Support/Microsoft/Teams/Application Cache/*", + "~/Library/Application Support/Microsoft/Teams/Code Cache/*", + "~/Library/Application Support/Microsoft/Teams/GPUCache/*", + "~/Library/Application Support/Microsoft/Teams/logs/*", + "~/Library/Application Support/Microsoft/Teams/tmp/*" + ] + ), + rule( + id: "cache.ai-assistants", + choice: .cache, + title: "AI assistant app caches", + targets: [ + "~/Library/Caches/com.openai.chat/*", + "~/Library/Caches/com.anthropic.claudefordesktop/*", + "~/Library/Logs/Claude/*", + "~/Library/Logs/com.openai.codex/*", + "~/Library/Application Support/Codex/Cache/*", + "~/Library/Application Support/Codex/Code Cache/*", + "~/Library/Application Support/Codex/GPUCache/*", + "~/Library/Application Support/Codex/DawnGraphiteCache/*", + "~/Library/Application Support/Codex/DawnWebGPUCache/*" + ] + ), + rule( + id: "cache.creative-tools", + choice: .cache, + title: "Creative tool caches", + targets: [ + "~/Library/Caches/com.bohemiancoding.sketch3/*", + "~/Library/Application Support/com.bohemiancoding.sketch3/cache/*", + "~/Library/Caches/Adobe/*", + "~/Library/Caches/com.adobe.*/*", + "~/Library/Caches/com.figma.Desktop/*", + "~/Library/Application Support/Adobe/Common/Media Cache Files/*", + "~/Library/Caches/net.telestream.screenflow10/*", + "~/Library/Caches/com.apple.FinalCut/*", + "~/Library/Caches/com.blackmagic-design.DaVinciResolve/*", + "~/Movies/CacheClip/*", + "~/Library/Caches/com.adobe.PremierePro.*/*", + "~/Library/Caches/org.blenderfoundation.blender/*", + "~/Library/Caches/com.maxon.cinema4d/*", + "~/Library/Caches/com.autodesk.*/*", + "~/Library/Caches/com.sketchup.*/*" + ] + ), + rule( + id: "cache.productivity-media", + choice: .cache, + title: "Productivity and media caches", + targets: [ + "~/Library/Caches/com.tw93.MiaoYan/*", + "~/Library/Caches/com.klee.desktop/*", + "~/Library/Caches/klee_desktop/*", + "~/Library/Caches/com.orabrowser.app/*", + "~/Library/Caches/com.filo.client/*", + "~/Library/Caches/com.flomoapp.mac/*", + "~/Library/Application Support/Quark/Cache/videoCache/*", + "~/Library/Containers/com.ranchero.NetNewsWire-Evergreen/Data/Library/Caches/*", + "~/Library/Containers/com.ideasoncanvas.mindnode/Data/Library/Caches/*", + "~/.cache/kaku/*", + "~/Library/Caches/com.spotify.client/*", + "~/Library/Caches/com.apple.Music", + "~/Library/Caches/com.apple.podcasts", + "~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia", + "~/Library/Containers/com.apple.podcasts/Data/tmp/*.heic", + "~/Library/Containers/com.apple.podcasts/Data/tmp/*.img", + "~/Library/Containers/com.apple.podcasts/Data/tmp/*CFNetworkDownload*.tmp", + "~/Library/Caches/com.apple.TV/*", + "~/Library/Caches/tv.plex.player.desktop", + "~/Library/Caches/com.netease.163music", + "~/Library/Caches/com.tencent.QQMusic/*", + "~/Library/Caches/com.kugou.mac/*", + "~/Library/Caches/com.kuwo.mac/*", + "~/Library/Caches/com.colliderli.iina", + "~/Library/Caches/org.videolan.vlc", + "~/Library/Caches/io.mpv", + "~/Library/Caches/com.iqiyi.player", + "~/Library/Caches/com.tencent.tenvideo", + "~/Library/Caches/tv.danmaku.bili/*", + "~/Library/Caches/com.douyu.*/*", + "~/Library/Caches/com.huya.*/*", + "~/Library/Caches/smart.stremio*/*", + "~/Library/Application Support/stremio/stremio-server/stremio-cache/*" + ] + ), + rule( + id: "cache.utilities", + choice: .cache, + title: "Utility app caches", + targets: [ + "~/Library/Caches/net.xmac.aria2gui", + "~/Library/Caches/org.m0k.transmission", + "~/Library/Caches/com.qbittorrent.qBittorrent", + "~/Library/Caches/com.downie.Downie-*", + "~/Library/Caches/com.folx.*/*", + "~/Library/Caches/com.charlessoft.pacifist/*", + "~/Library/Caches/com.youdao.YoudaoDict", + "~/Library/Caches/com.eudic.*", + "~/Library/Caches/com.bob-build.Bob", + "~/Library/Caches/com.cleanshot.*", + "~/Library/Caches/com.reincubate.camo", + "~/Library/Caches/com.xnipapp.xnip", + "~/Library/Caches/com.readdle.smartemail-Mac", + "~/Library/Caches/com.airmail.*", + "~/Library/Caches/com.todoist.mac.Todoist", + "~/Library/Caches/com.any.do.*", + "~/.zcompdump*", + "~/.lesshst", + "~/.viminfo.tmp", + "~/.wget-hsts", + "~/.cacher/logs/*", + "~/.kite/logs/*", + "~/Library/Caches/dev.warp.Warp-Stable/*", + "~/Library/Logs/warp.log", + "~/Library/Caches/SentryCrash/Warp/*", + "~/Library/Caches/com.mitchellh.ghostty/*" + ] + ), + rule( + id: "cache.games-notes-remote", + choice: .cache, + title: "Games, notes, and remote desktop caches", + targets: [ + "~/Library/Caches/com.valvesoftware.steam/*", + "~/Library/Application Support/Steam/htmlcache/*", + "~/Library/Application Support/Steam/appcache/*", + "~/Library/Application Support/Steam/depotcache/*", + "~/Library/Application Support/Steam/steamapps/shadercache/*", + "~/Library/Application Support/Steam/logs/*", + "~/Library/Caches/com.epicgames.EpicGamesLauncher/*", + "~/Library/Caches/com.blizzard.Battle.net/*", + "~/Library/Application Support/Battle.net/Cache/*", + "~/Library/Caches/com.ea.*/*", + "~/Library/Caches/com.gog.galaxy/*", + "~/Library/Caches/com.riotgames.*/*", + "~/Library/Application Support/minecraft/logs/*", + "~/Library/Application Support/minecraft/crash-reports/*", + "~/Library/Application Support/minecraft/webcache/*", + "~/Library/Application Support/minecraft/webcache2/*", + "~/.lunarclient/game-cache/*", + "~/.lunarclient/launcher-cache/*", + "~/.lunarclient/logs/*", + "~/.lunarclient/offline/*/logs/*", + "~/.lunarclient/offline/files/*/logs/*", + "~/Library/Caches/net.pcsx2.PCSX2/*", + "~/Library/Application Support/PCSX2/cache/*", + "~/Library/Logs/PCSX2/*", + "~/Library/Caches/net.rpcs3.rpcs3/*", + "~/Library/Application Support/rpcs3/logs/*", + "~/Library/Caches/notion.id/*", + "~/Library/Caches/md.obsidian/*", + "~/Library/Caches/com.logseq.*/*", + "~/Library/Caches/com.bear-writer.*/*", + "~/Library/Caches/com.evernote.*/*", + "~/Library/Caches/com.yinxiang.*/*", + "~/Library/Caches/com.teamviewer.*/*", + "~/Library/Caches/com.anydesk.*/*", + "~/Library/Caches/com.todesk.*/*", + "~/Library/Caches/com.sunlogin.*/*" + ] + ), + rule( + id: "cache.launchers-system-utils", + choice: .cache, + title: "Launchers and system utility caches", + targets: [ + "~/Library/Caches/com.runningwithcrayons.Alfred/*", + "~/Library/Caches/cx.c3.theunarchiver/*", + "~/Library/Caches/com.raycast.macos/urlcache/*", + "~/Library/Caches/com.raycast.macos/fsCachedData/*", + "~/Library/Caches/com.runjuu.Input-Source-Pro/*", + "~/Library/Caches/macos-wakatime.WakaTime/*", + "~/Library/Application Support/WeType/com.onevcat.Kingfisher.ImageCache.WeType/*", + "~/Library/Application Support/WeType/DictUpdate/*", + "~/Library/Application Support/mihomo-party/Cache/*", + "~/Library/Application Support/mihomo-party/Code Cache/*", + "~/Library/Application Support/mihomo-party/GPUCache/*", + "~/Library/Application Support/mihomo-party/DawnGraphiteCache/*", + "~/Library/Application Support/mihomo-party/DawnWebGPUCache/*", + "~/Library/Application Support/mihomo-party/logs/*", + "~/Library/Caches/ws.stash.app.mac/*" + ] + ) + ] + + private static let developerRules: [DiskCleanRule] = [ + DiskCleanRule( + id: "developer.xcode-derived-data", + choice: .developer, + title: "Xcode DerivedData", + risk: .medium, + targets: [.dynamic(.xcodeDerivedData)], + skipWhenProcessIsRunning: ["Xcode"] + ), + rule( + id: "developer.xcode-user-caches", + choice: .developer, + title: "Xcode user caches", + risk: .medium, + targets: [ + "~/Library/Caches/com.apple.dt.Xcode/*", + "~/Library/Developer/Xcode/iOS Device Logs/*", + "~/Library/Developer/Xcode/watchOS Device Logs/*", + "~/Library/Developer/Xcode/Products/*", + "~/Library/Developer/Xcode/Archives/*", + "~/Library/Developer/Xcode/DocumentationCache/*", + "~/Library/Developer/Xcode/DocumentationIndex/*", + "~/Library/Developer/Xcode/UserData/IB Support/*", + "~/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*", + "~/Library/Developer/Xcode/iOS DeviceSupport/*.log", + "~/Library/Developer/Xcode/watchOS DeviceSupport/*/Symbols/System/Library/Caches/*", + "~/Library/Developer/Xcode/watchOS DeviceSupport/*.log", + "~/Library/Developer/Xcode/tvOS DeviceSupport/*/Symbols/System/Library/Caches/*", + "~/Library/Developer/Xcode/tvOS DeviceSupport/*.log" + ], + skipWhenProcessIsRunning: ["Xcode"] + ), + DiskCleanRule( + id: "developer.simulator-unavailable", + choice: .developer, + title: "Unavailable simulators", + risk: .medium, + targets: [.dynamic(.unavailableSimulators)], + skipWhenProcessIsRunning: ["Simulator"] + ), + rule( + id: "developer.simulator-caches", + choice: .developer, + title: "Simulator caches", + risk: .medium, + targets: [ + "~/Library/Developer/CoreSimulator/Caches/*", + "~/Library/Developer/CoreSimulator/Devices/*/data/tmp/*", + "~/Library/Logs/CoreSimulator/*", + "~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/*" + ], + skipWhenProcessIsRunning: ["Simulator"] + ), + DiskCleanRule( + id: "developer.package-manager-dynamic-caches", + choice: .developer, + title: "Package manager dynamic caches", + risk: .medium, + targets: [ + .dynamic(.npmCache), + .dynamic(.pnpmStore), + .dynamic(.bunCache), + .dynamic(.pipCache), + .dynamic(.goBuildCache), + .dynamic(.goModuleCache), + .dynamic(.miseCache) + ] + ), + rule( + id: "developer.javascript-caches", + choice: .developer, + title: "JavaScript tool caches", + risk: .medium, + targets: [ + "~/.npm/_cacache/*", + "~/.npm/_npx/*", + "~/.npm/_logs/*", + "~/.npm/_prebuilds/*", + "~/Library/pnpm/store/*", + "~/.bun/install/cache/*", + "~/.tnpm/_cacache/*", + "~/.tnpm/_logs/*", + "~/.yarn/cache/*", + "~/Library/Caches/Yarn/*", + "~/.cache/typescript/*", + "~/.cache/electron/*", + "~/.cache/node-gyp/*", + "~/.node-gyp/*", + "~/.turbo/cache/*", + "~/.vite/cache/*", + "~/.cache/vite/*", + "~/.cache/webpack/*", + "~/.parcel-cache/*", + "~/.cache/eslint/*", + "~/.cache/prettier/*" + ] + ), + rule( + id: "developer.python-caches", + choice: .developer, + title: "Python and ML caches", + risk: .medium, + targets: [ + "~/Library/Caches/pip/*", + "~/.cache/pip/*", + "~/.pyenv/cache/*", + "~/.cache/poetry/*", + "~/.cache/uv/*", + "~/.cache/ruff/*", + "~/.cache/mypy/*", + "~/.pytest_cache/*", + "~/.jupyter/runtime/*", + "~/.cache/huggingface/*", + "~/.cache/torch/*", + "~/.cache/tensorflow/*", + "~/.conda/pkgs/*", + "~/anaconda3/pkgs/*", + "~/.cache/wandb/*" + ] + ), + rule( + id: "developer.rust-go-docker", + choice: .developer, + title: "Rust, Go, and Docker caches", + risk: .medium, + targets: [ + "~/.cargo/registry/cache/*", + "~/.cargo/git/*", + "~/.rustup/downloads/*", + "~/Library/Caches/go-build/*", + "~/go/pkg/mod/*", + "~/.docker/buildx/cache/*" + ] + ), + rule( + id: "developer.mobile-caches", + choice: .developer, + title: "Mobile development caches", + risk: .medium, + targets: [ + "~/Library/Caches/Google/AndroidStudio*/*", + "~/.android/build-cache/*", + "~/.android/cache/*", + "~/.cache/swift-package-manager/*", + "~/Library/Caches/org.swift.swiftpm/*", + "~/.expo/expo-go/*", + "~/.expo/android-apk-cache/*", + "~/.expo/ios-simulator-app-cache/*", + "~/.expo/native-modules-cache/*", + "~/.expo/schema-cache/*", + "~/.expo/template-cache/*", + "~/.expo/versions-cache/*" + ] + ), + rule( + id: "developer.jvm-caches", + choice: .developer, + title: "JVM caches", + risk: .medium, + targets: [ + "~/.m2/repository/*", + "~/.sbt/*", + "~/.ivy2/cache/*", + "~/.gradle/caches/*", + "~/.gradle/daemon/*" + ] + ), + DiskCleanRule( + id: "developer.jetbrains-toolbox-old-versions", + choice: .developer, + title: "JetBrains Toolbox old IDE versions", + risk: .medium, + targets: [.dynamic(.jetbrainsToolboxOldVersions)] + ), + DiskCleanRule( + id: "developer.ai-agent-old-versions", + choice: .developer, + title: "AI coding agent old versions", + risk: .medium, + targets: [.dynamic(.aiAgentOldVersions)] + ), + rule( + id: "developer.editor-caches", + choice: .developer, + title: "Editor caches", + risk: .medium, + targets: [ + "~/Library/Application Support/Code/logs/*", + "~/Library/Application Support/Code/Cache/*", + "~/Library/Application Support/Code/CachedExtensions/*", + "~/Library/Application Support/Code/CachedData/*", + "~/Library/Application Support/Code/DawnGraphiteCache/*", + "~/Library/Application Support/Code/DawnWebGPUCache/*", + "~/Library/Application Support/Code/GPUCache/*", + "~/Library/Application Support/Code/CachedExtensionVSIXs/*", + "~/Library/Application Support/Code/Service Worker/ScriptCache/*", + "~/Library/Caches/com.microsoft.VSCode/Cache/*", + "~/Library/Caches/com.sublimetext.*/*", + "~/Library/Caches/Zed/*", + "~/Library/Logs/Zed/*", + "~/Library/Caches/copilot/*", + "~/.cache/vscode-ripgrep/*", + "~/Library/Caches/Cursor/*", + "~/Library/Application Support/Cursor/CachedData/*", + "~/Library/Application Support/Cursor/CachedExtensionVSIXs/*", + "~/Library/Application Support/Cursor/GPUCache/*", + "~/Library/Application Support/Cursor/DawnGraphiteCache/*", + "~/Library/Application Support/Cursor/DawnWebGPUCache/*", + "~/Library/Application Support/Cursor/Service Worker/ScriptCache/*" + ] + ), + rule( + id: "developer.cloud-devops-caches", + choice: .developer, + title: "Cloud and DevOps caches", + risk: .medium, + targets: [ + "~/.kube/cache/*", + "~/.local/share/containers/storage/tmp/*", + "~/.aws/cli/cache/*", + "~/.config/gcloud/logs/*", + "~/.azure/logs/*", + "~/.cache/terraform/*", + "~/.grafana/cache/*", + "~/.prometheus/data/wal/*", + "~/.jenkins/workspace/*/target/*", + "~/.cache/gitlab-runner/*", + "~/.github/cache/*", + "~/.circleci/cache/*", + "~/.sonar/*" + ] + ), + rule( + id: "developer.language-caches", + choice: .developer, + title: "Other language tool caches", + risk: .medium, + targets: [ + "~/.bundle/cache/*", + "~/.composer/cache/*", + "~/Library/Caches/composer/*", + "~/.nuget/packages/*", + "~/.cache/bazel/*", + "~/.cache/zig/*", + "~/Library/Caches/deno/*", + "~/.hex/cache/*", + "~/.cabal/packages/*", + "~/.opam/download-cache/*" + ] + ), + rule( + id: "developer.database-api-caches", + choice: .developer, + title: "Database and API tool caches", + risk: .medium, + targets: [ + "~/Library/Caches/com.sequel-ace.sequel-ace/*", + "~/Library/Caches/com.eggerapps.Sequel-Pro/*", + "~/Library/Caches/redis-desktop-manager/*", + "~/Library/Caches/com.navicat.*", + "~/Library/Caches/com.dbeaver.*", + "~/Library/Caches/com.redis.RedisInsight", + "~/Library/Caches/com.postmanlabs.mac/*", + "~/Library/Caches/com.konghq.insomnia/*", + "~/Library/Caches/com.tinyapp.TablePlus/*", + "~/Library/Caches/com.getpaw.Paw/*", + "~/Library/Caches/com.charlesproxy.charles/*", + "~/Library/Caches/com.proxyman.NSProxy/*" + ] + ), + rule( + id: "developer.misc-caches", + choice: .developer, + title: "Misc developer caches", + risk: .medium, + targets: [ + "~/Library/Caches/com.unity3d.*/*", + "~/Library/Caches/com.mongodb.compass/*", + "~/Library/Caches/com.figma.Desktop/*", + "~/Library/Caches/com.github.GitHubDesktop/*", + "~/Library/Caches/SentryCrash/*", + "~/Library/Caches/KSCrash/*", + "~/Library/Caches/com.crashlytics.data/*", + "~/Library/Application Support/Antigravity/Cache/*", + "~/Library/Application Support/Antigravity/Code Cache/*", + "~/Library/Application Support/Antigravity/GPUCache/*", + "~/Library/Application Support/Antigravity/DawnGraphiteCache/*", + "~/Library/Application Support/Antigravity/DawnWebGPUCache/*", + "~/Library/Application Support/Codex/Cache/*", + "~/Library/Application Support/Codex/Code Cache/*", + "~/Library/Application Support/Codex/GPUCache/*", + "~/Library/Application Support/Codex/DawnGraphiteCache/*", + "~/Library/Application Support/Codex/DawnWebGPUCache/*", + "~/Library/Application Support/Filo/production/Cache/*", + "~/Library/Application Support/Filo/production/Code Cache/*", + "~/Library/Application Support/Filo/production/GPUCache/*", + "~/Library/Application Support/Filo/production/DawnGraphiteCache/*", + "~/Library/Application Support/Filo/production/DawnWebGPUCache/*", + "~/Library/Application Support/Claude/Cache/*", + "~/Library/Application Support/Claude/Code Cache/*", + "~/Library/Application Support/Claude/GPUCache/*", + "~/Library/Application Support/Claude/DawnGraphiteCache/*", + "~/Library/Application Support/Claude/DawnWebGPUCache/*", + "~/Library/Application Support/Claude/sentry/*", + "~/Library/Application Support/Claude/pending-uploads/*", + "~/Library/Application Support/Qoder/Cache/*", + "~/Library/Application Support/Qoder/CachedData/*", + "~/Library/Application Support/Qoder/CachedExtensionVSIXs/*", + "~/Library/Application Support/Qoder/Code Cache/*", + "~/Library/Application Support/Qoder/GPUCache/*", + "~/Library/Application Support/Qoder/DawnGraphiteCache/*", + "~/Library/Application Support/Qoder/DawnWebGPUCache/*", + "~/Library/Application Support/Qoder/logs/*", + "~/.cache/prisma/*", + "~/.cache/opencode/*" + ] + ), + rule( + id: "developer.shell-network-caches", + choice: .developer, + title: "Shell and network tool caches", + risk: .low, + targets: [ + "~/.gitconfig.lock", + "~/.gitconfig.bak*", + "~/.oh-my-zsh/cache/*", + "~/.config/fish/fish_history.bak*", + "~/.bash_history.bak*", + "~/.zsh_history.bak*", + "~/.cache/pre-commit/*", + "~/.cache/curl/*", + "~/.cache/wget/*", + "~/Library/Caches/curl/*", + "~/Library/Caches/wget/*" + ] + ), + rule( + id: "developer.homebrew", + choice: .developer, + title: "Homebrew caches", + risk: .medium, + targets: [ + "~/Library/Caches/Homebrew/*" + ] + ) + ] + + private static let browserRules: [DiskCleanRule] = [ + rule( + id: "browser.safari", + choice: .browser, + title: "Safari cache", + targets: [ + "~/Library/Caches/com.apple.Safari/*" + ] + ), + rule( + id: "browser.chrome", + choice: .browser, + title: "Chrome and Chromium caches", + targets: [ + "~/Library/Caches/Google/Chrome/*", + "~/Library/Application Support/Google/Chrome/*/Application Cache/*", + "~/Library/Application Support/Google/Chrome/*/GPUCache/*", + "~/Library/Application Support/Google/Chrome/component_crx_cache/*", + "~/Library/Application Support/Google/Chrome/ShaderCache/*", + "~/Library/Application Support/Google/Chrome/GrShaderCache/*", + "~/Library/Application Support/Google/Chrome/GraphiteDawnCache/*", + "~/Library/Application Support/Google/Chrome/*/Service Worker/ScriptCache/*", + "~/Library/Application Support/Google/GoogleUpdater/crx_cache/*", + "~/Library/Application Support/Google/GoogleUpdater/*.old", + "~/Library/Caches/Chromium/*", + "~/.cache/puppeteer/*" + ], + skipWhenProcessIsRunning: ["Google Chrome"] + ), + rule( + id: "browser.edge", + choice: .browser, + title: "Edge cache", + targets: [ + "~/Library/Caches/com.microsoft.edgemac/*" + ] + ), + rule( + id: "browser.arc-dia", + choice: .browser, + title: "Arc and Dia caches", + targets: [ + "~/Library/Caches/company.thebrowser.Browser/*", + "~/Library/Application Support/Arc/*/GPUCache/*", + "~/Library/Application Support/Arc/ShaderCache/*", + "~/Library/Application Support/Arc/GrShaderCache/*", + "~/Library/Application Support/Arc/GraphiteDawnCache/*", + "~/Library/Application Support/Arc/*/Service Worker/ScriptCache/*", + "~/Library/Caches/company.thebrowser.dia/*" + ], + skipWhenProcessIsRunning: ["Arc"] + ), + rule( + id: "browser.brave", + choice: .browser, + title: "Brave cache", + targets: [ + "~/Library/Caches/BraveSoftware/Brave-Browser/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Application Cache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/GPUCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/component_crx_cache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/ShaderCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/GrShaderCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/GraphiteDawnCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Service Worker/ScriptCache/*" + ], + skipWhenProcessIsRunning: ["Brave Browser"] + ), + rule( + id: "browser.helium-yandex", + choice: .browser, + title: "Helium and Yandex caches", + targets: [ + "~/Library/Caches/net.imput.helium/*", + "~/Library/Application Support/net.imput.helium/*/GPUCache/*", + "~/Library/Application Support/net.imput.helium/component_crx_cache/*", + "~/Library/Application Support/net.imput.helium/extensions_crx_cache/*", + "~/Library/Application Support/net.imput.helium/GrShaderCache/*", + "~/Library/Application Support/net.imput.helium/GraphiteDawnCache/*", + "~/Library/Application Support/net.imput.helium/ShaderCache/*", + "~/Library/Application Support/net.imput.helium/*/Application Cache/*", + "~/Library/Caches/Yandex/YandexBrowser/*", + "~/Library/Application Support/Yandex/YandexBrowser/ShaderCache/*", + "~/Library/Application Support/Yandex/YandexBrowser/GrShaderCache/*", + "~/Library/Application Support/Yandex/YandexBrowser/GraphiteDawnCache/*", + "~/Library/Application Support/Yandex/YandexBrowser/*/GPUCache/*" + ] + ), + rule( + id: "browser.firefox", + choice: .browser, + title: "Firefox cache", + targets: [ + "~/Library/Caches/Firefox/*", + "~/Library/Application Support/Firefox/Profiles/*/cache2/*" + ], + skipWhenProcessIsRunning: ["Firefox"] + ), + rule( + id: "browser.opera-vivaldi", + choice: .browser, + title: "Opera and Vivaldi caches", + targets: [ + "~/Library/Caches/com.operasoftware.Opera/*", + "~/Library/Caches/com.vivaldi.Vivaldi/*", + "~/Library/Application Support/Vivaldi/*/GPUCache/*", + "~/Library/Application Support/Vivaldi/ShaderCache/*", + "~/Library/Application Support/Vivaldi/GrShaderCache/*", + "~/Library/Application Support/Vivaldi/GraphiteDawnCache/*", + "~/Library/Application Support/Vivaldi/*/Service Worker/ScriptCache/*" + ], + skipWhenProcessIsRunning: ["Vivaldi"] + ), + rule( + id: "browser.comet-orion-zen", + choice: .browser, + title: "Comet, Orion, and Zen caches", + targets: [ + "~/Library/Caches/Comet/*", + "~/Library/Caches/com.kagi.kagimacOS/*", + "~/Library/Caches/zen/*" + ] + ), + DiskCleanRule( + id: "browser.service-worker", + choice: .browser, + title: "Browser Service Worker caches", + risk: .medium, + targets: [.dynamic(.serviceWorkerCache)] + ), + DiskCleanRule( + id: "browser.old-versions", + choice: .browser, + title: "Old browser versions", + risk: .medium, + targets: [.dynamic(.oldBrowserVersions)] + ) + ] + + private static func rule( + id: String, + choice: DiskCleanChoice, + title: String, + risk: DiskCleanRisk = .low, + targets: [String], + skipWhenProcessIsRunning: [String] = [], + requiresAdmin: Bool = false + ) -> DiskCleanRule { + DiskCleanRule( + id: id, + choice: choice, + title: title, + risk: risk, + targets: targets.map { .path($0) }, + skipWhenProcessIsRunning: skipWhenProcessIsRunning, + requiresAdmin: requiresAdmin + ) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift b/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift new file mode 100644 index 0000000..8932115 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift @@ -0,0 +1,316 @@ +import Foundation + +struct DiskCleanSafetyPolicy: Sendable { + let homeDirectory: String + let whitelistStore: DiskCleanWhitelistStore + + init( + homeDirectory: String = NSHomeDirectory(), + whitelistStore: DiskCleanWhitelistStore? = nil + ) { + self.homeDirectory = Self.normalizeSlashes(homeDirectory) + self.whitelistStore = whitelistStore ?? DiskCleanWhitelistStore(homeDirectory: homeDirectory) + } + + func validatePathShape( + _ path: String, + isSymlink: Bool = false, + resolvedSymlinkTarget: String? = nil + ) -> DiskCleanSafetyStatus { + let normalizedPath = normalizePath(path) + guard !normalizedPath.isEmpty else { + return .invalid(reason: "empty path") + } + guard normalizedPath.hasPrefix("/") else { + return .invalid(reason: "path must be absolute") + } + guard !Self.containsTraversalComponent(normalizedPath) else { + return .invalid(reason: "path traversal is not allowed") + } + guard !Self.containsControlCharacter(normalizedPath) else { + return .invalid(reason: "path contains control characters") + } + + if isSymlink { + guard let resolvedSymlinkTarget, !resolvedSymlinkTarget.isEmpty else { + return .invalid(reason: "cannot validate symlink target") + } + + let normalizedTarget = normalizePath(resolvedSymlinkTarget) + if Self.isProtectedSystemRoot(normalizedTarget) { + return .invalid(reason: "symlink points to protected system path") + } + } + + if Self.isProtectedSystemRoot(normalizedPath) { + return .invalid(reason: "critical system path") + } + + return .allowed + } + + func safetyStatus( + for path: String, + isSymlink: Bool = false, + resolvedSymlinkTarget: String? = nil + ) -> DiskCleanSafetyStatus { + let normalizedPath = normalizePath(path) + let shapeStatus = validatePathShape( + normalizedPath, + isSymlink: isSymlink, + resolvedSymlinkTarget: resolvedSymlinkTarget + ) + guard case .allowed = shapeStatus else { + return shapeStatus + } + + if let reason = sensitiveProtectionReason(for: normalizedPath) { + return .protected(reason: reason) + } + + if let rule = whitelistStore.matchingRule(for: normalizedPath) { + return .whitelisted(rule: rule.expandedPattern) + } + + return .allowed + } + + private func normalizePath(_ path: String) -> String { + let expanded = expandHome(in: path.trimmingCharacters(in: .whitespacesAndNewlines)) + return Self.normalizeSlashes(Self.stripTrailingSlash(expanded)) + } + + private func expandHome(in path: String) -> String { + if path == "~" || path == "$HOME" || path == "${HOME}" { + return homeDirectory + } + if path.hasPrefix("~/") { + return homeDirectory + String(path.dropFirst()) + } + if path.hasPrefix("$HOME/") { + return homeDirectory + String(path.dropFirst("$HOME".count)) + } + if path.hasPrefix("${HOME}/") { + return homeDirectory + String(path.dropFirst("${HOME}".count)) + } + return path + } + + private func sensitiveProtectionReason(for path: String) -> String? { + let lower = path.lowercased() + + if Self.hasPathFragment(lower, "/library/keychains") + || Self.hasPathFragment(lower, "/.ssh") + || Self.hasPathFragment(lower, "/.gnupg") + || lower.contains("keychain") + || lower.contains("credential") + || lower.contains("auth_token") + || lower.contains("access_token") { + return "credentials and key material are protected" + } + + if Self.hasPathFragment(lower, "/library/application support/com.apple.tcc") + || lower.hasSuffix("/tcc.db") + || lower.contains("/tcc/") { + return "privacy permission database is protected" + } + + if Self.hasPathFragment(lower, "/library/mobile documents") + || Self.hasPathFragment(lower, "/mobile documents") { + return "iCloud synced data is protected" + } + + if isProtectedBrowserProfileData(lower) { + return "browser profile data is protected" + } + + if isProtectedPasswordManagerData(lower) { + return "password manager data is protected" + } + + if isProtectedProxyOrVPNData(lower) { + return "VPN and proxy data is protected" + } + + if isProtectedSystemState(lower) { + return "macOS state data is protected" + } + + if Self.hasPathFragment(lower, "/library/logs/mole") + || Self.hasPathFragment(lower, "/library/logs/mactools") + || Self.hasPathFragment(lower, "/.config/mole") + || Self.hasPathFragment(lower, "/library/application support/mactools") { + return "cleanup tool state is protected" + } + + return nil + } + + private func isProtectedBrowserProfileData(_ lower: String) -> Bool { + if Self.hasPathFragment(lower, "/library/cookies") + || lower.hasSuffix(".binarycookies") + || lower.hasSuffix("/cookies") + || lower.hasSuffix("/cookies-journal") + || lower.hasSuffix("/history") + || lower.hasSuffix("/history-journal") + || lower.hasSuffix("/login data") + || lower.hasSuffix("/login data-journal") + || lower.hasSuffix("/web data") + || lower.hasSuffix("/local state") + || lower.hasSuffix("/bookmarks") + || lower.hasSuffix("/sessions") + || lower.hasSuffix("/session storage") { + return true + } + + if Self.hasPathFragment(lower, "/library/safari") + && (lower.hasSuffix("/history.db") + || lower.hasSuffix("/downloads.plist") + || lower.hasSuffix("/bookmarks.plist") + || lower.hasSuffix("/lastsession.plist")) { + return true + } + + if Self.hasPathFragment(lower, "/library/application support/firefox/profiles") + && (lower.hasSuffix("/places.sqlite") + || lower.hasSuffix("/cookies.sqlite") + || lower.hasSuffix("/key4.db") + || lower.hasSuffix("/logins.json") + || lower.contains("/sessionstore")) { + return true + } + + return false + } + + private func isProtectedPasswordManagerData(_ lower: String) -> Bool { + let tokens = [ + "1password", + "agilebits", + "lastpass", + "dashlane", + "bitwarden", + "keepass", + "keepassxc", + "authy", + "yubico" + ] + return tokens.contains { lower.contains($0) } + } + + private func isProtectedProxyOrVPNData(_ lower: String) -> Bool { + let tokens = [ + "nssurge", + "surge-mac", + "clash", + "mihomo", + "v2ray", + "shadowsocks", + "sing-box", + "openvpn", + "tailscale", + "zerotier", + "nordvpn", + "expressvpn", + "protonvpn", + "surfshark", + "windscribe", + "mullvad", + "privateinternetaccess", + "quantumult" + ] + return tokens.contains { lower.contains($0) } + } + + private func isProtectedSystemState(_ lower: String) -> Bool { + if lower.contains("systemsettings") + || lower.contains("systempreferences") + || lower.contains("controlcenter") + || lower.contains("com.apple.settings") + || lower.contains("com.apple.notes") + || lower.contains("com.apple.finder") + || lower.contains("com.apple.dock") + || lower.contains("com.apple.bluetooth") + || lower.contains("com.apple.wifi") + || lower.contains("org.cups.") { + return true + } + + if lower.hasSuffix("/library/preferences/com.apple.dock.plist") + || lower.hasSuffix("/library/preferences/com.apple.finder.plist") { + return true + } + + if lower.contains("inputmethod") + || lower.contains("textinput") + || lower.contains("keyboard") + || lower.contains("inputsource") + || lower.contains("keylayout") + || lower.contains("globalpreferences") + || lower.contains("karabiner") { + return true + } + + return false + } + + private static func containsTraversalComponent(_ path: String) -> Bool { + path.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } + + private static func containsControlCharacter(_ path: String) -> Bool { + path.unicodeScalars.contains { scalar in + scalar.value < 32 || scalar.value == 127 + } + } + + private static func isProtectedSystemRoot(_ path: String) -> Bool { + let normalized = stripTrailingSlash(normalizeSlashes(path)) + if normalized == "/" { + return true + } + + let exactRoots = [ + "/private", + "/var", + "/var/db", + "/private/var", + "/private/var/db" + ] + if exactRoots.contains(normalized) { + return true + } + + let protectedPrefixes = [ + "/System", + "/bin", + "/sbin", + "/usr", + "/etc", + "/private/etc", + "/Library/Extensions" + ] + + return protectedPrefixes.contains { root in + normalized == root || normalized.hasPrefix(root + "/") + } || normalized.hasPrefix("/var/db/") + || normalized.hasPrefix("/private/var/db/") + } + + private static func hasPathFragment(_ lowerPath: String, _ fragment: String) -> Bool { + lowerPath == fragment || lowerPath.contains(fragment + "/") || lowerPath.contains(fragment) + } + + private static func normalizeSlashes(_ path: String) -> String { + var normalized = path + while normalized.contains("//") { + normalized = normalized.replacingOccurrences(of: "//", with: "/") + } + return normalized + } + + private static func stripTrailingSlash(_ path: String) -> String { + guard path.count > 1, path.hasSuffix("/") else { return path } + return String(path.dropLast()) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanScanner.swift b/Plugins/DiskClean/Sources/DiskCleanScanner.swift new file mode 100644 index 0000000..1144799 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanScanner.swift @@ -0,0 +1,301 @@ +import Foundation +import MacToolsPluginKit + +typealias DiskCleanScanProgressHandler = @Sendable (DiskCleanScanLogMessage) async -> Void + +protocol DiskCleanScanning: Sendable { + func scan( + choices: Set, + progress: DiskCleanScanProgressHandler + ) async throws -> DiskCleanScanResult +} + +extension DiskCleanScanning { + func scan(choices: Set) async throws -> DiskCleanScanResult { + try await scan(choices: choices, progress: { _ in }) + } +} + +protocol DiskCleanProcessInspecting: Sendable { + func runningProcessName(from names: [String]) -> String? +} + +struct LocalDiskCleanProcessInspector: DiskCleanProcessInspecting { + func runningProcessName(from names: [String]) -> String? { + for name in names where isProcessRunning(name) { + return name + } + return nil + } + + private func isProcessRunning(_ name: String) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-x", name] + + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } +} + +struct DiskCleanScanner: DiskCleanScanning { + private struct ExpandedItem { + let rule: DiskCleanRule + let item: DiskCleanFileItem + } + + let ruleCatalog: DiskCleanRuleCatalog + let fileSystem: DiskCleanFileSystemProviding + let safetyPolicy: DiskCleanSafetyPolicy + let processInspector: DiskCleanProcessInspecting + let now: @Sendable () -> Date + let localization: PluginLocalization + + init( + ruleCatalog: DiskCleanRuleCatalog = .moleFirstVersion, + fileSystem: DiskCleanFileSystemProviding = LocalDiskCleanFileSystem(), + safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy(), + processInspector: DiskCleanProcessInspecting = LocalDiskCleanProcessInspector(), + now: @escaping @Sendable () -> Date = Date.init, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.ruleCatalog = ruleCatalog + self.fileSystem = fileSystem + self.safetyPolicy = safetyPolicy + self.processInspector = processInspector + self.now = now + self.localization = localization + } + + func scan( + choices: Set, + progress: DiskCleanScanProgressHandler + ) async throws -> DiskCleanScanResult { + var expandedItems: [ExpandedItem] = [] + var currentChoice: DiskCleanChoice? + + for rule in ruleCatalog.rules where choices.contains(rule.choice) { + try Task.checkCancellation() + + if currentChoice != rule.choice { + currentChoice = rule.choice + await progress( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.scope", + defaultValue: "扫描范围:%@", + rule.choice.title(localization: localization) + ), + tone: .info + ) + ) + } + + await progress( + DiskCleanScanLogMessage( + text: localization.format("scanLog.expandRule", defaultValue: "展开规则:%@", rule.title), + tone: .info + ) + ) + + for target in rule.targets { + try Task.checkCancellation() + let items = try expand(target) + await progress( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.matched", + defaultValue: "匹配 %d 项:%@", + items.count, + rule.title + ), + tone: items.isEmpty ? .info : .success + ) + ) + expandedItems += items.map { ExpandedItem(rule: rule, item: $0) } + } + } + + let firstItemByPath = Dictionary( + expandedItems.map { ($0.item.path, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let deduplicatedPaths = fileSystem.deduplicatedParentChildPaths(expandedItems.map(\.item.path)) + var candidates: [DiskCleanCandidate] = [] + for path in deduplicatedPaths { + try Task.checkCancellation() + guard let expandedItem = firstItemByPath[path] else { + continue + } + let candidate = makeCandidate(from: expandedItem) + candidates.append(candidate) + await progress(logMessage(for: candidate)) + } + + await progress( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.completed", + defaultValue: "扫描完成:%d 项,%d 项可清理", + candidates.count, + candidates.filter { $0.safety.isCleanable }.count + ), + tone: .success + ) + ) + + return DiskCleanScanResult( + choices: choices, + candidates: candidates, + scannedAt: now() + ) + } + + private func expand(_ target: DiskCleanRule.Target) throws -> [DiskCleanFileItem] { + switch target { + case let .path(pattern): + return try fileSystem.expandPathPattern(pattern) + case let .dynamic(dynamicRule): + return try expand(dynamicRule) + } + } + + private func expand(_ dynamicRule: DiskCleanDynamicRule) throws -> [DiskCleanFileItem] { + var items: [DiskCleanFileItem] = [] + for pattern in staticFallbackPatterns(for: dynamicRule) { + items += try fileSystem.expandPathPattern(pattern) + } + return items + } + + private func staticFallbackPatterns(for dynamicRule: DiskCleanDynamicRule) -> [String] { + switch dynamicRule { + case .xcodeDerivedData: + return ["~/Library/Developer/Xcode/DerivedData/*"] + case .unavailableSimulators: + return [] + case .npmCache: + return [ + "~/.npm/_cacache/*", + "~/.npm/_npx/*", + "~/.npm/_logs/*", + "~/.npm/_prebuilds/*" + ] + case .pnpmStore: + return ["~/Library/pnpm/store/*"] + case .bunCache: + return ["~/.bun/install/cache/*"] + case .pipCache: + return [ + "~/Library/Caches/pip/*", + "~/.cache/pip/*" + ] + case .goBuildCache: + return [ + "~/Library/Caches/go-build/*", + "~/.cache/go-build/*" + ] + case .goModuleCache: + return ["~/go/pkg/mod/*"] + case .miseCache: + return ["~/Library/Caches/mise/*"] + case .jetbrainsToolboxOldVersions: + return [] + case .aiAgentOldVersions: + return [] + case .serviceWorkerCache: + return [ + "~/Library/Application Support/Google/Chrome/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Arc/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Vivaldi/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Code/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Cursor/Service Worker/CacheStorage/*/*" + ] + case .oldBrowserVersions: + return [] + } + } + + private func makeCandidate(from expandedItem: ExpandedItem) -> DiskCleanCandidate { + let rule = expandedItem.rule + let item = expandedItem.item + let safety = safetyStatus(for: rule, item: item) + let size = (try? fileSystem.sizeOfItem(at: item.path)) ?? 0 + + return DiskCleanCandidate( + id: "\(rule.id)::\(item.path)", + ruleID: rule.id, + choice: rule.choice, + title: rule.title, + path: item.path, + sizeBytes: size, + safety: safety, + risk: rule.risk + ) + } + + private func logMessage(for candidate: DiskCleanCandidate) -> DiskCleanScanLogMessage { + switch candidate.safety { + case .allowed: + return DiskCleanScanLogMessage( + text: localization.format("scanLog.candidate.allowed", defaultValue: "可清理:%@", candidate.path), + tone: .success + ) + case .whitelisted: + return DiskCleanScanLogMessage( + text: localization.format("scanLog.candidate.whitelisted", defaultValue: "白名单保护:%@", candidate.path), + tone: .warning + ) + case .protected: + return DiskCleanScanLogMessage( + text: localization.format("scanLog.candidate.protected", defaultValue: "敏感数据保护:%@", candidate.path), + tone: .warning + ) + case .invalid: + return DiskCleanScanLogMessage( + text: localization.format("scanLog.candidate.invalid", defaultValue: "路径安全保护:%@", candidate.path), + tone: .warning + ) + case .requiresAdmin: + return DiskCleanScanLogMessage( + text: localization.format("scanLog.candidate.requiresAdmin", defaultValue: "需要管理员权限:%@", candidate.path), + tone: .warning + ) + case let .inUse(processName): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.candidate.inUse", + defaultValue: "正在使用(%@):%@", + processName, + candidate.path + ), + tone: .warning + ) + } + } + + private func safetyStatus( + for rule: DiskCleanRule, + item: DiskCleanFileItem + ) -> DiskCleanSafetyStatus { + if let processName = processInspector.runningProcessName(from: rule.skipWhenProcessIsRunning) { + return .inUse(processName: processName) + } + + if rule.requiresAdmin { + return .requiresAdmin(reason: "admin privileges required") + } + + return safetyPolicy.safetyStatus( + for: item.path, + isSymlink: item.isSymlink, + resolvedSymlinkTarget: item.resolvedSymlinkTarget + ) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanWhitelistStore.swift b/Plugins/DiskClean/Sources/DiskCleanWhitelistStore.swift new file mode 100644 index 0000000..76c0a01 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanWhitelistStore.swift @@ -0,0 +1,261 @@ +import Darwin +import Foundation + +struct DiskCleanWhitelistRule: Identifiable, Hashable, Sendable { + enum Source: Hashable, Sendable { + case defaultRule + case custom + } + + let rawPattern: String + let expandedPattern: String + let source: Source + + var id: String { expandedPattern } +} + +enum DiskCleanWhitelistValidationError: Error, Equatable, Sendable { + case empty + case relativePath + case traversal + case controlCharacters + case protectedSystemRoot +} + +struct DiskCleanWhitelistStore: Sendable { + static let finderMetadataSentinel = "FINDER_METADATA" + + static let defaultRulePatterns: [String] = [ + "$HOME/Library/Caches/ms-playwright*", + "$HOME/.cache/huggingface*", + "$HOME/.m2/repository/*", + "$HOME/.gradle/caches/*", + "$HOME/.gradle/daemon/*", + "$HOME/.ollama/models/*", + "$HOME/Library/Caches/com.nssurge.surge-mac/*", + "$HOME/Library/Application Support/com.nssurge.surge-mac/*", + "$HOME/Library/Caches/org.R-project.R/R/renv/*", + "$HOME/Library/Caches/pypoetry/virtualenvs*", + "$HOME/Library/Caches/JetBrains*", + "$HOME/Library/Caches/com.jetbrains.toolbox*", + "$HOME/Library/Caches/tealdeer/tldr-pages", + "$HOME/Library/Application Support/JetBrains*", + "$HOME/Library/Caches/com.apple.finder", + "$HOME/Library/Mobile Documents*", + "$HOME/Library/Caches/com.apple.FontRegistry*", + "$HOME/Library/Caches/com.apple.spotlight*", + "$HOME/Library/Caches/com.apple.Spotlight*", + "$HOME/Library/Caches/CloudKit*", + finderMetadataSentinel + ] + + let homeDirectory: String + + private let includeDefaults: Bool + private let customRules: [String] + + init( + homeDirectory: String = NSHomeDirectory(), + includeDefaults: Bool = true, + customRules: [String] = [] + ) { + self.homeDirectory = Self.normalizeSlashes(homeDirectory) + self.includeDefaults = includeDefaults + self.customRules = customRules + } + + func expandedRules() -> [DiskCleanWhitelistRule] { + let defaultRules = includeDefaults ? Self.defaultRulePatterns : [] + var seenPatterns = Set() + var rules: [DiskCleanWhitelistRule] = [] + + for rawPattern in defaultRules { + guard let rule = try? makeRule(rawPattern, source: .defaultRule, validate: true) else { + continue + } + append(rule, to: &rules, seenPatterns: &seenPatterns) + } + + for rawPattern in customRules { + guard let rule = try? makeRule(rawPattern, source: .custom, validate: true) else { + continue + } + append(rule, to: &rules, seenPatterns: &seenPatterns) + } + + return rules + } + + func validateCustomRule(_ rawPattern: String) throws -> DiskCleanWhitelistRule { + try makeRule(rawPattern, source: .custom, validate: true) + } + + func matchingRule(for path: String) -> DiskCleanWhitelistRule? { + let normalizedPath = Self.normalizeSlashes(Self.stripTrailingSlash(expandHome(in: path))) + guard !normalizedPath.isEmpty else { return nil } + + for rule in expandedRules() { + let pattern = Self.normalizeSlashes(Self.stripTrailingSlash(rule.expandedPattern)) + + if pattern == Self.finderMetadataSentinel { + if URL(fileURLWithPath: normalizedPath).lastPathComponent == ".DS_Store" { + return rule + } + continue + } + + let hasGlob = Self.containsGlob(pattern) + if normalizedPath == pattern || Self.fnmatch(pattern: pattern, path: normalizedPath) { + return rule + } + + if pattern.hasPrefix(normalizedPath + "/") { + return rule + } + + if !hasGlob && normalizedPath.hasPrefix(pattern + "/") { + return rule + } + } + + return nil + } + + func isWhitelisted(_ path: String) -> Bool { + matchingRule(for: path) != nil + } + + private func makeRule( + _ rawPattern: String, + source: DiskCleanWhitelistRule.Source, + validate: Bool + ) throws -> DiskCleanWhitelistRule { + let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw DiskCleanWhitelistValidationError.empty + } + + guard !Self.containsControlCharacter(trimmed) else { + throw DiskCleanWhitelistValidationError.controlCharacters + } + + if trimmed == Self.finderMetadataSentinel { + return DiskCleanWhitelistRule( + rawPattern: trimmed, + expandedPattern: trimmed, + source: source + ) + } + + let expanded = Self.normalizeSlashes(Self.stripTrailingSlash(expandHome(in: trimmed))) + + guard expanded.hasPrefix("/") else { + throw DiskCleanWhitelistValidationError.relativePath + } + + guard !Self.containsTraversalComponent(expanded) else { + throw DiskCleanWhitelistValidationError.traversal + } + + if validate, Self.isProtectedSystemRoot(expanded) { + throw DiskCleanWhitelistValidationError.protectedSystemRoot + } + + return DiskCleanWhitelistRule( + rawPattern: trimmed, + expandedPattern: expanded, + source: source + ) + } + + private func expandHome(in pattern: String) -> String { + if pattern == "~" || pattern == "$HOME" || pattern == "${HOME}" { + return homeDirectory + } + if pattern.hasPrefix("~/") { + return homeDirectory + String(pattern.dropFirst()) + } + if pattern.hasPrefix("$HOME/") { + return homeDirectory + String(pattern.dropFirst("$HOME".count)) + } + if pattern.hasPrefix("${HOME}/") { + return homeDirectory + String(pattern.dropFirst("${HOME}".count)) + } + return pattern + } + + private func append( + _ rule: DiskCleanWhitelistRule, + to rules: inout [DiskCleanWhitelistRule], + seenPatterns: inout Set + ) { + guard seenPatterns.insert(rule.expandedPattern).inserted else { + return + } + rules.append(rule) + } + + private static func normalizeSlashes(_ path: String) -> String { + var normalized = path + while normalized.contains("//") { + normalized = normalized.replacingOccurrences(of: "//", with: "/") + } + return normalized + } + + private static func stripTrailingSlash(_ path: String) -> String { + guard path.count > 1 else { return path } + return String(path.dropLast(path.hasSuffix("/") ? 1 : 0)) + } + + private static func containsControlCharacter(_ pattern: String) -> Bool { + pattern.unicodeScalars.contains { scalar in + scalar.value < 32 || scalar.value == 127 + } + } + + private static func containsTraversalComponent(_ pattern: String) -> Bool { + pattern.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } + + private static func containsGlob(_ pattern: String) -> Bool { + pattern.rangeOfCharacter(from: CharacterSet(charactersIn: "*?[")) != nil + } + + private static func fnmatch(pattern: String, path: String) -> Bool { + pattern.withCString { patternPointer in + path.withCString { pathPointer in + Darwin.fnmatch(patternPointer, pathPointer, 0) == 0 + } + } + } + + private static func isProtectedSystemRoot(_ pattern: String) -> Bool { + let path = stripTrailingGlob(from: stripTrailingSlash(pattern)) + if path == "/" { + return true + } + + let protectedRoots = [ + "/System", + "/bin", + "/sbin", + "/usr", + "/etc", + "/private", + "/var", + "/Library" + ] + + return protectedRoots.contains { root in + path == root || path.hasPrefix(root + "/") + } + } + + private static func stripTrailingGlob(from pattern: String) -> String { + guard let globRange = pattern.rangeOfCharacter(from: CharacterSet(charactersIn: "*?[")) else { + return pattern + } + return stripTrailingSlash(String(pattern[.. DiskCleanExecutor { + DiskCleanExecutor( + fileSystem: fileSystem, + safetyPolicy: DiskCleanSafetyPolicy( + homeDirectory: home, + whitelistStore: DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false) + ) + ) + } + + private func candidate( + id: String, + path: String, + size: Int64 = 1, + safety: DiskCleanSafetyStatus = .allowed + ) -> DiskCleanCandidate { + DiskCleanCandidate( + id: id, + ruleID: "rule", + choice: .cache, + title: id, + path: path, + sizeBytes: size, + safety: safety, + risk: .low + ) + } + + private func item( + _ path: String, + isSymlink: Bool = false, + resolvedSymlinkTarget: String? = nil + ) -> DiskCleanFileItem { + DiskCleanFileItem( + path: path, + isDirectory: true, + isSymlink: isSymlink, + resolvedSymlinkTarget: resolvedSymlinkTarget + ) + } + + private func outcome( + for candidateID: String, + in result: DiskCleanExecutionResult + ) -> DiskCleanExecutionItemResult.Outcome? { + result.itemResults.first { $0.candidateID == candidateID }?.outcome + } +} + +private struct TestRemoveError: Error {} + +private final class FakeDiskCleanExecutorFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable { + var items: [String: DiskCleanFileItem] + var removeErrors: [String: Error] + + private(set) var removedPaths: [String] = [] + + init( + items: [String: DiskCleanFileItem], + removeErrors: [String: Error] = [:] + ) { + self.items = items + self.removeErrors = removeErrors + } + + func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] { + [] + } + + func itemInfo(at path: String) throws -> DiskCleanFileItem? { + items[path] + } + + func sizeOfItem(at path: String) throws -> Int64 { + 0 + } + + func removeItem(at path: String) throws { + removedPaths.append(path) + if let error = removeErrors[path] { + throw error + } + items[path] = nil + } + + func deduplicatedParentChildPaths(_ paths: [String]) -> [String] { + paths + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanFileSystemTests.swift b/Plugins/DiskClean/Tests/DiskCleanFileSystemTests.swift new file mode 100644 index 0000000..8437458 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanFileSystemTests.swift @@ -0,0 +1,124 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanFileSystemTests: XCTestCase { + private var tempDirectory: URL! + private var fileSystem: LocalDiskCleanFileSystem! + + override func setUpWithError() throws { + try super.setUpWithError() + tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("DiskCleanFileSystemTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + fileSystem = LocalDiskCleanFileSystem(homeDirectory: tempDirectory.path) + } + + override func tearDownWithError() throws { + if let tempDirectory { + try? FileManager.default.removeItem(at: tempDirectory) + } + fileSystem = nil + tempDirectory = nil + try super.tearDownWithError() + } + + func testExpandsHomeAndGlobPatternsIncludingSpaces() throws { + try createFile("Library/Caches/Foo/data.bin") + try createFile("Library/Caches/With Space/data.bin") + try createFile("Library/Application Support/App/Profile 1/GPUCache/cache.bin") + try createFile("Library/Application Support/App/Profile 2/GPUCache/cache.bin") + + let cacheMatches = try fileSystem.expandPathPattern("~/Library/Caches/*").map(\.path) + XCTAssertEqual( + Set(cacheMatches), + [ + tempDirectory.appendingPathComponent("Library/Caches/Foo").path, + tempDirectory.appendingPathComponent("Library/Caches/With Space").path + ] + ) + + let nestedMatches = try fileSystem + .expandPathPattern("~/Library/Application Support/App/*/GPUCache") + .map(\.path) + XCTAssertEqual( + Set(nestedMatches), + [ + tempDirectory.appendingPathComponent("Library/Application Support/App/Profile 1/GPUCache").path, + tempDirectory.appendingPathComponent("Library/Application Support/App/Profile 2/GPUCache").path + ] + ) + } + + func testDirectChildGlobDoesNotRecursivelyScanMatchedDirectories() throws { + try createFile("Library/Caches/Foo/Nested/.keep") + for index in 0..<10_000 { + try createFile("Library/Caches/Foo/Nested/file-\(index).bin") + } + + let startedAt = Date() + let matches = try fileSystem.expandPathPattern("~/Library/Caches/*").map(\.path) + let elapsed = Date().timeIntervalSince(startedAt) + + XCTAssertEqual(matches, [tempDirectory.appendingPathComponent("Library/Caches/Foo").path]) + XCTAssertLessThan(elapsed, 0.05) + } + + func testDeduplicatesParentChildPathsKeepingParents() { + let parent = tempDirectory.appendingPathComponent("Library/Caches/Foo").path + let child = tempDirectory.appendingPathComponent("Library/Caches/Foo/Nested").path + let sibling = tempDirectory.appendingPathComponent("Library/Caches/FooBar").path + + XCTAssertEqual( + fileSystem.deduplicatedParentChildPaths([child, sibling, parent]), + [parent, sibling] + ) + } + + func testDeduplicatesParentChildPathsWhenPrefixSiblingSortsBetweenParentAndChild() { + let parent = tempDirectory.appendingPathComponent("a").path + let child = tempDirectory.appendingPathComponent("a/b").path + let prefixSibling = tempDirectory.appendingPathComponent("a-b").path + + XCTAssertEqual( + fileSystem.deduplicatedParentChildPaths([child, prefixSibling, parent]), + [parent, prefixSibling] + ) + } + + func testSizeCalculationIncludesNestedFiles() throws { + try createFile("Library/Caches/Foo/a.bin", bytes: 10) + try createFile("Library/Caches/Foo/Nested/b.bin", bytes: 25) + + let size = try fileSystem.sizeOfItem( + at: tempDirectory.appendingPathComponent("Library/Caches/Foo").path + ) + + XCTAssertEqual(size, 35) + } + + func testSymlinkMetadataReportsTargetWithoutFollowingIt() throws { + let linkURL = tempDirectory.appendingPathComponent("Library/Caches/SystemLink") + try FileManager.default.createDirectory( + at: linkURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: "/System") + + let item = try XCTUnwrap(fileSystem.itemInfo(at: linkURL.path)) + + XCTAssertEqual(item.path, linkURL.path) + XCTAssertTrue(item.isSymlink) + XCTAssertEqual(item.resolvedSymlinkTarget, "/System") + } + + private func createFile(_ relativePath: String, bytes: Int = 1) throws { + let url = tempDirectory.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = Data(repeating: 0x41, count: bytes) + try data.write(to: url) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift b/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift new file mode 100644 index 0000000..904157c --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanModelsTests: XCTestCase { + func testCleanupChoiceTitlesMatchFirstVersionScope() { + XCTAssertEqual(DiskCleanChoice.cache.title, "缓存清理") + XCTAssertEqual(DiskCleanChoice.developer.title, "开发者缓存清理") + XCTAssertEqual(DiskCleanChoice.browser.title, "浏览器缓存清理") + XCTAssertEqual(DiskCleanChoice.allCases, [.cache, .developer, .browser]) + } + + func testScanResultTotalsOnlyAllowedCandidates() { + let result = DiskCleanScanResult( + choices: [.cache], + candidates: [ + DiskCleanCandidate( + id: "a", + ruleID: "r1", + choice: .cache, + title: "A", + path: "/tmp/a", + sizeBytes: 10, + safety: .allowed, + risk: .low + ), + DiskCleanCandidate( + id: "b", + ruleID: "r2", + choice: .cache, + title: "B", + path: "/tmp/b", + sizeBytes: 20, + safety: .protected(reason: "protected"), + risk: .high + ) + ], + scannedAt: Date(timeIntervalSince1970: 0) + ) + + XCTAssertEqual(result.cleanableSizeBytes, 10) + XCTAssertEqual(result.cleanableCandidates.map(\.id), ["a"]) + XCTAssertEqual(result.protectedCount, 1) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift b/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift new file mode 100644 index 0000000..360ea3f --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift @@ -0,0 +1,169 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import DiskCleanPlugin + +@MainActor +final class DiskCleanPluginTests: XCTestCase { + func testMetadataIdentifiesDiskCleanPlugin() { + let plugin = DiskCleanPlugin(controller: FakeDiskCleanPluginController()) + + XCTAssertEqual(plugin.metadata.id, "disk-clean") + XCTAssertEqual(plugin.metadata.title, "磁盘清理") + } + + func testExpandedPanelExposesOnlyScanCleanAndOpenDetailsActions() throws { + let plugin = DiskCleanPlugin(controller: FakeDiskCleanPluginController()) + + plugin.handleAction(.setDisclosureExpanded(true)) + + let controls = try XCTUnwrap(plugin.primaryPanelState.detail?.primaryControls) + + XCTAssertEqual( + controls.map(\.id), + [ + DiskCleanPlugin.ControlID.scan, + DiskCleanPlugin.ControlID.clean, + DiskCleanPlugin.ControlID.openDetails + ] + ) + XCTAssertEqual(controls.map(\.actionTitle), ["扫描", "清理", "打开详情"]) + XCTAssertFalse(controls.contains { $0.id.hasPrefix("disk-clean-choice.") }) + XCTAssertFalse(controls.contains { $0.id == "disk-clean-test-mode" }) + } + + func testInvokingScanForwardsToController() { + let controller = FakeDiskCleanPluginController() + let plugin = DiskCleanPlugin(controller: controller) + + plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.scan)) + + XCTAssertEqual(controller.scanCallCount, 1) + } + + func testInvokingCleanForwardsAllCleanableCandidates() { + let controller = FakeDiskCleanPluginController() + let plugin = DiskCleanPlugin(controller: controller) + controller.snapshot = DiskCleanControllerSnapshot( + phase: .scanned, + selectedChoices: Set(DiskCleanChoice.allCases), + scanResult: DiskCleanScanResult( + choices: Set(DiskCleanChoice.allCases), + candidates: [ + DiskCleanCandidate( + id: "allowed", + ruleID: "rule", + choice: .cache, + title: "Cache", + path: "/Users/tester/Library/Caches/App", + sizeBytes: 10, + safety: .allowed, + risk: .low + ), + DiskCleanCandidate( + id: "protected", + ruleID: "rule", + choice: .cache, + title: "Cache", + path: "/Users/tester/Library/Keychains/login.keychain-db", + sizeBytes: 10, + safety: .protected(reason: "credentials"), + risk: .low + ) + ], + scannedAt: Date(timeIntervalSince1970: 0) + ), + executionResult: nil, + isResultStale: false, + errorMessage: nil + ) + + plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.clean)) + + XCTAssertEqual(controller.cleanSelectedCalls, [["allowed"]]) + } + + func testOpenDetailsActionUsesMenuBarStableActionID() throws { + let plugin = DiskCleanPlugin(controller: FakeDiskCleanPluginController()) + + plugin.handleAction(.setDisclosureExpanded(true)) + + let controls = try XCTUnwrap(plugin.primaryPanelState.detail?.primaryControls) + let openDetails = try XCTUnwrap( + controls.first { $0.id == DiskCleanPlugin.ControlID.openDetails } + ) + + XCTAssertEqual(DiskCleanPlugin.ControlID.openDetails, MenuBarContent.diskCleanOpenDetailsActionID) + switch openDetails.actionBehavior { + case .dismissBeforeHandling: + break + case .keepPresented: + XCTFail("Open details action should dismiss the menu before opening the window") + } + } + + func testPluginHostIncludesDiskCleanWhenProvided() { + let host = makePluginHostForTests(plugins: [DiskCleanPlugin(controller: FakeDiskCleanPluginController())]) + + XCTAssertTrue(host.featureManagementItems.contains { $0.id == "disk-clean" }) + } + + func testPluginHostExposesDiskCleanConfigurationWhenProvided() { + let host = makePluginHostForTests(plugins: [DiskCleanPlugin(controller: DiskCleanController())]) + + XCTAssertTrue(host.pluginConfigurationItems.contains { $0.id == "disk-clean" }) + } + + func testPresentPluginConfigurationSelectsDiskCleanSettings() { + let host = makePluginHostForTests(plugins: [DiskCleanPlugin(controller: DiskCleanController())]) + + host.presentPluginConfiguration(pluginID: "disk-clean") + + XCTAssertEqual(host.selectedSettingsDestination, .pluginConfiguration) + XCTAssertEqual(host.selectedFeatureSettingsPane, .configuration("disk-clean")) + } +} + +@MainActor +private final class FakeDiskCleanPluginController: DiskCleanControlling { + var onStateChange: (() -> Void)? + var snapshot = DiskCleanControllerSnapshot.initial + private(set) var scanCallCount = 0 + private(set) var canceledOperationCount = 0 + private(set) var selectedChoiceChanges: [(choice: DiskCleanChoice, isSelected: Bool)] = [] + private(set) var cleanSelectedCalls: [Set] = [] + + func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) { + selectedChoiceChanges.append((choice: choice, isSelected: isSelected)) + var nextChoices = snapshot.selectedChoices + if isSelected { + nextChoices.insert(choice) + } else { + nextChoices.remove(choice) + } + snapshot = DiskCleanControllerSnapshot( + phase: snapshot.phase, + selectedChoices: nextChoices, + scanResult: snapshot.scanResult, + executionResult: snapshot.executionResult, + isResultStale: snapshot.isResultStale, + errorMessage: snapshot.errorMessage + ) + onStateChange?() + } + + func scan() { + scanCallCount += 1 + onStateChange?() + } + + func cleanSelected(candidateIDs: Set) { + cleanSelectedCalls.append(candidateIDs) + onStateChange?() + } + + func cancelCurrentOperation() { + canceledOperationCount += 1 + onStateChange?() + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanRuleCatalogTests.swift b/Plugins/DiskClean/Tests/DiskCleanRuleCatalogTests.swift new file mode 100644 index 0000000..9185bb6 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanRuleCatalogTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanRuleCatalogTests: XCTestCase { + func testCatalogExposesExactlyFirstVersionChoices() { + let catalog = DiskCleanRuleCatalog.moleFirstVersion + + XCTAssertEqual(Set(catalog.rules.map(\.choice)), Set(DiskCleanChoice.allCases)) + XCTAssertEqual(DiskCleanChoice.allCases, [.cache, .developer, .browser]) + } + + func testCacheRulesContainRepresentativeMoleUserAppCloudAndOfficeTargets() { + let rules = DiskCleanRuleCatalog.moleFirstVersion.rules(for: .cache) + let haystack = joinedRuleContent(rules) + + XCTAssertTrue(haystack.contains("~/Library/Caches/*")) + XCTAssertTrue(haystack.contains("~/Library/Logs/*")) + XCTAssertTrue(haystack.contains("~/Library/Saved Application State/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/com.dropbox.*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/com.microsoft.OneDrive")) + XCTAssertTrue(haystack.contains("~/Library/Caches/com.microsoft.Word")) + XCTAssertTrue(haystack.contains("~/Library/Containers/com.apple.AppStore/Data/Library/Caches/*")) + } + + func testDeveloperRulesContainMoleDeveloperToolTargets() { + let rules = DiskCleanRuleCatalog.moleFirstVersion.rules(for: .developer) + let haystack = joinedRuleContent(rules) + + XCTAssertTrue(haystack.contains("xcodeDerivedData")) + XCTAssertTrue(haystack.contains("~/Library/Developer/CoreSimulator/Caches/*")) + XCTAssertTrue(haystack.contains("npmCache")) + XCTAssertTrue(haystack.contains("pnpmStore")) + XCTAssertTrue(haystack.contains("bunCache")) + XCTAssertTrue(haystack.contains("pipCache")) + XCTAssertTrue(haystack.contains("goBuildCache")) + XCTAssertTrue(haystack.contains("~/.cargo/registry/cache/*")) + XCTAssertTrue(haystack.contains("~/.docker/buildx/cache/*")) + XCTAssertTrue(haystack.contains("jetbrainsToolboxOldVersions")) + XCTAssertTrue(haystack.contains("~/Library/Caches/Cursor/*")) + XCTAssertTrue(haystack.contains("~/Library/Application Support/Codex/Cache/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/Homebrew/*")) + } + + func testBrowserRulesContainMoleBrowserTargetsAndDynamicRules() { + let rules = DiskCleanRuleCatalog.moleFirstVersion.rules(for: .browser) + let haystack = joinedRuleContent(rules) + + XCTAssertTrue(haystack.contains("~/Library/Caches/com.apple.Safari/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/Google/Chrome/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/company.thebrowser.Browser/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/BraveSoftware/Brave-Browser/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/com.microsoft.edgemac/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/Firefox/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/com.vivaldi.Vivaldi/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/Comet/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/com.kagi.kagimacOS/*")) + XCTAssertTrue(haystack.contains("~/Library/Caches/zen/*")) + XCTAssertTrue(haystack.contains("serviceWorkerCache")) + XCTAssertTrue(haystack.contains("oldBrowserVersions")) + } + + func testFirstVersionRulesDoNotRequireAdmin() { + XCTAssertFalse(DiskCleanRuleCatalog.moleFirstVersion.rules.contains { $0.requiresAdmin }) + } + + private func joinedRuleContent(_ rules: [DiskCleanRule]) -> String { + rules.flatMap { rule -> [String] in + var values = [rule.id, rule.title] + values += rule.targets.map { target in + switch target { + case let .path(path): + return path + case let .dynamic(dynamicRule): + return dynamicRule.rawValue + } + } + return values + } + .joined(separator: "\n") + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanSafetyPolicyTests.swift b/Plugins/DiskClean/Tests/DiskCleanSafetyPolicyTests.swift new file mode 100644 index 0000000..225b050 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanSafetyPolicyTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanSafetyPolicyTests: XCTestCase { + private let home = "/Users/tester" + + func testRejectsInvalidPathShapesButAllowsFirefoxDotDotNames() { + let policy = DiskCleanSafetyPolicy(homeDirectory: home) + + assertInvalid(policy.validatePathShape("")) + assertInvalid(policy.validatePathShape("relative/path")) + assertInvalid(policy.validatePathShape("/tmp/../etc")) + assertInvalid(policy.validatePathShape("\(home)/Library/Caches/Foo\nBar")) + + XCTAssertEqual( + policy.validatePathShape("\(home)/Library/Caches/Firefox/name..files/data"), + .allowed + ) + } + + func testRejectsCriticalSystemRootsAndUnsafeSymlinks() { + let policy = DiskCleanSafetyPolicy(homeDirectory: home) + + for path in ["/", "/System", "/usr/bin", "/etc", "/private", "/var/db", "/Library/Extensions"] { + assertInvalid(policy.safetyStatus(for: path)) + } + + assertInvalid( + policy.safetyStatus( + for: "\(home)/Library/Caches/SystemLink", + isSymlink: true, + resolvedSymlinkTarget: "/System" + ) + ) + } + + func testAllowsSafeUserCachePathAndReportsWhitelist() { + let whitelist = DiskCleanWhitelistStore( + homeDirectory: home, + includeDefaults: false, + customRules: ["\(home)/Library/Caches/KeepMe*"] + ) + let policy = DiskCleanSafetyPolicy(homeDirectory: home, whitelistStore: whitelist) + + XCTAssertEqual(policy.safetyStatus(for: "\(home)/Library/Caches/RegularApp"), .allowed) + + guard case let .whitelisted(rule) = policy.safetyStatus(for: "\(home)/Library/Caches/KeepMe/data") else { + return XCTFail("Expected whitelisted status") + } + XCTAssertEqual(rule, "\(home)/Library/Caches/KeepMe*") + } + + func testProtectsSensitiveDataPathsPortedFromMole() { + let policy = DiskCleanSafetyPolicy(homeDirectory: home) + + assertProtected(policy.safetyStatus(for: "\(home)/Library/Keychains/login.keychain-db")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Application Support/Google/Chrome/Default/Cookies")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Application Support/Firefox/Profiles/a.default/places.sqlite")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Application Support/com.apple.TCC/TCC.db")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Mobile Documents/com~apple~CloudDocs/file.txt")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Application Support/1Password/Data/vault.sqlite")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Application Support/com.nssurge.surge-mac/profiles.conf")) + assertProtected(policy.safetyStatus(for: "\(home)/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/config.yaml")) + } + + private func assertInvalid( + _ status: DiskCleanSafetyStatus, + file: StaticString = #filePath, + line: UInt = #line + ) { + if case .invalid = status { + return + } + XCTFail("Expected invalid status, got \(status)", file: file, line: line) + } + + private func assertProtected( + _ status: DiskCleanSafetyStatus, + file: StaticString = #filePath, + line: UInt = #line + ) { + if case .protected = status { + return + } + XCTFail("Expected protected status, got \(status)", file: file, line: line) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanScannerTests.swift b/Plugins/DiskClean/Tests/DiskCleanScannerTests.swift new file mode 100644 index 0000000..cf5c82c --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanScannerTests.swift @@ -0,0 +1,290 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanScannerTests: XCTestCase { + private let home = "/Users/tester" + + func testScanReturnsOnlySelectedChoicesAndNeverDeletesDuringDryRun() async throws { + let cachePath = "\(home)/Library/Caches/App" + let developerPath = "\(home)/Library/Caches/DeveloperTool" + let fileSystem = FakeDiskCleanFileSystem( + expansions: [ + "\(home)/Library/Caches/*": [item(cachePath), item(developerPath)], + "\(home)/Library/Developer/*": [item(developerPath)] + ], + sizes: [ + cachePath: 10, + developerPath: 20 + ] + ) + let catalog = DiskCleanRuleCatalog(rules: [ + DiskCleanRule( + id: "cache-rule", + choice: .cache, + title: "Cache", + risk: .low, + targets: [.path("\(home)/Library/Caches/*")] + ), + DiskCleanRule( + id: "developer-rule", + choice: .developer, + title: "Developer", + risk: .medium, + targets: [.path("\(home)/Library/Developer/*")] + ) + ]) + let scanner = makeScanner(catalog: catalog, fileSystem: fileSystem) + + let result = try await scanner.scan(choices: [.cache]) + + XCTAssertEqual(result.choices, [.cache]) + XCTAssertEqual(result.candidates.map(\.choice), [.cache, .cache]) + XCTAssertEqual(Set(result.candidates.map(\.path)), [cachePath, developerPath]) + XCTAssertTrue(fileSystem.removedPaths.isEmpty) + } + + func testScanPreservesWhitelistedProtectedAndInUseCandidatesAsNonCleanable() async throws { + let allowedPath = "\(home)/Library/Caches/Regular" + let whitelistedPath = "\(home)/Library/Caches/KeepMe/data" + let protectedPath = "\(home)/Library/Application Support/Google/Chrome/Default/Cookies" + let runningPath = "\(home)/Library/Caches/Running" + let fileSystem = FakeDiskCleanFileSystem( + expansions: [ + "\(home)/Library/Caches/Regular": [item(allowedPath)], + "\(home)/Library/Caches/KeepMe/data": [item(whitelistedPath)], + "\(home)/Library/Application Support/Google/Chrome/Default/Cookies": [item(protectedPath)], + "\(home)/Library/Caches/Running": [item(runningPath)] + ], + sizes: [ + allowedPath: 10, + whitelistedPath: 20, + protectedPath: 30, + runningPath: 40 + ] + ) + let whitelist = DiskCleanWhitelistStore( + homeDirectory: home, + includeDefaults: false, + customRules: ["\(home)/Library/Caches/KeepMe*"] + ) + let catalog = DiskCleanRuleCatalog(rules: [ + DiskCleanRule( + id: "allowed", + choice: .cache, + title: "Allowed", + risk: .low, + targets: [.path("\(home)/Library/Caches/Regular")] + ), + DiskCleanRule( + id: "whitelisted", + choice: .cache, + title: "Whitelisted", + risk: .low, + targets: [.path("\(home)/Library/Caches/KeepMe/data")] + ), + DiskCleanRule( + id: "protected", + choice: .cache, + title: "Protected", + risk: .high, + targets: [.path("\(home)/Library/Application Support/Google/Chrome/Default/Cookies")] + ), + DiskCleanRule( + id: "running", + choice: .cache, + title: "Running", + risk: .medium, + targets: [.path("\(home)/Library/Caches/Running")], + skipWhenProcessIsRunning: ["Google Chrome"] + ) + ]) + let scanner = makeScanner( + catalog: catalog, + fileSystem: fileSystem, + whitelistStore: whitelist, + processInspector: FakeDiskCleanProcessInspector(runningProcessNames: ["Google Chrome"]) + ) + + let result = try await scanner.scan(choices: [.cache]) + + XCTAssertEqual(result.cleanableCandidates.map(\.path), [allowedPath]) + XCTAssertEqual(result.cleanableSizeBytes, 10) + XCTAssertEqual(result.protectedCount, 2) + XCTAssertEqual(status(for: whitelistedPath, in: result), .whitelisted(rule: "\(home)/Library/Caches/KeepMe*")) + XCTAssertEqual(status(for: protectedPath, in: result), .protected(reason: "browser profile data is protected")) + XCTAssertEqual(status(for: runningPath, in: result), .inUse(processName: "Google Chrome")) + } + + func testScanDeduplicatesParentChildCandidatesBeforeSizingTotals() async throws { + let parent = "\(home)/Library/Caches/App" + let child = "\(home)/Library/Caches/App/Nested" + let sibling = "\(home)/Library/Caches/Other" + let fileSystem = FakeDiskCleanFileSystem( + expansions: [ + "\(home)/Library/Caches/App": [item(parent)], + "\(home)/Library/Caches/App/Nested": [item(child)], + "\(home)/Library/Caches/Other": [item(sibling)] + ], + sizes: [ + parent: 100, + child: 75, + sibling: 25 + ] + ) + let catalog = DiskCleanRuleCatalog(rules: [ + DiskCleanRule( + id: "dedupe", + choice: .cache, + title: "Dedupe", + risk: .low, + targets: [ + .path("\(home)/Library/Caches/App"), + .path("\(home)/Library/Caches/App/Nested"), + .path("\(home)/Library/Caches/Other") + ] + ) + ]) + let scanner = makeScanner(catalog: catalog, fileSystem: fileSystem) + + let result = try await scanner.scan(choices: [.cache]) + + XCTAssertEqual(result.candidates.map(\.path), [parent, sibling]) + XCTAssertEqual(result.cleanableSizeBytes, 125) + } + + func testScanEmitsProgressMessagesForRulesCandidatesAndCompletion() async throws { + let cachePath = "\(home)/Library/Caches/App" + let fileSystem = FakeDiskCleanFileSystem( + expansions: ["\(home)/Library/Caches/App": [item(cachePath)]], + sizes: [cachePath: 10] + ) + let catalog = DiskCleanRuleCatalog(rules: [ + DiskCleanRule( + id: "cache", + choice: .cache, + title: "Cache", + risk: .low, + targets: [.path("\(home)/Library/Caches/App")] + ) + ]) + let scanner = makeScanner(catalog: catalog, fileSystem: fileSystem) + let recorder = DiskCleanProgressRecorder() + + _ = try await scanner.scan(choices: [.cache]) { message in + await recorder.record(message) + } + + let messages = await recorder.messages() + let texts = messages.map(\.text) + + XCTAssertTrue(texts.contains("扫描范围:缓存清理")) + XCTAssertTrue(texts.contains("展开规则:Cache")) + XCTAssertTrue(texts.contains("匹配 1 项:Cache")) + XCTAssertTrue(texts.contains("可清理:\(cachePath)")) + XCTAssertEqual(texts.last, "扫描完成:1 项,1 项可清理") + } + + private func makeScanner( + catalog: DiskCleanRuleCatalog, + fileSystem: FakeDiskCleanFileSystem, + whitelistStore: DiskCleanWhitelistStore? = nil, + processInspector: DiskCleanProcessInspecting = FakeDiskCleanProcessInspector() + ) -> DiskCleanScanner { + DiskCleanScanner( + ruleCatalog: catalog, + fileSystem: fileSystem, + safetyPolicy: DiskCleanSafetyPolicy( + homeDirectory: home, + whitelistStore: whitelistStore ?? DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false) + ), + processInspector: processInspector, + now: { Date(timeIntervalSince1970: 0) } + ) + } + + private func status(for path: String, in result: DiskCleanScanResult) -> DiskCleanSafetyStatus? { + result.candidates.first { $0.path == path }?.safety + } + + private func item(_ path: String, isDirectory: Bool = true) -> DiskCleanFileItem { + DiskCleanFileItem( + path: path, + isDirectory: isDirectory, + isSymlink: false, + resolvedSymlinkTarget: nil + ) + } +} + +private final class FakeDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable { + let expansions: [String: [DiskCleanFileItem]] + let sizes: [String: Int64] + + private(set) var removedPaths: [String] = [] + + init( + expansions: [String: [DiskCleanFileItem]], + sizes: [String: Int64] + ) { + self.expansions = expansions + self.sizes = sizes + } + + func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] { + expansions[pattern] ?? [] + } + + func itemInfo(at path: String) throws -> DiskCleanFileItem? { + expansions.values + .flatMap { $0 } + .first { $0.path == path } + } + + func sizeOfItem(at path: String) throws -> Int64 { + sizes[path] ?? 0 + } + + func removeItem(at path: String) throws { + removedPaths.append(path) + } + + func deduplicatedParentChildPaths(_ paths: [String]) -> [String] { + let uniqueSortedPaths = Array(Set(paths)).sorted { lhs, rhs in + if lhs.count == rhs.count { + return lhs < rhs + } + return lhs.count < rhs.count + } + + var kept: [String] = [] + for path in uniqueSortedPaths { + if kept.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) { + continue + } + kept.append(path) + } + + return kept.sorted() + } +} + +private struct FakeDiskCleanProcessInspector: DiskCleanProcessInspecting { + var runningProcessNames: Set = [] + + func runningProcessName(from names: [String]) -> String? { + names.first { runningProcessNames.contains($0) } + } +} + +private actor DiskCleanProgressRecorder { + private var recordedMessages: [DiskCleanScanLogMessage] = [] + + func record(_ message: DiskCleanScanLogMessage) { + recordedMessages.append(message) + } + + func messages() -> [DiskCleanScanLogMessage] { + recordedMessages + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanWhitelistStoreTests.swift b/Plugins/DiskClean/Tests/DiskCleanWhitelistStoreTests.swift new file mode 100644 index 0000000..961f39b --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanWhitelistStoreTests.swift @@ -0,0 +1,75 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanWhitelistStoreTests: XCTestCase { + private let home = "/Users/tester" + + func testDefaultRulesIncludeMoleProtectedCleanEntries() { + let store = DiskCleanWhitelistStore(homeDirectory: home) + let rules = Set(store.expandedRules().map(\.expandedPattern)) + + XCTAssertTrue(rules.contains("\(home)/Library/Caches/ms-playwright*")) + XCTAssertTrue(rules.contains("\(home)/.cache/huggingface*")) + XCTAssertTrue(rules.contains("\(home)/.m2/repository/*")) + XCTAssertTrue(rules.contains("\(home)/.gradle/caches/*")) + XCTAssertTrue(rules.contains("\(home)/.gradle/daemon/*")) + XCTAssertTrue(rules.contains("\(home)/.ollama/models/*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/com.nssurge.surge-mac/*")) + XCTAssertTrue(rules.contains("\(home)/Library/Application Support/com.nssurge.surge-mac/*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/org.R-project.R/R/renv/*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/JetBrains*")) + XCTAssertTrue(rules.contains("\(home)/Library/Application Support/JetBrains*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/com.apple.FontRegistry*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/com.apple.spotlight*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/com.apple.Spotlight*")) + XCTAssertTrue(rules.contains("\(home)/Library/Caches/CloudKit*")) + XCTAssertTrue(rules.contains("\(home)/Library/Mobile Documents*")) + XCTAssertTrue(rules.contains(DiskCleanWhitelistStore.finderMetadataSentinel)) + } + + func testExpandsHomeFormsAndCollapsesDuplicates() { + let store = DiskCleanWhitelistStore( + homeDirectory: home, + includeDefaults: false, + customRules: [ + "~/Library/Caches/Foo", + "$HOME/Library/Caches/Foo", + "${HOME}/Library/Caches/Foo", + "\(home)/Library/Caches/Foo" + ] + ) + + XCTAssertEqual(store.expandedRules().map(\.expandedPattern), ["\(home)/Library/Caches/Foo"]) + } + + func testRejectsInvalidCustomRules() { + let store = DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false) + + XCTAssertThrowsError(try store.validateCustomRule("Library/Caches/Foo")) + XCTAssertThrowsError(try store.validateCustomRule("\(home)/Library/../Secrets")) + XCTAssertThrowsError(try store.validateCustomRule("\(home)/Library/Caches/Foo\nBar")) + XCTAssertThrowsError(try store.validateCustomRule("/")) + XCTAssertThrowsError(try store.validateCustomRule("/System/Library/Caches")) + XCTAssertThrowsError(try store.validateCustomRule("/Library/Extensions")) + } + + func testMatchesGlobParentChildAndFinderMetadataSentinel() { + let store = DiskCleanWhitelistStore( + homeDirectory: home, + includeDefaults: false, + customRules: [ + "\(home)//Library//Application Support/Google/Chrome/Default/Service Worker/CacheStorage", + "\(home)/Library/Caches/org.R-project.R/R/renv/*", + "\(home)/Library/Caches/Customer/*", + DiskCleanWhitelistStore.finderMetadataSentinel + ] + ) + + XCTAssertNotNil(store.matchingRule(for: "\(home)/Library/Application Support/Google/Chrome/Default//Service Worker/CacheStorage")) + XCTAssertNotNil(store.matchingRule(for: "\(home)/Library/Caches/org.R-project.R")) + XCTAssertNotNil(store.matchingRule(for: "\(home)/Library/Caches/Customer/cbbim-w-prod.mat")) + XCTAssertNotNil(store.matchingRule(for: "\(home)/Documents/.DS_Store")) + XCTAssertNil(store.matchingRule(for: "\(home)/Library/Caches/Other/extra.dat")) + } +} diff --git a/Plugins/DiskClean/plugin.json b/Plugins/DiskClean/plugin.json new file mode 100644 index 0000000..f6b1a00 --- /dev/null +++ b/Plugins/DiskClean/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "disk-clean", + "displayName": "磁盘清理", + "summary": "缓存、开发者缓存和浏览器缓存清理", + "localizedMetadata": { + "ar": { + "displayName": "تنظيف القرص", + "summary": "تنظيف ذاكرة التخزين المؤقت، وذاكرة التخزين المؤقت للمطورين، وذاكرة التخزين المؤقت للمتصفح." + }, + "de": { + "displayName": "Datenträgerbereinigung", + "summary": "Caches, Entwickler-Caches und Browser-Caches bereinigen." + }, + "en": { + "displayName": "Disk Cleanup", + "summary": "Clean caches, developer caches, and browser caches." + }, + "es": { + "displayName": "Limpieza de disco", + "summary": "Limpiar cachés, cachés de desarrolladores y cachés de navegador." + }, + "fr": { + "displayName": "Nettoyage du disque", + "summary": "Nettoyer les caches, les caches des développeurs et les caches du navigateur." + }, + "ja": { + "displayName": "ディスククリーンアップ", + "summary": "キャッシュ、開発者キャッシュ、ブラウザ キャッシュをクリーンアップします。" + }, + "ko": { + "displayName": "디스크 정리", + "summary": "캐시, 개발자 캐시 및 브라우저 캐시를 정리합니다." + }, + "pt": { + "displayName": "Limpeza de disco", + "summary": "Limpe caches, caches de desenvolvedores e caches de navegador." + }, + "ru": { + "displayName": "Очистка диска", + "summary": "Очистка кеша, кеша разработчика и кеша браузера." + }, + "zh-Hans": { + "displayName": "磁盘清理", + "summary": "缓存、开发者缓存和浏览器缓存清理" + }, + "zh-Hant": { + "displayName": "磁碟清理", + "summary": "緩存、開發者緩存和瀏覽器緩存清理" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "DiskClean.bundle", + "factoryClass": "DiskCleanPlugin.DiskCleanPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "DiskCleanPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "storage" +} diff --git a/Plugins/DisplayBrightness/Bundle/DisplayBrightnessPluginBundleEntrypoint.swift b/Plugins/DisplayBrightness/Bundle/DisplayBrightnessPluginBundleEntrypoint.swift new file mode 100644 index 0000000..921e98c --- /dev/null +++ b/Plugins/DisplayBrightness/Bundle/DisplayBrightnessPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import DisplayBrightnessPlugin + +private let displayBrightnessPluginFactoryAnchor: Any.Type = DisplayBrightnessPluginFactory.self diff --git a/Plugins/DisplayBrightness/Resources/Localizable.xcstrings b/Plugins/DisplayBrightness/Resources/Localizable.xcstrings new file mode 100644 index 0000000..679b94e --- /dev/null +++ b/Plugins/DisplayBrightness/Resources/Localizable.xcstrings @@ -0,0 +1,2704 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سطوع العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigehelligkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display Brightness" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Brillo de la pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Luminosité de l'écran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイの明るさ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 밝기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Brilho da tela" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Яркость дисплея" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示器亮度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示器亮度" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ضبط السطوع بسرعة لكل شاشة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passen Sie die Helligkeit für jedes Display schnell an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quickly adjust brightness for each display." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajusta rápidamente el brillo de cada pantalla." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajustez rapidement la luminosité de chaque écran." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各ディスプレイの明るさをすばやく調整します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "각 디스플레이의 밝기를 빠르게 조정하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajuste rapidamente o brilho de cada tela." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Быстрая настройка яркости для каждого дисплея." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速调节每个显示器的亮度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快速調節每個顯示器的亮度" + } + } + } + }, + "panel.subtitle.noDisplays": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف أي شاشات قابلة للتعديل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine anpassbaren Displays erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No adjustable displays detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectaron pantallas ajustables" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun affichage réglable détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "調整可能なディスプレイが検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조정 가능한 디스플레이가 감지되지 않았습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma tela ajustável detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настраиваемые дисплеи не обнаружены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到可调节亮度的显示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到可調節亮度的顯示器" + } + } + } + }, + "panel.subtitle.displayCountFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d wird angezeigt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d displays" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d muestra" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d affiche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d が表示されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 표시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d é exibido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображается %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个显示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個顯示器" + } + } + } + }, + "shortcut.direction.decrease": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقليل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abnahme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Decrease" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disminución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diminuer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "減少" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "감소" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Diminuir" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уменьшение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "降低" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "降低" + } + } + } + }, + "shortcut.direction.increase": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "زيادة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erhöhen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Increase" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aumentar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Augmentation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "増加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "증가" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aumentar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Увеличение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "增加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "增加" + } + } + } + }, + "shortcut.titleFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ السطوع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Helligkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ Brightness" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ Brillo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ Luminosité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 明るさ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 밝기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ Brilho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ Яркость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@亮度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@亮度" + } + } + } + }, + "shortcut.descriptionFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ سطوع الشاشة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Displayhelligkeit." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ display brightness." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ brillo de la pantalla." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Luminosité de l'écran %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ ディスプレイの明るさ。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 디스플레이 밝기." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ brilho da tela." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ яркость дисплея." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@显示器亮度。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@顯示器亮度。" + } + } + } + }, + "shortcut.settingsGroupTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختصارات السطوع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Helligkeitsverknüpfungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Brightness Shortcuts" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Atajos de brillo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Raccourcis de luminosité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "明るさのショートカット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "밝기 단축키" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atalhos de brilho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ярлыки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "亮度快捷键" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "亮度快速鍵" + } + } + } + }, + "shortcut.settingsGroupDescription": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ضبط سطوع الشاشة باستخدام النطاق المستهدف المحدد." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passen Sie die Displayhelligkeit mithilfe des ausgewählten Zielbereichs an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjust display brightness using the selected target scope." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajusta el brillo de la pantalla usando el alcance objetivo seleccionado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajustez la luminosité de l'écran à l'aide de la portée cible sélectionnée." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したターゲット スコープを使用してディスプレイの明るさを調整します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 대상 범위를 사용하여 디스플레이 밝기를 조정합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajuste o brilho da tela usando o escopo de destino selecionado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отрегулируйте яркость дисплея, используя выбранную целевую область." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按所选作用范围调整显示器亮度。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按所選作用範圍調整顯示器亮度。" + } + } + } + }, + "settings.shortcutTarget.sectionTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النطاق المستهدف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zielbereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Target Scope" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcance objetivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Portée cible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターゲット範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대상 범위" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escopo alvo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Целевой объем" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "作用范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "作用範圍" + } + } + } + }, + "settings.shortcutTarget.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "هدف الاختصار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verknüpfungsziel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shortcut Target" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Destino del acceso directo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cible du raccourci" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ショートカットターゲット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "바로가기 대상" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alvo do atalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цель быстрого доступа" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快捷键目标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快速鍵目標" + } + } + } + }, + "settings.shortcutTarget.followsMouse": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اتبع الماوس" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maus folgen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Follow Mouse" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seguir ratón" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suivre la souris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マウスをフォローしてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마우스 따라가기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Seguir o Rato" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Следовать за мышью" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跟随鼠标" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跟隨滑鼠" + } + } + } + }, + "settings.shortcutTarget.allDisplays": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جميع شاشات العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All Displays" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todas las pantallas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tous les affichages" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのディスプレイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 디스플레이" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Todas as telas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все дисплеи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所有显示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所有顯示器" + } + } + } + }, + "settings.shortcutTarget.followsMouseDescription": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يضبط العرض أسفل المؤشر فقط." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passt nur die Anzeige unter dem Zeiger an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only adjusts the display under the pointer." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo ajusta la visualización debajo del puntero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajuste uniquement l'affichage sous le pointeur." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポインタの下の表示のみを調整します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터 아래의 디스플레이만 조정합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajusta apenas a exibição sob o ponteiro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настраивает только отображение под указателем." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只调整光标所在屏幕。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只調整光標所在螢幕。" + } + } + } + }, + "settings.shortcutTarget.allDisplaysDescription": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يضبط كل شاشة عرض يمكن التحكم فيها معًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Passt alle steuerbaren Displays gemeinsam an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjusts every controllable display together." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajusta todas las pantallas controlables juntas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajuste tous les écrans contrôlables ensemble." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての制御可能なディスプレイをまとめて調整します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제어 가능한 모든 디스플레이를 함께 조정합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajusta todos os monitores controláveis juntos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настраивает каждый управляемый дисплей одновременно." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步调整所有可控显示器。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步調整所有可控顯示器。" + } + } + } + }, + "displayDisable.action.disable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف تشغيل شاشة العرض المدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Integrierte Anzeige ausschalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn Off Built-in Display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Apagar la pantalla integrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver l'affichage intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイをオフにする" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이 끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desativar tela integrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить встроенный дисплей" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭内建显示屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉內建顯示螢幕" + } + } + } + }, + "displayDisable.action.restore": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استعادة شاشة العرض المدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingebautes Display wiederherstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Restore Built-in Display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restaurar pantalla integrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restaurer l'affichage intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイを復元" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이 복원" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Restaurar exibição integrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановить встроенный дисплей" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复内建显示屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復內建顯示螢幕" + } + } + } + }, + "error.displayUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم قطع اتصال الشاشة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anzeige wurde getrennt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The display has disconnected." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La pantalla se ha desconectado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'écran s'est déconnecté." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイが切断されました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 연결이 끊어졌습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A tela foi desconectada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дисплей отключился." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示器已断开连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示器已斷開連接" + } + } + } + }, + "error.brightnessUnavailableFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ لا يمكنه قراءة السطوع الآن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ kann die Helligkeit derzeit nicht lesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ cannot read brightness right now" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ no puede leer el brillo en este momento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ne peut pas lire la luminosité pour le moment" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は現在明るさを読み取ることができません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은 현재 밝기를 읽을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ não é possível ler o brilho no momento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ сейчас не может определить яркость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 当前无法读取亮度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 當前無法讀取亮度" + } + } + } + }, + "error.nativeAPINotAvailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "واجهة برمجة تطبيقات سطوع النظام غير متاحة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemhelligkeits-API ist nicht verfügbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System brightness API is unavailable." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La API de brillo del sistema no está disponible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'API de luminosité du système n'est pas disponible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム輝度 API は使用できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 밝기 API를 사용할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A API de brilho do sistema não está disponível." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "API яркости системы недоступен." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统亮度接口不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統亮度接口不可用" + } + } + } + }, + "error.genericBrightnessUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا تستطيع الشاشة قراءة السطوع الآن" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Display kann derzeit die Helligkeit nicht ablesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The display cannot read brightness right now" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La pantalla no puede leer el brillo en este momento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'écran ne peut pas lire la luminosité pour le moment" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイは現在明るさを読み取ることができません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 디스플레이에서 밝기를 읽을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A tela não consegue ler o brilho no momento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "На дисплее сейчас невозможно определить яркость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示器当前无法读取亮度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示器當前無法讀取亮度" + } + } + } + }, + "error.i2cUnavailableFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ لا يدعم DDC/CI" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ unterstützt DDC/CI nicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ does not support DDC/CI" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ no es compatible con DDC/CI" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ ne prend pas en charge DDC/CI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は DDC/CI をサポートしていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은(는) DDC/CI을(를) 지원하지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ não é compatível com DDC/CI" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ не поддерживает DDC/CI" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 不支持 DDC/CI" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 不支持 DDC/CI" + } + } + } + }, + "error.unsupportedReplyFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ قام بإرجاع بيانات سطوع غير صالحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ hat ungültige Helligkeitsdaten zurückgegeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ returned invalid brightness data" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ devolvió datos de brillo no válidos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ a renvoyé des données de luminosité non valides" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ が無効な輝度データを返しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@이 잘못된 밝기 데이터를 반환했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ retornou dados de brilho inválidos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ вернул неверные данные яркости" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 返回了无效亮度数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 返回了無效亮度數據" + } + } + } + }, + "error.ddcWriteFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ فشلت كتابة DDC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ DDC-Schreiben fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ DDC write failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ Error al escribir DDC" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ Échec de l'écriture DDC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ DDC 書き込みに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ DDC 쓰기 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ Falha na gravação do DDC" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ Ошибка записи DDC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ DDC 写入失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ DDC 寫入失敗" + } + } + } + }, + "error.softwareBrightnessFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تعديل سطوع البرنامج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Software-Helligkeitsanpassung fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Software brightness adjustment failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en el ajuste de brillo del software" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du réglage logiciel de la luminosité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソフトウェアの明るさ調整に失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "소프트웨어 밝기 조정 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha no ajuste de brilho do software" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось выполнить программную настройку яркости" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "软件亮度调节失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "軟件亮度調節失敗" + } + } + } + }, + "error.nativeBrightnessWriteFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت كتابة السطوع الأصلي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreiben der nativen Helligkeit fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Native brightness write failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al escribir con brillo nativo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'écriture de la luminosité native" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブ輝度の書き込みに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 밝기 쓰기 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na gravação do brilho nativo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка записи встроенной яркости" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原生亮度写入失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原生亮度寫入失敗" + } + } + } + }, + "error.adjustFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التعديل: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anpassung fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjustment failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de ajuste: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du réglage : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "調整に失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조정 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha no ajuste: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось выполнить настройку: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "调节失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "調節失敗:%@" + } + } + } + }, + "displayDisable.error.beginConfiguration": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر بدء تكوين العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigekonfiguration konnte nicht gestartet werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not begin display configuration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo iniciar la configuración de visualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de démarrer la configuration de l'affichage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ構成を開始できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 구성을 시작할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível iniciar a configuração do monitor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось начать настройку дисплея" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法开始显示器配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法開始顯示器配置" + } + } + } + }, + "displayDisable.error.completeConfiguration": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تطبيق تكوين العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigekonfiguration konnte nicht angewendet werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not apply display configuration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo aplicar la configuración de visualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'appliquer la configuration d'affichage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ構成を適用できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 구성을 적용할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível aplicar a configuração de exibição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось применить конфигурацию отображения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法提交显示器配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法提交顯示器配置" + } + } + } + }, + "displayDisable.error.configureDisplay": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تغيير حالة العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigestatus konnte nicht geändert werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not change display state" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo cambiar el estado de visualización" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de modifier l'état d'affichage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示状態を変更できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시 상태를 변경할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível alterar o estado de exibição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось изменить состояние отображения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法切换显示器状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法切換顯示器狀態" + } + } + } + }, + "displayDisable.message.disableFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر إيقاف تشغيل الشاشة المدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das integrierte Display konnte nicht ausgeschaltet werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not turn off the built-in display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo apagar la pantalla incorporada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'éteindre l'écran intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイをオフにできませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이를 끌 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível desligar a tela integrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось отключить встроенный дисплей" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭内建显示屏失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉內建顯示螢幕失敗" + } + } + } + }, + "displayDisable.message.disableFailedRestored": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر إيقاف تشغيل الشاشة المضمنة؛ حاولت استعادته" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das integrierte Display konnte nicht ausgeschaltet werden; habe versucht, es wiederherzustellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not turn off the built-in display; tried to restore it" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo apagar la pantalla incorporada; intenté restaurarlo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'éteindre l'écran intégré ; j'ai essayé de le restaurer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイをオフにできませんでした。復元しようとしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이를 끌 수 없습니다. 복원을 시도했습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível desligar a tela integrada; tentei restaurá-lo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось отключить встроенный дисплей; пытался восстановить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭内建显示屏失败,已尝试恢复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉內建顯示螢幕失敗,已嘗試恢復" + } + } + } + }, + "displayDisable.message.mirrorUnsupported": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يتم دعم إيقاف تشغيل الشاشة المدمجة أثناء عكس شاشات العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ausschalten des integrierten Displays wird bei gespiegelten Displays nicht unterstützt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Turning off the built-in display is not supported while displays are mirrored" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se permite apagar la pantalla integrada mientras las pantallas están reflejadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La désactivation de l'écran intégré n'est pas prise en charge lorsque les écrans sont en miroir" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイがミラーリングされている間、内蔵ディスプレイをオフにすることはサポートされていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이가 미러링되는 동안에는 내장 디스플레이 끄기가 지원되지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível desativar a tela integrada enquanto as telas são espelhadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключение встроенного дисплея не поддерживается, пока дисплеи зеркально отображены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "镜像显示时暂不支持关闭内建显示屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鏡像顯示時暫不支持關閉內建顯示螢幕" + } + } + } + }, + "displayDisable.message.needsExternalDisplay": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بتوصيل شاشة خارجية قبل إيقاف تشغيل الشاشة المدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen Sie ein externes Display an, bevor Sie das integrierte Display ausschalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect an external display before turning off the built-in display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conecte una pantalla externa antes de apagar la pantalla incorporada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectez un écran externe avant d'éteindre l'écran intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイの電源をオフにする前に、外部ディスプレイを接続してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이를 끄기 전에 외부 디스플레이를 연결하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conecte um monitor externo antes de desligar o monitor integrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключите внешний дисплей перед выключением встроенного дисплея" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "连接外接显示器后可关闭内建显示屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連接外接顯示器後可關閉內建顯示螢幕" + } + } + } + }, + "displayDisable.message.noBuiltInDisplay": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف شاشة عرض مدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein integriertes Display erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No built-in display detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectó ninguna pantalla integrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun écran intégré détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイが検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이가 감지되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma tela integrada detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный дисплей не обнаружен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到内建显示屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到內建顯示螢幕" + } + } + } + }, + "displayDisable.message.restoreFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذرت استعادة شاشة العرض المضمنة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das integrierte Display konnte nicht wiederhergestellt werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not restore the built-in display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo restaurar la pantalla integrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de restaurer l'écran intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ディスプレイを復元できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 디스플레이를 복원할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível restaurar a tela integrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось восстановить встроенный дисплей" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "恢复内建显示屏失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "恢復內建顯示螢幕失敗" + } + } + } + }, + "displayDisable.unsupported": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يدعم هذا النظام إيقاف تشغيل الشاشة المدمجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieses System unterstützt nicht das Ausschalten des integrierten Displays" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This system does not support turning off the built-in display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este sistema no admite el apagado de la pantalla integrada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce système ne prend pas en charge la désactivation de l'écran intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このシステムは内蔵ディスプレイのオフをサポートしていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 시스템은 내장 디스플레이 끄기를 지원하지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este sistema não suporta desligar a tela integrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эта система не поддерживает отключение встроенного дисплея" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前系统不支持关闭内建显示屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前系統不支持關閉內建顯示螢幕" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessBackends.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessBackends.swift new file mode 100644 index 0000000..545ca24 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessBackends.swift @@ -0,0 +1,695 @@ +import AppKit +import CoreGraphics +import Foundation +import MacToolsPluginKit + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} + +final class SystemDisplayBrightnessBackendBuilder: DisplayBrightnessBackendBuilding { + typealias Arm64ServiceResolver = ([DisplayInfo]) -> [CGDirectDisplayID: CFTypeRef] + typealias AppleBackendFactory = (DisplayInfo) -> (any DisplayBrightnessBackend)? + typealias DDCBackendFactory = (DisplayInfo, CFTypeRef?) -> (any DisplayBrightnessBackend)? + typealias SoftwareBackendFactory = (DisplayInfo) -> (any DisplayBrightnessBackend)? + + private enum BackendCandidate: Equatable { + case appleNative + case ddc + case gamma + case shade + + init(kind: DisplayBrightnessBackendKind) { + switch kind { + case .appleNative: + self = .appleNative + case .ddc: + self = .ddc + case .gamma: + self = .gamma + case .shade: + self = .shade + } + } + } + + private let resolveArm64Services: Arm64ServiceResolver + private let appleFactory: AppleBackendFactory + private let ddcFactory: DDCBackendFactory + private let gammaFactory: SoftwareBackendFactory + private let shadeFactory: SoftwareBackendFactory + + init( + resolveArm64Services: @escaping Arm64ServiceResolver = Arm64DDCServiceMatcher.resolveServices, + appleFactory: AppleBackendFactory? = nil, + ddcFactory: DDCBackendFactory? = nil, + gammaFactory: SoftwareBackendFactory? = nil, + shadeFactory: SoftwareBackendFactory? = nil + ) { + let displayServicesBridge = DisplayServicesBrightnessBridge() + + self.resolveArm64Services = resolveArm64Services + self.appleFactory = appleFactory ?? { display in + let backend = AppleNativeBrightnessBackend( + display: display, + bridge: displayServicesBridge + ) + + do { + _ = try backend.readBrightness() + return backend + } catch { + return nil + } + } + self.ddcFactory = ddcFactory ?? { display, matchedService in + if let transport = Arm64DDCTransport(display: display, service: nil) { + return DDCBrightnessBackend(display: display, transport: transport) + } + + if let matchedService, + let transport = Arm64DDCTransport(display: display, service: matchedService) { + return DDCBrightnessBackend(display: display, transport: transport) + } + + if let transport = IntelDDCTransport(display: display) { + return DDCBrightnessBackend(display: display, transport: transport) + } + + return nil + } + self.gammaFactory = gammaFactory ?? { display in + GammaBrightnessBackend(display: display) + } + self.shadeFactory = shadeFactory ?? { display in + ShadeBrightnessBackend(display: display) + } + } + + func backends( + for displays: [DisplayInfo], + previous: [CGDirectDisplayID: any DisplayBrightnessBackend] + ) -> [CGDirectDisplayID: any DisplayBrightnessBackend] { + var arm64Services: [CGDirectDisplayID: CFTypeRef]? + var result: [CGDirectDisplayID: any DisplayBrightnessBackend] = [:] + + for display in displays { + for candidate in backendCandidates(for: display) { + guard let backend = makeBackend( + candidate, + for: display, + in: displays, + previous: previous, + cache: &arm64Services + ) else { + continue + } + + result[display.id] = backend + break + } + } + + return result + } + + func fallbackBackend( + after failedBackend: any DisplayBrightnessBackend, + for display: DisplayInfo, + previous: [CGDirectDisplayID: any DisplayBrightnessBackend] + ) -> (any DisplayBrightnessBackend)? { + let candidates = backendCandidates(for: display) + guard let failedIndex = candidates.firstIndex(of: BackendCandidate(kind: failedBackend.kind)) else { + return nil + } + + var arm64Services: [CGDirectDisplayID: CFTypeRef]? + for candidate in candidates.dropFirst(failedIndex + 1) { + guard let backend = makeBackend( + candidate, + for: display, + in: [display], + previous: previous.filter { $0.key != display.id }, + cache: &arm64Services + ) else { + continue + } + + return backend + } + + return nil + } + + private func backendCandidates(for display: DisplayInfo) -> [BackendCandidate] { + if display.isBuiltin { + return [.appleNative, .gamma, .shade] + } + + if display.supportsAppleNativeBrightness { + return [.appleNative, .ddc, .gamma, .shade] + } + + return [.ddc, .gamma, .shade] + } + + private func makeBackend( + _ candidate: BackendCandidate, + for display: DisplayInfo, + in displays: [DisplayInfo], + previous: [CGDirectDisplayID: any DisplayBrightnessBackend], + cache arm64Services: inout [CGDirectDisplayID: CFTypeRef]? + ) -> (any DisplayBrightnessBackend)? { + switch candidate { + case .appleNative: + return appleBackend(for: display, previous: previous) + case .ddc: + return ddcBackend( + for: display, + in: displays, + previous: previous, + cache: &arm64Services + ) + case .gamma: + return softwareBackend( + kind: .gamma, + label: "Gamma", + for: display, + previous: previous, + factory: gammaFactory + ) + case .shade: + return softwareBackend( + kind: .shade, + label: "Shade", + for: display, + previous: previous, + factory: shadeFactory + ) + } + } + + private func appleBackend( + for display: DisplayInfo, + previous: [CGDirectDisplayID: any DisplayBrightnessBackend] + ) -> (any DisplayBrightnessBackend)? { + guard display.isAppleDisplay else { + return nil + } + + guard let backend = reuse(previous[display.id], kind: .appleNative, display: display) + ?? appleFactory(display) else { + return nil + } + + DisplayBrightnessLog.backend.debug( + "selected Apple native brightness backend for \(display.name, privacy: .public)" + ) + return backend + } + + private func softwareBackend( + kind: DisplayBrightnessBackendKind, + label: String, + for display: DisplayInfo, + previous: [CGDirectDisplayID: any DisplayBrightnessBackend], + factory: SoftwareBackendFactory + ) -> (any DisplayBrightnessBackend)? { + guard let backend = reuse(previous[display.id], kind: kind, display: display) + ?? factory(display) else { + return nil + } + + DisplayBrightnessLog.backend.info( + "using \(label, privacy: .public) software brightness fallback for \(display.name, privacy: .public)" + ) + return backend + } + + private func ddcBackend( + for display: DisplayInfo, + in displays: [DisplayInfo], + previous: [CGDirectDisplayID: any DisplayBrightnessBackend], + cache arm64Services: inout [CGDirectDisplayID: CFTypeRef]? + ) -> (any DisplayBrightnessBackend)? { + guard let backend = reuse(previous[display.id], kind: .ddc, display: display) + ?? ddcFactory(display, resolvedArm64Service(for: display, in: displays, cache: &arm64Services)) else { + return nil + } + + DisplayBrightnessLog.backend.debug( + "selected DDC brightness backend for \(display.name, privacy: .public)" + ) + return backend + } + + private func resolvedArm64Service( + for display: DisplayInfo, + in displays: [DisplayInfo], + cache arm64Services: inout [CGDirectDisplayID: CFTypeRef]? + ) -> CFTypeRef? { + if arm64Services == nil { + arm64Services = resolveArm64Services(displays) + } + + return arm64Services?[display.id] + } + + private func reuse( + _ previous: (any DisplayBrightnessBackend)?, + kind: DisplayBrightnessBackendKind, + display: DisplayInfo + ) -> (any DisplayBrightnessBackend)? { + guard let backend = previous, backend.kind == kind else { + return nil + } + + backend.display = display + return backend + } +} + +final class AppleNativeBrightnessBackend: DisplayBrightnessBackend, @unchecked Sendable { + let kind: DisplayBrightnessBackendKind = .appleNative + + private let lock = NSLock() + private var _display: DisplayInfo + var display: DisplayInfo { + get { lock.withLock { _display } } + set { lock.withLock { _display = newValue } } + } + + private let bridge: DisplayServicesBrightnessBridge + + init( + display: DisplayInfo, + bridge: DisplayServicesBrightnessBridge + ) { + self._display = display + self.bridge = bridge + } + + func readBrightness() throws -> Double { + let displayID = lock.withLock { _display.id } + return try bridge.readBrightness(displayID: displayID) + } + + func writeBrightness(_ value: Double) throws { + let displayID = lock.withLock { _display.id } + try bridge.writeBrightness(value, displayID: displayID) + } + + func cleanup() {} +} + +final class DDCBrightnessBackend: DisplayBrightnessBackend, @unchecked Sendable { + let kind: DisplayBrightnessBackendKind = .ddc + + private let lock = NSLock() + private var _display: DisplayInfo + var display: DisplayInfo { + get { lock.withLock { _display } } + set { lock.withLock { _display = newValue } } + } + + private let transport: any DDCBrightnessTransport + private var maximumValue: UInt16 + + init?(display: DisplayInfo, transport: any DDCBrightnessTransport) { + guard !display.isBuiltin else { + return nil + } + + self._display = display + self.transport = transport + self.maximumValue = (try? transport.readBrightness().maximum).flatMap(Self.validMaximum) ?? 100 + } + + func readBrightness() throws -> Double { + let brightness = try transport.readBrightness() + return lock.withLock { + maximumValue = Self.validMaximum(brightness.maximum) ?? maximumValue + return maximumValue == 0 ? 1 : Double(brightness.current) / Double(maximumValue) + } + } + + func writeBrightness(_ value: Double) throws { + let clampedValue = max(0, min(value, 1)) + let rawValue = lock.withLock { + UInt16((Double(maximumValue) * clampedValue).rounded()) + } + try transport.writeBrightness(rawValue) + } + + func cleanup() {} + + private static func validMaximum(_ maximum: UInt16) -> UInt16? { + maximum == 0 ? nil : maximum + } +} + +final class GammaBrightnessBackend: DisplayBrightnessBackend, @unchecked Sendable { + private struct TransferTable { + let red: [CGGammaValue] + let green: [CGGammaValue] + let blue: [CGGammaValue] + } + + let kind: DisplayBrightnessBackendKind = .gamma + + private let lock = NSLock() + private var _display: DisplayInfo + var display: DisplayInfo { + get { lock.withLock { _display } } + set { lock.withLock { _display = newValue } } + } + + private var originalTransferTable: TransferTable? + private var currentBrightness = 1.0 + + init?(display: DisplayInfo) { + guard Self.canControl(displayID: display.id) else { + return nil + } + + self._display = display + } + + func readBrightness() throws -> Double { + lock.withLock { currentBrightness } + } + + func writeBrightness(_ value: Double) throws { + let clampedValue = max(0, min(value, 1)) + let (displayID, transferTable) = try loadOriginalTransferTableIfNeeded() + let red = transferTable.red.map { $0 * Float(clampedValue) } + let green = transferTable.green.map { $0 * Float(clampedValue) } + let blue = transferTable.blue.map { $0 * Float(clampedValue) } + + let result = CGSetDisplayTransferByTable( + displayID, + UInt32(red.count), + red, + green, + blue + ) + + guard result == .success else { + throw DisplayBrightnessControllerError.softwareBrightnessFailed + } + + lock.withLock { currentBrightness = clampedValue } + } + + func cleanup() { + let snapshot: (id: CGDirectDisplayID, table: TransferTable)? = lock.withLock { + guard let originalTransferTable else { return nil } + return (_display.id, originalTransferTable) + } + + guard let snapshot else { return } + + _ = CGSetDisplayTransferByTable( + snapshot.id, + UInt32(snapshot.table.red.count), + snapshot.table.red, + snapshot.table.green, + snapshot.table.blue + ) + lock.withLock { currentBrightness = 1 } + } + + private func loadOriginalTransferTableIfNeeded() throws -> (CGDirectDisplayID, TransferTable) { + // The first-time capture must be atomic w.r.t. concurrent writeBrightness() + // calls: if the read of the live LUT happened outside the lock, a racing + // writer could have already dimmed the display, and we'd cache that dimmed + // LUT as the "original" — cleanup() would then restore the wrong curve and + // never return the display to full brightness. Capture under the lock; it is + // a one-time cost (subsequent calls hit the cache and return immediately). + return try lock.withLock { + let displayID = _display.id + let displayName = _display.name + + if let cachedTable = originalTransferTable { + return (displayID, cachedTable) + } + + let transferTable = try Self.readTransferTable(displayID: displayID, displayName: displayName) + originalTransferTable = transferTable + return (displayID, transferTable) + } + } + + private static func readTransferTable( + displayID: CGDirectDisplayID, + displayName: String + ) throws -> TransferTable { + var sampleCount: UInt32 = 0 + let countResult = CGGetDisplayTransferByTable(displayID, 0, nil, nil, nil, &sampleCount) + + guard countResult == .success, sampleCount > 0 else { + throw DisplayBrightnessControllerError.brightnessUnavailable(displayName: displayName) + } + + let red = UnsafeMutablePointer.allocate(capacity: Int(sampleCount)) + let green = UnsafeMutablePointer.allocate(capacity: Int(sampleCount)) + let blue = UnsafeMutablePointer.allocate(capacity: Int(sampleCount)) + defer { + red.deallocate() + green.deallocate() + blue.deallocate() + } + + let readResult = CGGetDisplayTransferByTable( + displayID, + sampleCount, + red, + green, + blue, + &sampleCount + ) + + guard readResult == .success else { + throw DisplayBrightnessControllerError.brightnessUnavailable(displayName: displayName) + } + + return TransferTable( + red: Array(UnsafeBufferPointer(start: red, count: Int(sampleCount))), + green: Array(UnsafeBufferPointer(start: green, count: Int(sampleCount))), + blue: Array(UnsafeBufferPointer(start: blue, count: Int(sampleCount))) + ) + } + + private static func canControl(displayID: CGDirectDisplayID) -> Bool { + var sampleCount: UInt32 = 0 + return CGGetDisplayTransferByTable(displayID, 0, nil, nil, nil, &sampleCount) == .success + && sampleCount > 0 + } +} + +final class ShadeBrightnessBackend: DisplayBrightnessBackend, @unchecked Sendable { + let kind: DisplayBrightnessBackendKind = .shade + + private let lock = NSLock() + private var _display: DisplayInfo + var display: DisplayInfo { + get { lock.withLock { _display } } + set { lock.withLock { _display = newValue } } + } + + private let overlayController = ShadeOverlayController() + private var currentBrightness = 1.0 + + init?(display: DisplayInfo) { + guard Self.screen(for: display.id) != nil else { + return nil + } + + self._display = display + } + + func readBrightness() throws -> Double { + refreshOverlayFrameIfNeeded() + return lock.withLock { currentBrightness } + } + + func writeBrightness(_ value: Double) throws { + let clampedValue = max(0, min(value, 1)) + let displayID = lock.withLock { _display.id } + guard Self.screen(for: displayID) != nil else { + throw DisplayBrightnessControllerError.displayUnavailable(displayID: displayID) + } + + applyOverlay(brightness: clampedValue) + lock.withLock { currentBrightness = clampedValue } + } + + func cleanup() { + hideOverlay() + lock.withLock { currentBrightness = 1 } + } + + private func refreshOverlayFrameIfNeeded() { + let (displayID, brightness): (CGDirectDisplayID, Double) = lock.withLock { + (_display.id, currentBrightness) + } + guard brightness < 0.999, Self.screen(for: displayID) != nil else { + return + } + + applyOverlay(brightness: brightness) + } + + private func applyOverlay(brightness: Double) { + let displayID = display.id + let overlayController = self.overlayController + + if Thread.isMainThread { + MainActor.assumeIsolated { + guard let screen = Self.screen(for: displayID) else { + return + } + + overlayController.apply(brightness: brightness, on: screen) + } + return + } + + DispatchQueue.main.sync { + MainActor.assumeIsolated { + guard let screen = Self.screen(for: displayID) else { + return + } + + overlayController.apply(brightness: brightness, on: screen) + } + } + } + + private func hideOverlay() { + let overlayController = self.overlayController + + if Thread.isMainThread { + MainActor.assumeIsolated { + overlayController.hide() + } + return + } + + DispatchQueue.main.sync { + MainActor.assumeIsolated { + overlayController.hide() + } + } + } + + private static func screen(for displayID: CGDirectDisplayID) -> NSScreen? { + NSScreen.screens.first(where: { + ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == displayID + }) + } +} + +@MainActor +private final class ShadeOverlayController { + private var window: NSWindow? + + func apply(brightness: Double, on screen: NSScreen) { + let alpha = max(0, min(1 - brightness, 0.92)) + + guard alpha > 0.001 else { + hide() + return + } + + let window = self.window ?? self.makeWindow(screen: screen) + window.setFrame(screen.frame, display: true) + window.alphaValue = alpha + window.orderFrontRegardless() + self.window = window + } + + func hide() { + guard let window else { + return + } + + window.orderOut(nil) + self.window = nil + } + + private func makeWindow(screen: NSScreen) -> NSWindow { + let window = NSWindow( + contentRect: screen.frame, + styleMask: .borderless, + backing: .buffered, + defer: false, + screen: screen + ) + window.level = .screenSaver + window.backgroundColor = .black + window.isOpaque = false + window.ignoresMouseEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + window.hasShadow = false + return window + } +} + +final class DisplayServicesBrightnessBridge: @unchecked Sendable { + private typealias GetBrightnessFunction = @convention(c) ( + CGDirectDisplayID, + UnsafeMutablePointer + ) -> Int32 + private typealias SetBrightnessFunction = @convention(c) (CGDirectDisplayID, Float) -> Int32 + + private let getBrightness: GetBrightnessFunction? + private let setBrightness: SetBrightnessFunction? + + init() { + let handle = dlopen( + "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices", + RTLD_LAZY + ) + self.getBrightness = Self.loadSymbol("DisplayServicesGetBrightness", from: handle) + self.setBrightness = Self.loadSymbol("DisplayServicesSetBrightness", from: handle) + } + + func readBrightness(displayID: CGDirectDisplayID) throws -> Double { + guard let getBrightness else { + throw DisplayBrightnessControllerError.nativeAPINotAvailable + } + + var value: Float = 0 + guard getBrightness(displayID, &value) == 0 else { + throw DisplayBrightnessControllerError.genericBrightnessUnavailable + } + + return Double(max(0, min(value, 1))) + } + + func writeBrightness(_ value: Double, displayID: CGDirectDisplayID) throws { + guard let setBrightness else { + throw DisplayBrightnessControllerError.nativeAPINotAvailable + } + + guard setBrightness(displayID, Float(max(0, min(value, 1)))) == 0 else { + throw DisplayBrightnessControllerError.nativeBrightnessWriteFailed + } + } + + private static func loadSymbol( + _ symbol: String, + from handle: UnsafeMutableRawPointer? + ) -> T? { + guard let symbolPointer = dlsym(handle, symbol) else { + return nil + } + + return unsafeBitCast(symbolPointer, to: T.self) + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessController.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessController.swift new file mode 100644 index 0000000..9e4666b --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessController.swift @@ -0,0 +1,414 @@ +import AppKit +import CoreGraphics +import Foundation +import MacToolsPluginKit + +private final class WeakBrightnessControllerRef: @unchecked Sendable { + weak var value: DisplayBrightnessController? + + init(_ value: DisplayBrightnessController?) { + self.value = value + } +} + +private final class BrightnessWriteGate: @unchecked Sendable { + private let lock = NSLock() + private var nextWriteDate: [CGDirectDisplayID: Date] = [:] + + func waitTurn(for displayID: CGDirectDisplayID, minimumInterval: TimeInterval) { + let delay = lock.withLock { () -> TimeInterval in + let now = Date() + let scheduledDate = max(now, nextWriteDate[displayID] ?? now) + nextWriteDate[displayID] = scheduledDate.addingTimeInterval(minimumInterval) + return max(0, scheduledDate.timeIntervalSince(now)) + } + + guard delay > 0 else { + return + } + + Thread.sleep(forTimeInterval: delay) + } +} + +@MainActor +final class DisplayBrightnessController: DisplayBrightnessControlling { + private struct ManagedDisplay { + var display: DisplayInfo + var backend: any DisplayBrightnessBackend + var currentBrightness: Double + var lastCommittedBrightness: Double + var pendingBrightness: Double? + var writeInFlight = false + var scheduledFlush: DispatchWorkItem? + var pendingReadbackAfterWrite = false + } + + var onStateChange: (() -> Void)? + + private let displayProvider: DisplayProviding + private let backendBuilder: DisplayBrightnessBackendBuilding + private let localization: PluginLocalization + private let logger = DisplayBrightnessLog.controller + private let shortWriteDelay: TimeInterval + private let minimumWriteInterval: TimeInterval + private let writeGate = BrightnessWriteGate() + + private var managedDisplays: [CGDirectDisplayID: ManagedDisplay] = [:] + private var displayOrder: [CGDirectDisplayID] = [] + private var lastErrorMessage: String? + private var terminateObserver: NSObjectProtocol? + + init( + displayProvider: DisplayProviding = SystemDisplayService(), + backendBuilder: DisplayBrightnessBackendBuilding? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main), + shortWriteDelay: TimeInterval = 0.08, + minimumWriteInterval: TimeInterval = 0.08 + ) { + self.displayProvider = displayProvider + self.localization = localization + self.backendBuilder = backendBuilder ?? SystemDisplayBrightnessBackendBuilder( + resolveArm64Services: Arm64DDCServiceMatcher.resolveServices + ) + self.shortWriteDelay = shortWriteDelay + self.minimumWriteInterval = minimumWriteInterval + + terminateObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.cleanupAll() + } + } + } + + func refresh() { + let displays = displayProvider.listConnectedDisplays() + let previousBackends = Dictionary( + uniqueKeysWithValues: managedDisplays.map { ($0.key, $0.value.backend) } + ) + let nextBackends = backendBuilder.backends(for: displays, previous: previousBackends) + let nextDisplayIDs = Set(displays.map(\.id)) + + cleanupDisconnectedDisplays(keeping: nextDisplayIDs) + + var nextManagedDisplays: [CGDirectDisplayID: ManagedDisplay] = [:] + var nextDisplayOrder: [CGDirectDisplayID] = [] + + for display in displays { + guard let backend = nextBackends[display.id] else { + continue + } + + let previous = managedDisplays[display.id] + let brightness = resolvedBrightness(for: display, backend: backend, previous: previous) + + nextManagedDisplays[display.id] = ManagedDisplay( + display: display, + backend: backend, + currentBrightness: previous?.pendingBrightness ?? brightness, + lastCommittedBrightness: brightness, + pendingBrightness: previous?.pendingBrightness, + writeInFlight: previous?.writeInFlight ?? false, + scheduledFlush: previous?.scheduledFlush, + pendingReadbackAfterWrite: previous?.pendingReadbackAfterWrite ?? false + ) + nextDisplayOrder.append(display.id) + } + + managedDisplays = nextManagedDisplays + displayOrder = nextDisplayOrder + + if !nextManagedDisplays.isEmpty { + lastErrorMessage = nil + } + } + + func snapshot() -> DisplayBrightnessSnapshot { + let displays = displayOrder.compactMap { displayID -> DisplayBrightnessDisplay? in + guard let managedDisplay = managedDisplays[displayID] else { + return nil + } + + return DisplayBrightnessDisplay( + display: managedDisplay.display, + brightness: managedDisplay.currentBrightness, + isPendingWrite: managedDisplay.pendingBrightness != nil || managedDisplay.writeInFlight + ) + } + + return DisplayBrightnessSnapshot( + displays: displays, + errorMessage: lastErrorMessage + ) + } + + func setBrightness( + _ value: Double, + for displayID: CGDirectDisplayID, + phase: PluginPanelAction.SliderPhase + ) { + if managedDisplays[displayID] == nil { + refresh() + } + + guard var managedDisplay = managedDisplays[displayID] else { + lastErrorMessage = DisplayBrightnessControllerError.displayUnavailable( + displayID: displayID + ).localizedDescription(localization: localization) + onStateChange?() + return + } + + let clampedValue = Self.clamp(value) + managedDisplay.currentBrightness = clampedValue + managedDisplay.pendingBrightness = clampedValue + managedDisplay.scheduledFlush?.cancel() + managedDisplay.scheduledFlush = nil + managedDisplay.pendingReadbackAfterWrite = phase == .ended + lastErrorMessage = nil + managedDisplays[displayID] = managedDisplay + + let delay = phase == .ended ? 0 : shortWriteDelay + scheduleWrite(for: displayID, delay: delay) + + onStateChange?() + } + + private func resolvedBrightness( + for display: DisplayInfo, + backend: any DisplayBrightnessBackend, + previous: ManagedDisplay? + ) -> Double { + do { + return Self.clamp(try backend.readBrightness()) + } catch { + if let previous { + return previous.currentBrightness + } + + logger.error( + "failed to read brightness for \(display.name, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + return 1 + } + } + + private func scheduleWrite(for displayID: CGDirectDisplayID, delay: TimeInterval) { + guard var managedDisplay = managedDisplays[displayID] else { + return + } + + let controllerRef = WeakBrightnessControllerRef(self) + let workItem = Self.makeScheduledWriteWorkItem( + controllerRef: controllerRef, + displayID: displayID + ) + + managedDisplay.scheduledFlush = workItem + managedDisplays[displayID] = managedDisplay + + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + delay, + execute: workItem + ) + } + + private func beginWriteIfNeeded(for displayID: CGDirectDisplayID) { + guard var managedDisplay = managedDisplays[displayID] else { + return + } + + guard !managedDisplay.writeInFlight, let targetValue = managedDisplay.pendingBrightness else { + return + } + + managedDisplay.writeInFlight = true + managedDisplay.pendingBrightness = nil + managedDisplay.scheduledFlush = nil + let needsReadback = managedDisplay.pendingReadbackAfterWrite + managedDisplay.pendingReadbackAfterWrite = false + let backend = managedDisplay.backend + let displayName = managedDisplay.display.name + let controllerRef = WeakBrightnessControllerRef(self) + managedDisplays[displayID] = managedDisplay + + DispatchQueue.global(qos: .userInitiated).async( + execute: Self.makeWriteWorkItem( + controllerRef: controllerRef, + backend: backend, + displayID: displayID, + targetValue: targetValue, + needsReadback: needsReadback, + displayName: displayName, + writeGate: writeGate, + minimumWriteInterval: minimumWriteInterval + ) + ) + } + + private func finishWrite( + for displayID: CGDirectDisplayID, + targetValue: Double, + readbackValue: Double?, + displayName: String, + result: Result + ) { + guard var managedDisplay = managedDisplays[displayID] else { + return + } + + managedDisplay.writeInFlight = false + + switch result { + case .success: + let committedBrightness = readbackValue.map(Self.clamp) ?? targetValue + managedDisplay.lastCommittedBrightness = committedBrightness + if managedDisplay.pendingBrightness == nil { + managedDisplay.currentBrightness = committedBrightness + } + lastErrorMessage = nil + case .failure(let error): + let localizedDescription = localizedDescription(for: error) + logger.error( + "write failed for \(displayName, privacy: .public): \(localizedDescription, privacy: .public)" + ) + + if let fallbackBackend = fallbackBackend(for: displayID, failedBackend: managedDisplay.backend) { + logger.info( + "retrying brightness write for \(displayName, privacy: .public) with \(String(describing: fallbackBackend.kind), privacy: .public) fallback" + ) + let retryBrightness = managedDisplay.pendingBrightness ?? targetValue + managedDisplay.backend.cleanup() + managedDisplay.backend = fallbackBackend + managedDisplay.pendingBrightness = retryBrightness + managedDisplay.pendingReadbackAfterWrite = true + managedDisplay.currentBrightness = retryBrightness + lastErrorMessage = nil + } else { + if managedDisplay.pendingBrightness == nil { + managedDisplay.currentBrightness = managedDisplay.lastCommittedBrightness + } + + lastErrorMessage = localization.format( + "error.adjustFailedFormat", + defaultValue: "调节失败:%@", + localizedDescription + ) + } + } + + managedDisplays[displayID] = managedDisplay + onStateChange?() + + if managedDisplay.pendingBrightness != nil { + scheduleWrite(for: displayID, delay: 0) + } + } + + private func localizedDescription(for error: Error) -> String { + guard let brightnessError = error as? DisplayBrightnessControllerError else { + return error.localizedDescription + } + + return brightnessError.localizedDescription(localization: localization) + } + + private func fallbackBackend( + for displayID: CGDirectDisplayID, + failedBackend: any DisplayBrightnessBackend + ) -> (any DisplayBrightnessBackend)? { + guard let managedDisplay = managedDisplays[displayID] else { + return nil + } + + let previousBackends = Dictionary( + uniqueKeysWithValues: managedDisplays.map { ($0.key, $0.value.backend) } + ) + return backendBuilder.fallbackBackend( + after: failedBackend, + for: managedDisplay.display, + previous: previousBackends + ) + } + + private func cleanupDisconnectedDisplays(keeping displayIDs: Set) { + for (displayID, managedDisplay) in managedDisplays where !displayIDs.contains(displayID) { + managedDisplay.scheduledFlush?.cancel() + managedDisplay.backend.cleanup() + } + } + + private func cleanupAll() { + for (_, managedDisplay) in managedDisplays { + managedDisplay.scheduledFlush?.cancel() + managedDisplay.backend.cleanup() + } + } + + private static func clamp(_ value: Double) -> Double { + min(max(value, 0), 1) + } + + nonisolated private static func makeScheduledWriteWorkItem( + controllerRef: WeakBrightnessControllerRef, + displayID: CGDirectDisplayID + ) -> DispatchWorkItem { + DispatchWorkItem { + Task { @MainActor in + controllerRef.value?.beginWriteIfNeeded(for: displayID) + } + } + } + + nonisolated private static func makeWriteWorkItem( + controllerRef: WeakBrightnessControllerRef, + backend: any DisplayBrightnessBackend, + displayID: CGDirectDisplayID, + targetValue: Double, + needsReadback: Bool, + displayName: String, + writeGate: BrightnessWriteGate, + minimumWriteInterval: TimeInterval + ) -> DispatchWorkItem { + DispatchWorkItem { + let result: Result + var readbackValue: Double? + + do { + writeGate.waitTurn(for: displayID, minimumInterval: minimumWriteInterval) + try backend.writeBrightness(targetValue) + if needsReadback { + readbackValue = try? backend.readBrightness() + } + result = .success(()) + } catch { + result = .failure(error) + } + + Task { @MainActor in + controllerRef.value?.finishWrite( + for: displayID, + targetValue: targetValue, + readbackValue: readbackValue, + displayName: displayName, + result: result + ) + } + } + } +} + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { + unlock() + } + + return try body() + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessDDC.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessDDC.swift new file mode 100644 index 0000000..24755c2 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessDDC.swift @@ -0,0 +1,1115 @@ +import CoreGraphics +import Foundation +import IOKit +import IOKit.graphics +import IOKit.i2c +import MacToolsPluginKit + +private enum DDCBrightnessControl { + static let brightness: UInt8 = 0x10 + static let hostAddress: UInt8 = 0x51 + static let displayAddress: UInt8 = 0x6E + static let displayReplyAddress: UInt8 = 0x6F + static let arm64DisplayAddress7Bit: UInt8 = 0x37 + static let writeCycleCount = 2 + static let retryCount = 3 + static let writeDelay: useconds_t = 40_000 + static let readDelay: useconds_t = 50_000 + static let retryDelay: useconds_t = 20_000 +} + +struct DDCBrightnessValue { + let current: UInt16 + let maximum: UInt16 +} + +protocol DDCBrightnessTransport { + func readBrightness() throws -> DDCBrightnessValue + func writeBrightness(_ value: UInt16) throws +} + +private enum DDCCommunicationQueue { + static let shared = DispatchQueue(label: "MacTools.DisplayBrightness.DDC") +} + +final class IntelDDCTransport: DDCBrightnessTransport, @unchecked Sendable { + private let display: DisplayInfo + private let framebuffer: io_service_t + private let replyTransactionType: IOOptionBits + + init?(display: DisplayInfo) { + guard let framebuffer = Self.framebufferPort(for: display.id) else { + DisplayBrightnessLog.backend.info( + "DDC Intel transport unavailable for \(display.name, privacy: .public): no framebuffer service" + ) + return nil + } + + guard let replyTransactionType = Self.supportedReplyTransactionType() else { + IOObjectRelease(framebuffer) + DisplayBrightnessLog.backend.info( + "DDC Intel transport unavailable for \(display.name, privacy: .public): no supported I2C reply transaction type" + ) + return nil + } + + self.display = display + self.framebuffer = framebuffer + self.replyTransactionType = replyTransactionType + DisplayBrightnessLog.backend.debug( + "DDC Intel transport ready for \(display.name, privacy: .public)" + ) + } + + deinit { + IOObjectRelease(framebuffer) + } + + func readBrightness() throws -> DDCBrightnessValue { + try DDCCommunicationQueue.shared.sync { + var lastError: Error? + + for _ in 0.. DDCBrightnessValue { + var command = [ + DDCBrightnessControl.hostAddress, + 0x82, + 0x01, + DDCBrightnessControl.brightness + ] + command.append(Self.checksum(seed: DDCBrightnessControl.displayAddress, bytes: command)) + var reply = Array(repeating: UInt8.zero, count: 11) + + var request = IOI2CRequest() + request.commFlags = 0 + request.sendAddress = UInt32(DDCBrightnessControl.displayAddress) + request.sendTransactionType = IOOptionBits(kIOI2CSimpleTransactionType) + request.sendBytes = UInt32(command.count) + request.sendBuffer = command.withUnsafeBufferPointer { + vm_address_t(bitPattern: $0.baseAddress) + } + request.minReplyDelay = 10 + request.replyAddress = UInt32(DDCBrightnessControl.displayReplyAddress) + request.replySubAddress = DDCBrightnessControl.hostAddress + request.replyTransactionType = replyTransactionType + request.replyBytes = UInt32(reply.count) + request.replyBuffer = reply.withUnsafeMutableBufferPointer { + vm_address_t(bitPattern: $0.baseAddress) + } + + guard Self.send(request: &request, to: framebuffer) else { + throw DisplayBrightnessControllerError.i2cUnavailable(displayName: display.name) + } + + return try Self.parseReply(reply, displayName: display.name) + } + + private func performWrite(_ value: UInt16) -> Bool { + var command = [ + DDCBrightnessControl.hostAddress, + 0x84, + 0x03, + DDCBrightnessControl.brightness, + UInt8(value >> 8), + UInt8(value & 0xFF) + ] + command.append(Self.checksum(seed: DDCBrightnessControl.displayAddress, bytes: command)) + + var request = IOI2CRequest() + request.commFlags = 0 + request.sendAddress = UInt32(DDCBrightnessControl.displayAddress) + request.sendTransactionType = IOOptionBits(kIOI2CSimpleTransactionType) + request.sendBytes = UInt32(command.count) + request.sendBuffer = command.withUnsafeBufferPointer { + vm_address_t(bitPattern: $0.baseAddress) + } + request.replyTransactionType = IOOptionBits(kIOI2CNoTransactionType) + request.replyBytes = 0 + + return Self.send(request: &request, to: framebuffer) + } + + static func parseReply( + _ reply: [UInt8], + displayName: String + ) throws -> DDCBrightnessValue { + guard reply.count >= 10 else { + throw DisplayBrightnessControllerError.unsupportedReply(displayName: displayName) + } + + let checksum = checksum(seed: 0x50, bytes: Array(reply.dropLast())) + guard checksum == reply.last else { + throw DisplayBrightnessControllerError.unsupportedReply(displayName: displayName) + } + + guard reply[2] == 0x02, reply[3] == 0x00 else { + throw DisplayBrightnessControllerError.unsupportedReply(displayName: displayName) + } + + return DDCBrightnessValue( + current: UInt16(reply[8]) << 8 | UInt16(reply[9]), + maximum: UInt16(reply[6]) << 8 | UInt16(reply[7]) + ) + } + + private static func checksum(seed: UInt8, bytes: [UInt8]) -> UInt8 { + bytes.reduce(seed, ^) + } + + private static func send(request: inout IOI2CRequest, to framebuffer: io_service_t) -> Bool { + var busCount: IOItemCount = 0 + guard IOFBGetI2CInterfaceCount(framebuffer, &busCount) == KERN_SUCCESS else { + return false + } + + for bus in 0.. IOOptionBits? { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceNameMatching("IOFramebufferI2CInterface"), + &iterator + ) == KERN_SUCCESS else { + return nil + } + + defer { + IOObjectRelease(iterator) + } + + while case let service = IOIteratorNext(iterator), service != 0 { + defer { + IOObjectRelease(service) + } + + var properties: Unmanaged? + guard IORegistryEntryCreateCFProperties( + service, + &properties, + kCFAllocatorDefault, + 0 + ) == KERN_SUCCESS, + let dictionary = properties?.takeRetainedValue() as? [String: Any], + let types = dictionary[kIOI2CTransactionTypesKey] as? UInt64 + else { + continue + } + + if (1 << kIOI2CDDCciReplyTransactionType) & types != 0 { + return IOOptionBits(kIOI2CDDCciReplyTransactionType) + } + + if (1 << kIOI2CSimpleTransactionType) & types != 0 { + return IOOptionBits(kIOI2CSimpleTransactionType) + } + } + + return nil + } + + private static func framebufferPort(for displayID: CGDirectDisplayID) -> io_service_t? { + if let cgsService = PrivateDDCBridge.framebufferService(for: displayID) { + return cgsService + } + + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceMatching(IOFRAMEBUFFER_CONFORMSTO), + &iterator + ) == KERN_SUCCESS else { + return nil + } + + defer { + IOObjectRelease(iterator) + } + + while case let service = IOIteratorNext(iterator), service != 0 { + let vendorID = CGDisplayVendorNumber(displayID) + let modelID = CGDisplayModelNumber(displayID) + let serialNumber = CGDisplaySerialNumber(displayID) + + let infoDictionary = IODisplayCreateInfoDictionary(service, IOOptionBits(kIODisplayOnlyPreferredName)).takeRetainedValue() as NSDictionary + + let matchesVendor = (infoDictionary[kDisplayVendorID] as? UInt32) == vendorID + let matchesModel = (infoDictionary[kDisplayProductID] as? UInt32) == modelID + let displaySerial = infoDictionary[kDisplaySerialNumber] as? UInt32 + let matchesSerial = serialNumber == 0 || displaySerial == serialNumber + + if matchesVendor && matchesModel && matchesSerial { + return service + } + + IOObjectRelease(service) + } + + return nil + } +} + +final class Arm64DDCTransport: DDCBrightnessTransport, @unchecked Sendable { + private let display: DisplayInfo + private let service: CFTypeRef + + init?(display: DisplayInfo, service matchedService: CFTypeRef?) { + guard + let service = matchedService ?? PrivateDDCBridge.createService(for: display.id) + else { + DisplayBrightnessLog.backend.info( + "DDC ARM64 transport unavailable for \(display.name, privacy: .public): no IOAV service" + ) + return nil + } + + self.display = display + self.service = service + DisplayBrightnessLog.backend.debug( + "DDC ARM64 transport ready for \(display.name, privacy: .public)" + ) + } + + func readBrightness() throws -> DDCBrightnessValue { + try DDCCommunicationQueue.shared.sync { + var payload = [DDCBrightnessControl.brightness] + var reply = Array(repeating: UInt8.zero, count: 11) + try performCommunication(send: &payload, reply: &reply) + return try IntelDDCTransport.parseReply(reply, displayName: display.name) + } + } + + func writeBrightness(_ value: UInt16) throws { + try DDCCommunicationQueue.shared.sync { + var payload = [ + DDCBrightnessControl.brightness, + UInt8(value >> 8), + UInt8(value & 0xFF) + ] + var reply: [UInt8] = [] + try performCommunication(send: &payload, reply: &reply) + } + } + + private func performCommunication(send: inout [UInt8], reply: inout [UInt8]) throws { + var packet = [UInt8(0x80 | UInt8(send.count + 1)), UInt8(send.count)] + send + [0] + let seed = send.count == 1 + ? DDCBrightnessControl.arm64DisplayAddress7Bit << 1 + : (DDCBrightnessControl.arm64DisplayAddress7Bit << 1) ^ DDCBrightnessControl.hostAddress + packet[packet.count - 1] = packet.dropLast().reduce(seed, ^) + + var didWrite = false + for _ in 0.. [CGDirectDisplayID: CFTypeRef] { + resolveServices(for: displays, candidates: discoverCandidates()) + } + + static func resolveServices( + for displays: [DisplayInfo], + candidates: [CandidateService] + ) -> [CGDirectDisplayID: CFTypeRef] { + let scoredMatches = scoredCandidateMatches( + for: displays.filter { !$0.isBuiltin }, + candidates: candidates + ) + var matches: [CGDirectDisplayID: CFTypeRef] = [:] + var usedDisplayIDs: Set = [] + var usedCandidateIndexes: Set = [] + + for match in scoredMatches { + guard + !usedDisplayIDs.contains(match.display.id), + !usedCandidateIndexes.contains(match.candidateIndex) + else { + continue + } + + matches[match.display.id] = match.candidate.service + usedDisplayIDs.insert(match.display.id) + usedCandidateIndexes.insert(match.candidateIndex) + } + + assignRemainingServicesByStableOrder( + displays: displays.filter { !$0.isBuiltin }, + candidates: candidates, + matches: &matches, + usedCandidateIndexes: usedCandidateIndexes + ) + + return matches + } + + private static func assignRemainingServicesByStableOrder( + displays: [DisplayInfo], + candidates: [CandidateService], + matches: inout [CGDirectDisplayID: CFTypeRef], + usedCandidateIndexes: Set + ) { + let unmatchedDisplays = displays + .filter { matches[$0.id] == nil } + .sorted(by: stableDisplaySort) + let unusedCandidates = candidates.indices + .filter { !usedCandidateIndexes.contains($0) && isExternalCandidate(candidates[$0]) } + .map { candidates[$0] } + .sorted(by: stableCandidateSort) + + guard + !unmatchedDisplays.isEmpty, + unmatchedDisplays.count == unusedCandidates.count + else { + return + } + + for (display, candidate) in zip(unmatchedDisplays, unusedCandidates) { + matches[display.id] = candidate.service + } + } + + private static func discoverCandidates() -> [CandidateService] { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceNameMatching("DCPAVServiceProxy"), + &iterator + ) == KERN_SUCCESS else { + return [] + } + + defer { + IOObjectRelease(iterator) + } + + var result: [CandidateService] = [] + var serviceLocation = 0 + + while case let service = IOIteratorNext(iterator), service != 0 { + defer { + IOObjectRelease(service) + } + + guard let avService = PrivateDDCBridge.createService(with: service) else { + continue + } + + let location = searchedProperty( + key: "Location", + service: service + ) as? String + guard location?.localizedCaseInsensitiveContains("external") ?? true else { + continue + } + + let nearbyDisplayProperties = nearbyDisplayProperties(for: service) ?? [:] + let serviceDisplayAttributes = searchedProperty( + key: "DisplayAttributes", + service: service + ) as? [String: Any] + let displayAttributes = serviceDisplayAttributes ?? nearbyDisplayProperties["DisplayAttributes"] as? [String: Any] + let productAttributes = displayAttributes?["ProductAttributes"] as? [String: Any] + + serviceLocation += 1 + result.append( + CandidateService( + service: avService, + name: productAttributes?["ProductName"] as? String, + vendorNumber: numericValue(productAttributes?["LegacyManufacturerID"]) + ?? numericValue(productAttributes?["ManufacturerID"]), + serialNumber: numericValue(productAttributes?["SerialNumber"]), + productID: numericValue(productAttributes?["ProductID"]), + manufactureYear: numericValue(productAttributes?["YearOfManufacture"]), + manufactureWeek: numericValue(productAttributes?["WeekOfManufacture"]), + location: location, + ioDisplayLocation: (searchedProperty( + key: kIODisplayLocationKey, + service: service + ) as? String) ?? servicePathKey(for: service), + edidUUID: (searchedProperty( + key: "EDID UUID", + service: service + ) as? String) ?? nearbyDisplayProperties["EDID UUID"] as? String + ?? nearbyDisplayProperties["IOMFBUUID"] as? String, + serviceLocation: serviceLocation + ) + ) + } + + return result + } + + private struct ScoredCandidateMatch { + let display: DisplayInfo + let candidateIndex: Int + let candidate: CandidateService + let score: Int + } + + private static func scoredCandidateMatches( + for displays: [DisplayInfo], + candidates: [CandidateService] + ) -> [ScoredCandidateMatch] { + var scoredMatches: [ScoredCandidateMatch] = [] + + for display in displays { + for index in candidates.indices { + let candidate = candidates[index] + let score = matchScore(display: display, candidate: candidate) + guard score >= 4 else { + continue + } + + scoredMatches.append( + ScoredCandidateMatch( + display: display, + candidateIndex: index, + candidate: candidate, + score: score + ) + ) + } + } + + return scoredMatches.sorted { + if $0.score == $1.score { + if $0.display.id == $1.display.id { + return $0.candidate.serviceLocation < $1.candidate.serviceLocation + } + + return $0.display.id < $1.display.id + } + + return $0.score > $1.score + } + } + + private static func stableDisplaySort(_ lhs: DisplayInfo, _ rhs: DisplayInfo) -> Bool { + let lhsLocation = displayLocation(for: lhs.id) ?? "" + let rhsLocation = displayLocation(for: rhs.id) ?? "" + + if lhsLocation != rhsLocation { + return lhsLocation < rhsLocation + } + + return lhs.id < rhs.id + } + + private static func isExternalCandidate(_ candidate: CandidateService) -> Bool { + guard let location = candidate.location?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return false + } + + return location.localizedCaseInsensitiveCompare("External") == .orderedSame + } + + private static func stableCandidateSort(_ lhs: CandidateService, _ rhs: CandidateService) -> Bool { + let lhsLocation = lhs.ioDisplayLocation ?? "" + let rhsLocation = rhs.ioDisplayLocation ?? "" + + if lhsLocation != rhsLocation { + return lhsLocation < rhsLocation + } + + return lhs.serviceLocation < rhs.serviceLocation + } + + private static func matchScore(display: DisplayInfo, candidate: CandidateService) -> Int { + var score = 0 + + if let displayLocation = displayLocation(for: display.id), + let candidateLocation = candidate.ioDisplayLocation, + !displayLocation.isEmpty, + displayLocation == candidateLocation { + score += 12 + } + + score += edidMatchScore(displayID: display.id, edidUUID: candidate.edidUUID) + + if let serialNumber = display.serialNumber, serialNumber != 0, serialNumber == candidate.serialNumber { + score += 10 + } + + if let vendorNumber = display.vendorNumber, + let candidateVendorNumber = candidate.vendorNumber, + vendorNumber == candidateVendorNumber { + score += 3 + } + + if let modelNumber = display.modelNumber, + let productID = candidate.productID, + modelNumber == productID { + score += 6 + } + + score += displayAttributesMatchScore(displayID: display.id, candidate: candidate) + + if let name = candidate.name, + normalizedDisplayName(name) == normalizedDisplayName(display.name) { + score += 2 + } + + if let location = candidate.location, location.localizedCaseInsensitiveContains("external") { + score += 1 + } + + return score + } + + private static func edidMatchScore(displayID: CGDirectDisplayID, edidUUID: String?) -> Int { + guard + let edidUUID, + !edidUUID.isEmpty, + let dictionary = displayInfoDictionary(for: displayID) + else { + return 0 + } + + var score = 0 + let uppercaseUUID = edidUUID.uppercased() + + if let vendorID = dictionary[kDisplayVendorID] as? Int64, + uuidToken(UInt16(clamping: vendorID), byteSwapped: false).map({ uppercaseUUID.hasPrefix($0) }) == true { + score += 2 + } + + if let productID = dictionary[kDisplayProductID] as? Int64, + let token = uuidToken(UInt16(clamping: productID), byteSwapped: true), + uppercaseUUID.contains(token) { + score += 2 + } + + if let week = dictionary[kDisplayWeekOfManufacture] as? Int64, + let year = dictionary[kDisplayYearOfManufacture] as? Int64, + let token = manufactureDateToken(week: week, year: year), + uppercaseUUID.contains(token) { + score += 2 + } + + if let horizontalSize = dictionary[kDisplayHorizontalImageSize] as? Int64, + let verticalSize = dictionary[kDisplayVerticalImageSize] as? Int64, + let token = imageSizeToken(horizontal: horizontalSize, vertical: verticalSize), + uppercaseUUID.contains(token) { + score += 2 + } + + return score + } + + private static func displayAttributesMatchScore( + displayID: CGDirectDisplayID, + candidate: CandidateService + ) -> Int { + guard let dictionary = displayInfoDictionary(for: displayID) else { + return 0 + } + + var score = 0 + + if let serialNumber = dictionary[kDisplaySerialNumber] as? Int64, + serialNumber > 0, + UInt32(clamping: serialNumber) == candidate.serialNumber { + score += 10 + } + + if let vendorID = dictionary[kDisplayVendorID] as? Int64, + vendorID > 0, + UInt32(clamping: vendorID) == candidate.vendorNumber { + score += 3 + } + + if let productID = dictionary[kDisplayProductID] as? Int64, + productID > 0, + UInt32(clamping: productID) == candidate.productID { + score += 6 + } + + if let year = dictionary[kDisplayYearOfManufacture] as? Int64, + year > 0, + UInt32(clamping: year) == candidate.manufactureYear { + score += 2 + } + + if let week = dictionary[kDisplayWeekOfManufacture] as? Int64, + week > 0, + UInt32(clamping: week) == candidate.manufactureWeek { + score += 2 + } + + return score + } + + private static func uuidToken(_ value: UInt16, byteSwapped: Bool) -> String? { + if value == 0 { + return nil + } + + let normalizedValue = byteSwapped ? value.byteSwapped : value + return String(format: "%04X", normalizedValue) + } + + private static func manufactureDateToken(week: Int64, year: Int64) -> String? { + let normalizedWeek = UInt8(clamping: week) + let normalizedYear = UInt8(clamping: year - 1990) + guard normalizedWeek != 0 || normalizedYear != 0 else { + return nil + } + + return String(format: "%02X%02X", normalizedWeek, normalizedYear) + } + + private static func imageSizeToken(horizontal: Int64, vertical: Int64) -> String? { + let normalizedHorizontal = UInt8(clamping: horizontal / 10) + let normalizedVertical = UInt8(clamping: vertical / 10) + guard normalizedHorizontal != 0 || normalizedVertical != 0 else { + return nil + } + + return String(format: "%02X%02X", normalizedHorizontal, normalizedVertical) + } + + private static func normalizedDisplayName(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private static func numericValue(_ value: Any?) -> UInt32? { + switch value { + case let value as UInt32: + return value == 0 ? nil : value + case let value as UInt64: + guard value <= UInt64(UInt32.max) else { + return nil + } + + return value == 0 ? nil : UInt32(value) + case let value as Int: + guard value > 0, value <= Int(UInt32.max) else { + return nil + } + + return UInt32(value) + case let value as String: + let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + return UInt32(normalizedValue) ?? eisaManufacturerID(normalizedValue) + default: + return nil + } + } + + private static func eisaManufacturerID(_ value: String) -> UInt32? { + let characters = Array(value.uppercased()) + guard characters.count == 3 else { + return nil + } + + var encodedValue: UInt32 = 0 + for character in characters { + guard + let scalar = character.unicodeScalars.first, + scalar.value >= 65, + scalar.value <= 90 + else { + return nil + } + + encodedValue = (encodedValue << 5) | UInt32(scalar.value - 64) + } + + return encodedValue == 0 ? nil : encodedValue + } + + private static func displayInfoDictionary(for displayID: CGDirectDisplayID) -> NSDictionary? { + guard let createInfoDictionary = PrivateDDCBridge.coreDisplayCreateInfoDictionary else { + return nil + } + + return createInfoDictionary(displayID)?.takeRetainedValue() as NSDictionary? + } + + private static func displayLocation(for displayID: CGDirectDisplayID) -> String? { + if let location = displayInfoDictionary(for: displayID)?[kIODisplayLocationKey] as? String { + return location + } + + guard let service = PrivateDDCBridge.framebufferService(for: displayID) else { + return nil + } + + return servicePathKey(for: service) + } + + private static func nearbyDisplayProperties(for service: io_service_t) -> [String: Any]? { + guard let pathKey = servicePathKey(for: service) else { + return nil + } + + let displayServiceNames = [ + "AppleCLCD2", + "IOMobileFramebufferShim" + ] + + for serviceName in displayServiceNames { + if let properties = nearbyDisplayProperties( + serviceName: serviceName, + pathKey: pathKey + ) { + return properties + } + } + + return nil + } + + private static func nearbyDisplayProperties( + serviceName: String, + pathKey: String + ) -> [String: Any]? { + var iterator = io_iterator_t() + guard IOServiceGetMatchingServices( + kIOMainPortDefault, + IOServiceNameMatching(serviceName), + &iterator + ) == KERN_SUCCESS else { + return nil + } + + defer { + IOObjectRelease(iterator) + } + + while case let displayService = IOIteratorNext(iterator), displayService != 0 { + defer { + IOObjectRelease(displayService) + } + + guard servicePathKey(for: displayService) == pathKey else { + continue + } + + var properties: Unmanaged? + guard IORegistryEntryCreateCFProperties( + displayService, + &properties, + kCFAllocatorDefault, + 0 + ) == KERN_SUCCESS else { + continue + } + + return properties?.takeRetainedValue() as? [String: Any] + } + + return nil + } + + private static func servicePathKey(for service: io_service_t) -> String? { + guard + let unmanagedPath = IORegistryEntryCopyPath(service, kIOServicePlane), + let path = unmanagedPath.takeRetainedValue() as String? + else { + return nil + } + + let pattern = #"(?:^|/)disp(?:ext)?\d+(?=[@:/])"# + guard let range = path.range(of: pattern, options: .regularExpression) else { + return nil + } + + return String(path[range]).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + private static func searchedProperty(key: String, service: io_service_t) -> AnyObject? { + IORegistryEntrySearchCFProperty( + service, + kIOServicePlane, + key as CFString, + kCFAllocatorDefault, + IOOptionBits(kIORegistryIterateRecursively | kIORegistryIterateParents) + ) + } +} + +private extension UInt16 { + init(clamping value: Int64) { + if value < 0 { + self = 0 + } else if value > Int64(UInt16.max) { + self = UInt16.max + } else { + self = UInt16(value) + } + } +} + +private extension UInt8 { + init(clamping value: Int64) { + if value < 0 { + self = 0 + } else if value > Int64(UInt8.max) { + self = UInt8.max + } else { + self = UInt8(value) + } + } +} + +private extension UInt32 { + init(clamping value: Int) { + if value < 0 { + self = 0 + } else if value > Int(UInt32.max) { + self = UInt32.max + } else { + self = UInt32(value) + } + } +} + +private enum PrivateDDCBridge { + typealias CoreDisplayCreateInfoDictionaryFunction = @convention(c) ( + CGDirectDisplayID + ) -> Unmanaged? + + private typealias CreateWithServiceFunction = @convention(c) ( + CFAllocator?, + io_service_t + ) -> Unmanaged? + private typealias ReadI2CFunction = @convention(c) ( + CFTypeRef, + UInt32, + UInt32, + UnsafeMutableRawPointer?, + UInt32 + ) -> kern_return_t + private typealias WriteI2CFunction = @convention(c) ( + CFTypeRef, + UInt32, + UInt32, + UnsafeMutableRawPointer?, + UInt32 + ) -> kern_return_t + private typealias CGSServiceFunction = @convention(c) ( + CGDirectDisplayID, + UnsafeMutablePointer + ) -> Void + + private static let privateFrameworkPaths = [ + "/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay", + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", + "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices" + ] + + private static let createWithService: CreateWithServiceFunction? = loadSymbol("IOAVServiceCreateWithService") + private static let read: ReadI2CFunction? = loadSymbol("IOAVServiceReadI2C") + private static let write: WriteI2CFunction? = loadSymbol("IOAVServiceWriteI2C") + private static let cgsServiceForDisplay: CGSServiceFunction? = loadSymbol("CGSServiceForDisplayNumber") + static let coreDisplayCreateInfoDictionary: CoreDisplayCreateInfoDictionaryFunction? = loadSymbol( + "CoreDisplay_DisplayCreateInfoDictionary" + ) + + static func createService(for displayID: CGDirectDisplayID) -> CFTypeRef? { + guard let service = framebufferService(for: displayID), let createWithService else { + if createWithService == nil { + DisplayBrightnessLog.backend.info("DDC private symbol IOAVServiceCreateWithService is unavailable") + } + return nil + } + + return createWithService(kCFAllocatorDefault, service)?.takeRetainedValue() + } + + static func framebufferService(for displayID: CGDirectDisplayID) -> io_service_t? { + guard let cgsServiceForDisplay else { + DisplayBrightnessLog.backend.info("DDC private symbol CGSServiceForDisplayNumber is unavailable") + return nil + } + + var service = io_service_t() + cgsServiceForDisplay(displayID, &service) + guard service != 0 else { + return nil + } + + return service + } + + static func createService(with service: io_service_t) -> CFTypeRef? { + createWithService?(kCFAllocatorDefault, service)?.takeRetainedValue() + } + + static func readI2C( + service: CFTypeRef, + address: UInt32, + dataAddress: UInt32, + bytes: inout [UInt8] + ) -> kern_return_t { + guard let read else { + return kIOReturnUnsupported + } + + return bytes.withUnsafeMutableBytes { buffer in + read(service, address, dataAddress, buffer.baseAddress, UInt32(buffer.count)) + } + } + + static func writeI2C( + service: CFTypeRef, + address: UInt32, + dataAddress: UInt32, + bytes: inout [UInt8] + ) -> kern_return_t { + guard let write else { + return kIOReturnUnsupported + } + + return bytes.withUnsafeMutableBytes { buffer in + write(service, address, dataAddress, buffer.baseAddress, UInt32(buffer.count)) + } + } + + private static func loadSymbol(_ symbol: String) -> T? { + for path in privateFrameworkPaths { + guard + let handle = dlopen(path, RTLD_LAZY), + let pointer = dlsym(handle, symbol) + else { + continue + } + + return unsafeBitCast(pointer, to: T.self) + } + + guard let pointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), symbol) else { + return nil + } + + return unsafeBitCast(pointer, to: T.self) + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessLocalization.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessLocalization.swift new file mode 100644 index 0000000..a6b3b6e --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessLocalization.swift @@ -0,0 +1,20 @@ +import Foundation +import MacToolsPluginKit + +private final class DisplayBrightnessBundleToken {} + +enum DisplayBrightnessLocalization { + static let fallback = PluginLocalization(bundle: Bundle(for: DisplayBrightnessBundleToken.self)) + + static func string(_ key: String, defaultValue: String) -> String { + fallback.string(key, defaultValue: defaultValue) + } + + static func format(_ key: String, defaultValue: String, _ arguments: CVarArg...) -> String { + String( + format: string(key, defaultValue: defaultValue), + locale: PluginRuntimeLocalization.locale, + arguments: arguments + ) + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessLog.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessLog.swift new file mode 100644 index 0000000..539aef2 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessLog.swift @@ -0,0 +1,10 @@ +import Foundation +import OSLog + +enum DisplayBrightnessLog { + private static let subsystem = Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools" + + static let plugin = Logger(subsystem: subsystem, category: "DisplayBrightnessPlugin") + static let controller = Logger(subsystem: subsystem, category: "DisplayBrightnessController") + static let backend = Logger(subsystem: subsystem, category: "DisplayBrightnessBackend") +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessModels.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessModels.swift new file mode 100644 index 0000000..d183453 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessModels.swift @@ -0,0 +1,118 @@ +import CoreGraphics +import Foundation +import MacToolsPluginKit + +enum DisplayBrightnessBackendKind: Equatable { + case appleNative + case ddc + case gamma + case shade +} + +struct DisplayBrightnessDisplay: Identifiable, Equatable { + let display: DisplayInfo + let brightness: Double + let isPendingWrite: Bool + + var id: CGDirectDisplayID { display.id } +} + +struct DisplayBrightnessSnapshot: Equatable { + let displays: [DisplayBrightnessDisplay] + let errorMessage: String? +} + +enum DisplayBrightnessControllerError: Error, LocalizedError { + case displayUnavailable(displayID: CGDirectDisplayID) + case brightnessUnavailable(displayName: String) + case nativeAPINotAvailable + case genericBrightnessUnavailable + case i2cUnavailable(displayName: String) + case unsupportedReply(displayName: String) + case ddcWriteFailed(displayName: String) + case softwareBrightnessFailed + case nativeBrightnessWriteFailed + case failed(message: String) + + var errorDescription: String? { + localizedDescription(localization: DisplayBrightnessLocalization.fallback) + } + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case .displayUnavailable: + return localization.string("error.displayUnavailable", defaultValue: "显示器已断开连接") + case .brightnessUnavailable(let displayName): + return localization.format( + "error.brightnessUnavailableFormat", + defaultValue: "%@ 当前无法读取亮度", + displayName + ) + case .nativeAPINotAvailable: + return localization.string("error.nativeAPINotAvailable", defaultValue: "系统亮度接口不可用") + case .genericBrightnessUnavailable: + return localization.string("error.genericBrightnessUnavailable", defaultValue: "显示器当前无法读取亮度") + case .i2cUnavailable(let displayName): + return localization.format("error.i2cUnavailableFormat", defaultValue: "%@ 不支持 DDC/CI", displayName) + case .unsupportedReply(let displayName): + return localization.format( + "error.unsupportedReplyFormat", + defaultValue: "%@ 返回了无效亮度数据", + displayName + ) + case .ddcWriteFailed(let displayName): + return localization.format("error.ddcWriteFailedFormat", defaultValue: "%@ DDC 写入失败", displayName) + case .softwareBrightnessFailed: + return localization.string("error.softwareBrightnessFailed", defaultValue: "软件亮度调节失败") + case .nativeBrightnessWriteFailed: + return localization.string("error.nativeBrightnessWriteFailed", defaultValue: "原生亮度写入失败") + case .failed(let message): + return message + } + } +} + +@MainActor +protocol DisplayBrightnessControlling: AnyObject { + var onStateChange: (() -> Void)? { get set } + + func refresh() + func snapshot() -> DisplayBrightnessSnapshot + func setBrightness( + _ value: Double, + for displayID: CGDirectDisplayID, + phase: PluginPanelAction.SliderPhase + ) +} + +protocol DisplayBrightnessBackend: AnyObject, Sendable { + var kind: DisplayBrightnessBackendKind { get } + var display: DisplayInfo { get set } + + func readBrightness() throws -> Double + func writeBrightness(_ value: Double) throws + func cleanup() +} + +protocol DisplayBrightnessBackendBuilding { + func backends( + for displays: [DisplayInfo], + previous: [CGDirectDisplayID: any DisplayBrightnessBackend] + ) -> [CGDirectDisplayID: any DisplayBrightnessBackend] + + func fallbackBackend( + after failedBackend: any DisplayBrightnessBackend, + for display: DisplayInfo, + previous: [CGDirectDisplayID: any DisplayBrightnessBackend] + ) -> (any DisplayBrightnessBackend)? +} + +extension DisplayBrightnessBackendBuilding { + func fallbackBackend( + after failedBackend: any DisplayBrightnessBackend, + for display: DisplayInfo, + previous: [CGDirectDisplayID: any DisplayBrightnessBackend] + ) -> (any DisplayBrightnessBackend)? { + nil + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift new file mode 100644 index 0000000..2f7576d --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift @@ -0,0 +1,696 @@ +import AppKit +import CoreGraphics +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class DisplayBrightnessPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + DisplayBrightnessPluginProvider(context: context) + } +} + +@MainActor +private struct DisplayBrightnessPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [ + DisplayBrightnessPlugin( + localization: PluginLocalization(bundle: context.resourceBundle), + shortcutPreferences: DisplayBrightnessShortcutPreferences(storage: context.storage) + ) + ] + } +} + +enum DisplayBrightnessShortcutDirection: Equatable { + case decrease + case increase + + var actionID: String { + switch self { + case .decrease: + return "display-brightness.decrease" + case .increase: + return "display-brightness.increase" + } + } + + func title(localization: PluginLocalization) -> String { + switch self { + case .decrease: + return localization.string("shortcut.direction.decrease", defaultValue: "降低") + case .increase: + return localization.string("shortcut.direction.increase", defaultValue: "增加") + } + } + + var systemImage: String { + switch self { + case .decrease: + return "sun.min.fill" + case .increase: + return "sun.max.fill" + } + } + + var multiplier: Double { + switch self { + case .decrease: + return -1 + case .increase: + return 1 + } + } +} + +struct DisplayBrightnessShortcutAction: Equatable { + let id: String + let direction: DisplayBrightnessShortcutDirection + let targetDisplayIDs: [CGDirectDisplayID] +} + +struct DisplayBrightnessShortcutAcceleration { + private var lastPressDateByActionID: [String: Date] = [:] + private var quickPressCountByActionID: [String: Int] = [:] + + mutating func stepForPress(actionID: String, now: Date, fastTapWindow: TimeInterval = 0.48) -> Int { + let quickPressCount: Int + if let lastPressDate = lastPressDateByActionID[actionID], + now.timeIntervalSince(lastPressDate) <= fastTapWindow { + quickPressCount = min((quickPressCountByActionID[actionID] ?? 0) + 1, 9) + } else { + quickPressCount = 0 + } + + quickPressCountByActionID[actionID] = quickPressCount + lastPressDateByActionID[actionID] = now + return min(10, 1 + quickPressCount) + } + + static func stepForHold(elapsed: TimeInterval, baseline: Int) -> Int { + let elapsedStep: Int + switch elapsed { + case ..<0.45: + elapsedStep = 1 + case ..<0.9: + elapsedStep = 2 + case ..<1.35: + elapsedStep = 3 + case ..<1.8: + elapsedStep = 4 + case ..<2.4: + elapsedStep = 6 + case ..<3.2: + elapsedStep = 8 + default: + elapsedStep = 10 + } + + return min(10, max(baseline, elapsedStep)) + } +} + +private struct DisplayBrightnessShortcutSession { + let id: UUID + let action: DisplayBrightnessShortcutAction + let task: Task +} + +@MainActor +final class DisplayBrightnessPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginShortcutEventHandling, DisplayTopologyRefreshing { + private enum Constants { + static let displayControlPrefix = "display." + static let brightnessControlSuffix = ".brightness" + static let disableBuiltInDisplayControlID = "built-in-display-disable" + static let restoreBuiltInDisplayControlID = "built-in-display-restore" + static let shortcutGroupID = "display-brightness.shortcuts" + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let controller: DisplayBrightnessControlling + private let displayDisableCoordinator: any DisplayDisableCoordinating + private let showsDisplayDisableControls: Bool + private let shortcutPreferences: DisplayBrightnessShortcutPreferences + private let mouseDisplayIDProvider: @MainActor () -> CGDirectDisplayID? + private let localization: PluginLocalization + private var isExpanded = false + private var displayDisableActionTask: Task? + private var displayTopologyTask: Task? + private var shortcutAcceleration = DisplayBrightnessShortcutAcceleration() + private var shortcutSessions: [String: DisplayBrightnessShortcutSession] = [:] + + init( + controller: DisplayBrightnessControlling? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main), + displayDisableCoordinator: (any DisplayDisableCoordinating)? = nil, + showsDisplayDisableControls: Bool = true, + shortcutPreferences: DisplayBrightnessShortcutPreferences? = nil, + mouseDisplayIDProvider: @escaping @MainActor () -> CGDirectDisplayID? = DisplayBrightnessPlugin.currentMouseDisplayID + ) { + self.localization = localization + self.controller = controller ?? DisplayBrightnessController(localization: localization) + self.displayDisableCoordinator = displayDisableCoordinator ?? DisplayDisableCoordinator( + service: Self.defaultDisplayDisableService(), + store: UserDefaultsDisplayDisableStateStore(), + localization: localization + ) + self.showsDisplayDisableControls = showsDisplayDisableControls + self.shortcutPreferences = shortcutPreferences ?? DisplayBrightnessShortcutPreferences( + storage: UserDefaultsPluginStorage(pluginID: "display-brightness") + ) + self.mouseDisplayIDProvider = mouseDisplayIDProvider + self.metadata = PluginMetadata( + id: "display-brightness", + title: localization.string("metadata.title", defaultValue: "显示器亮度"), + iconName: "sun.max", + iconTint: Color(nsColor: .systemYellow), + order: 20, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "快速调节每个显示器的亮度" + ) + ) + self.controller.onStateChange = { [weak self] in + self?.onStateChange?() + } + } + + var primaryPanelState: PluginPanelState { + let snapshot = controller.snapshot() + + guard !snapshot.displays.isEmpty else { + isExpanded = false + return PluginPanelState( + subtitle: localization.string( + "panel.subtitle.noDisplays", + defaultValue: "未检测到可调节亮度的显示器" + ), + isOn: false, + isExpanded: false, + isEnabled: false, + isVisible: true, + detail: nil, + errorMessage: snapshot.errorMessage + ) + } + + return PluginPanelState( + subtitle: subtitle(for: snapshot.displays), + isOn: false, + isExpanded: isExpanded, + isEnabled: true, + isVisible: true, + detail: isExpanded ? buildDetail(for: snapshot.displays) : nil, + errorMessage: snapshot.errorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var configuration: PluginConfiguration? { + PluginConfiguration(description: metadata.defaultDescription) { [shortcutPreferences, localization] _ in + DisplayBrightnessSettingsView( + preferences: shortcutPreferences, + localization: localization + ) + } + } + + var shortcutDefinitions: [PluginShortcutDefinition] { + [ + shortcutDefinition(direction: .decrease), + shortcutDefinition(direction: .increase) + ] + } + + func refresh() { + controller.refresh() + displayDisableCoordinator.refreshSnapshot() + } + + func refreshDisplayTopology() { + controller.refresh() + let coordinator = displayDisableCoordinator + displayTopologyTask?.cancel() + displayTopologyTask = Task { @MainActor [weak self, coordinator] in + await coordinator.reconcileTopology() + self?.onStateChange?() + } + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(value): + isExpanded = value + onStateChange?() + case let .setSlider(controlID, value, phase): + guard let displayID = Self.parseDisplayID(from: controlID) else { + DisplayBrightnessLog.plugin.error( + "invalid slider control id \(controlID, privacy: .public)" + ) + return + } + + controller.setBrightness(value, for: displayID, phase: phase) + onStateChange?() + case let .invokeAction(controlID): + handleInvokeAction(controlID: controlID) + case .setSwitch, + .setSelection, + .setNavigationSelection, + .clearNavigationSelection, + .setDate: + return + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) { + handleShortcutEvent(id: id, phase: .pressed) + } + + func handleShortcutEvent(id: String, phase: PluginShortcutEventPhase) { + switch phase { + case .pressed: + startShortcutAction(id: id) + case .released: + stopShortcutAction(id: id) + } + } + + func deactivate(reason: PluginDeactivationReason) { + displayDisableActionTask?.cancel() + displayTopologyTask?.cancel() + stopAllShortcutActions() + guard reason.requiresStateCleanup else { return } + displayDisableCoordinator.restoreBuiltInDisplay() + } + + static func parseDisplayID(from controlID: String) -> CGDirectDisplayID? { + guard + controlID.hasPrefix(Constants.displayControlPrefix), + controlID.hasSuffix(Constants.brightnessControlSuffix) + else { + return nil + } + + let startIndex = controlID.index( + controlID.startIndex, + offsetBy: Constants.displayControlPrefix.count + ) + let endIndex = controlID.index( + controlID.endIndex, + offsetBy: -Constants.brightnessControlSuffix.count + ) + return CGDirectDisplayID(controlID[startIndex.. String { + if displays.count == 1, let display = displays.first { + return "\(display.display.name) \(Self.percentText(for: display.brightness))" + } + + return localization.format("panel.subtitle.displayCountFormat", defaultValue: "%d 个显示器", displays.count) + } + + private func buildDetail(for displays: [DisplayBrightnessDisplay]) -> PluginPanelDetail { + let brightnessControls = displays.map { display in + PluginPanelControl( + id: "\(Constants.displayControlPrefix)\(display.display.id)\(Constants.brightnessControlSuffix)", + kind: .slider, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: display.display.name, + sliderValue: display.brightness, + sliderBounds: 0...1, + sliderStep: 0.01, + valueLabel: Self.percentText(for: display.brightness), + isEnabled: true + ) + } + + return PluginPanelDetail( + primaryControls: brightnessControls + displayDisableControls(), + secondaryPanel: nil + ) + } + + private func displayDisableControls() -> [PluginPanelControl] { + guard showsDisplayDisableControls else { + return [] + } + + let snapshot = displayDisableCoordinator.snapshot + switch snapshot.status { + case .unsupported: + return [displayDisableActionControl( + id: Constants.disableBuiltInDisplayControlID, + title: localization.string( + "displayDisable.action.disable", + defaultValue: "关闭内建显示屏" + ), + iconName: "display", + isEnabled: false + )] + case .unavailable: + var controls = [displayDisableActionControl( + id: Constants.disableBuiltInDisplayControlID, + title: localization.string( + "displayDisable.action.disable", + defaultValue: "关闭内建显示屏" + ), + iconName: "display", + isEnabled: false + )] + if snapshot.isRestoreAllowed { + controls.append(displayDisableActionControl( + id: Constants.restoreBuiltInDisplayControlID, + title: localization.string( + "displayDisable.action.restore", + defaultValue: "恢复内建显示屏" + ), + iconName: "display", + isEnabled: true + )) + } + return controls + case .disabled: + return [displayDisableActionControl( + id: Constants.restoreBuiltInDisplayControlID, + title: localization.string( + "displayDisable.action.restore", + defaultValue: "恢复内建显示屏" + ), + iconName: "display", + isEnabled: snapshot.isRestoreAllowed + )] + case .available, .failed, .busy: + var controls: [PluginPanelControl] = [] + controls.append(displayDisableActionControl( + id: Constants.disableBuiltInDisplayControlID, + title: localization.string( + "displayDisable.action.disable", + defaultValue: "关闭内建显示屏" + ), + iconName: "display", + isEnabled: snapshot.isDisableAllowed + )) + if snapshot.isRestoreAllowed { + controls.append(displayDisableActionControl( + id: Constants.restoreBuiltInDisplayControlID, + title: localization.string( + "displayDisable.action.restore", + defaultValue: "恢复内建显示屏" + ), + iconName: "display", + isEnabled: true + )) + } + return controls + } + } + + private func displayDisableActionControl( + id: String, + title: String, + iconName: String, + isEnabled: Bool + ) -> PluginPanelControl { + PluginPanelControl( + id: id, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: title, + actionIconSystemName: iconName, + showsLeadingDivider: true, + isEnabled: isEnabled + ) + } + + private func handleInvokeAction(controlID: String) { + switch controlID { + case Constants.disableBuiltInDisplayControlID: + displayDisableActionTask?.cancel() + let coordinator = displayDisableCoordinator + displayDisableActionTask = Task { @MainActor [weak self, coordinator] in + await coordinator.disableBuiltInDisplay() + self?.onStateChange?() + } + case Constants.restoreBuiltInDisplayControlID: + displayDisableActionTask?.cancel() + displayDisableCoordinator.restoreBuiltInDisplay() + onStateChange?() + default: + return + } + } + + private static func percentText(for brightness: Double) -> String { + "\(Int((brightness * 100).rounded()))%" + } + + private static func defaultDisplayDisableService() -> any DisplayDisableServicing { + SystemDisplayDisableService() + } + + private func shortcutDefinition(direction: DisplayBrightnessShortcutDirection) -> PluginShortcutDefinition { + let actionID = direction.actionID + let directionTitle = direction.title(localization: localization) + + return PluginShortcutDefinition( + id: actionID, + title: localization.format("shortcut.titleFormat", defaultValue: "%@亮度", directionTitle), + description: localization.format("shortcut.descriptionFormat", defaultValue: "%@显示器亮度。", directionTitle), + actionID: actionID, + scope: .global, + defaultBinding: nil, + isRequired: false, + settingsGroupID: Constants.shortcutGroupID, + settingsGroupTitle: localization.string( + "shortcut.settingsGroupTitle", + defaultValue: "亮度快捷键" + ), + settingsGroupDescription: localization.string( + "shortcut.settingsGroupDescription", + defaultValue: "按所选作用范围调整显示器亮度。" + ), + settingsControlTitle: directionTitle, + settingsControlSystemImage: direction.systemImage + ) + } + + private func startShortcutAction(id: String) { + guard shortcutSessions[id] == nil, + let direction = Self.shortcutDirection(for: id) + else { + return + } + + var snapshot = controller.snapshot() + if snapshot.displays.isEmpty { + controller.refresh() + snapshot = controller.snapshot() + } + + let targetDisplayIDs = shortcutTargetDisplayIDs(in: snapshot) + guard !targetDisplayIDs.isEmpty else { + return + } + + let action = DisplayBrightnessShortcutAction( + id: id, + direction: direction, + targetDisplayIDs: targetDisplayIDs + ) + let now = Date() + let initialStep = shortcutAcceleration.stepForPress(actionID: id, now: now) + applyShortcutAction(action, step: initialStep, phase: .changed) + scheduleShortcutRepeats(for: action, initialStep: initialStep, startDate: now) + } + + private func stopShortcutAction(id: String) { + guard let session = shortcutSessions.removeValue(forKey: id) else { + return + } + + session.task.cancel() + commitShortcutAction(session.action) + } + + private func stopAllShortcutActions() { + for session in shortcutSessions.values { + session.task.cancel() + } + shortcutSessions.removeAll() + } + + private func scheduleShortcutRepeats( + for action: DisplayBrightnessShortcutAction, + initialStep: Int, + startDate: Date + ) { + let sessionID = UUID() + let task = Task { @MainActor [weak self] in + await Self.sleep(seconds: Self.initialHoldDelay) + var repeatCount = 0 + + while !Task.isCancelled { + guard let self, + self.shortcutSessions[action.id]?.id == sessionID + else { + return + } + + guard repeatCount < Self.maximumHoldRepeatCount else { + self.stopShortcutAction(id: action.id) + return + } + + let elapsed = Date().timeIntervalSince(startDate) + let step = DisplayBrightnessShortcutAcceleration.stepForHold( + elapsed: elapsed, + baseline: initialStep + ) + self.applyShortcutAction(action, step: step, phase: .changed) + repeatCount += 1 + await Self.sleep(seconds: Self.repeatDelay) + } + } + + shortcutSessions[action.id] = DisplayBrightnessShortcutSession( + id: sessionID, + action: action, + task: task + ) + } + + private func applyShortcutAction( + _ action: DisplayBrightnessShortcutAction, + step: Int, + phase: PluginPanelAction.SliderPhase + ) { + let delta = Double(step) / 100 * action.direction.multiplier + for display in displays(for: action.targetDisplayIDs) { + controller.setBrightness(display.brightness + delta, for: display.id, phase: phase) + } + } + + private func commitShortcutAction(_ action: DisplayBrightnessShortcutAction) { + for display in displays(for: action.targetDisplayIDs) { + controller.setBrightness(display.brightness, for: display.id, phase: .ended) + } + } + + private func displays(for displayIDs: [CGDirectDisplayID]) -> [DisplayBrightnessDisplay] { + let displaysByID = Dictionary(uniqueKeysWithValues: controller.snapshot().displays.map { ($0.id, $0) }) + return displayIDs.compactMap { displaysByID[$0] } + } + + private func shortcutTargetDisplayIDs(in snapshot: DisplayBrightnessSnapshot) -> [CGDirectDisplayID] { + switch shortcutPreferences.targetMode { + case .followsMouse: + if let mouseDisplayID = mouseDisplayIDProvider(), + snapshot.displays.contains(where: { $0.id == mouseDisplayID }) { + return [mouseDisplayID] + } + + if let mainDisplay = snapshot.displays.first(where: { $0.display.isMain }) { + return [mainDisplay.id] + } + + return snapshot.displays.first.map { [$0.id] } ?? [] + case .allDisplays: + return snapshot.displays.map(\.id) + } + } + + private static var initialHoldDelay: TimeInterval { + systemKeyboardTiming( + key: "InitialKeyRepeat", + fallback: 25, + minimum: 0.22, + maximum: 0.55 + ) + } + + private static var repeatDelay: TimeInterval { + systemKeyboardTiming( + key: "KeyRepeat", + fallback: 6, + minimum: 0.075, + maximum: 0.16 + ) + } + + private static var maximumHoldRepeatCount: Int { 100 } + + private static func systemKeyboardTiming( + key: String, + fallback: Double, + minimum: TimeInterval, + maximum: TimeInterval + ) -> TimeInterval { + let rawValue = UserDefaults.standard.object(forKey: key) as? Double ?? fallback + return min(max(rawValue * 0.014, minimum), maximum) + } + + private static func sleep(seconds: TimeInterval) async { + let nanoseconds = UInt64(max(0, seconds) * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + } + + static func shortcutDirection(for actionID: String) -> DisplayBrightnessShortcutDirection? { + switch actionID { + case DisplayBrightnessShortcutDirection.decrease.actionID: + return .decrease + case DisplayBrightnessShortcutDirection.increase.actionID: + return .increase + default: + return nil + } + } + + private static func currentMouseDisplayID() -> CGDirectDisplayID? { + let mouseLocation = NSEvent.mouseLocation + guard let screen = NSScreen.screens.first(where: { $0.frame.contains(mouseLocation) }) else { + return nil + } + + return displayID(for: screen) + } + + private static func displayID(for screen: NSScreen) -> CGDirectDisplayID? { + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + return nil + } + + return screenNumber.uint32Value + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift new file mode 100644 index 0000000..f60edb9 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import MacToolsPluginKit + +struct DisplayBrightnessSettingsView: View { + @ObservedObject var preferences: DisplayBrightnessShortcutPreferences + let localization: PluginLocalization + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + shortcutSection + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var shortcutSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + Label( + localization.string("settings.shortcutTarget.sectionTitle", defaultValue: "作用范围"), + systemImage: "display.2" + ) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + + VStack(spacing: 0) { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("settings.shortcutTarget.title", defaultValue: "快捷键目标")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + + Text(preferences.targetMode.description(localization: localization)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Picker("", selection: $preferences.targetMode) { + ForEach(DisplayBrightnessShortcutPreferences.TargetMode.allCases) { mode in + Text(mode.title(localization: localization)).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + .pluginSettingsListRowPadding(interactive: true) + } + .pluginSettingsCardBackground(.host) + } + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift new file mode 100644 index 0000000..06604a4 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessShortcutPreferences.swift @@ -0,0 +1,53 @@ +import Combine +import Foundation +import MacToolsPluginKit + +@MainActor +final class DisplayBrightnessShortcutPreferences: ObservableObject { + enum TargetMode: String, CaseIterable, Identifiable { + case followsMouse + case allDisplays + + var id: String { rawValue } + + func title(localization: PluginLocalization) -> String { + switch self { + case .followsMouse: + localization.string("settings.shortcutTarget.followsMouse", defaultValue: "跟随鼠标") + case .allDisplays: + localization.string("settings.shortcutTarget.allDisplays", defaultValue: "所有显示器") + } + } + + func description(localization: PluginLocalization) -> String { + switch self { + case .followsMouse: + localization.string( + "settings.shortcutTarget.followsMouseDescription", + defaultValue: "只调整光标所在屏幕。" + ) + case .allDisplays: + localization.string( + "settings.shortcutTarget.allDisplaysDescription", + defaultValue: "同步调整所有可控显示器。" + ) + } + } + } + + @Published var targetMode: TargetMode { + didSet { storage.set(targetMode.rawValue, forKey: Keys.targetMode) } + } + + private let storage: PluginStorage + + private enum Keys { + static let targetMode = "shortcutTargetMode" + } + + init(storage: PluginStorage) { + self.storage = storage + self.targetMode = TargetMode(rawValue: storage.string(forKey: Keys.targetMode) ?? "") + ?? .followsMouse + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayDisableCoordinator.swift b/Plugins/DisplayBrightness/Sources/DisplayDisableCoordinator.swift new file mode 100644 index 0000000..7d45c0f --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayDisableCoordinator.swift @@ -0,0 +1,401 @@ +import CoreGraphics +import Foundation +import MacToolsPluginKit + +@MainActor +protocol DisplayDisableCoordinating: AnyObject { + var snapshot: DisplayDisableSnapshot { get } + + func refreshSnapshot() + func disableBuiltInDisplay() async + func restoreBuiltInDisplay() + func reconcileTopology() async +} + +@MainActor +final class DisplayDisableCoordinator: DisplayDisableCoordinating { + private let service: any DisplayDisableServicing + private let store: any DisplayDisableStateStoring + private let localization: PluginLocalization + private let verificationSettleDelay: Duration + + private(set) var snapshot: DisplayDisableSnapshot + + init( + service: any DisplayDisableServicing, + store: any DisplayDisableStateStoring, + localization: PluginLocalization = DisplayBrightnessLocalization.fallback, + verificationSettleDelay: Duration = .milliseconds(800) + ) { + self.service = service + self.store = store + self.localization = localization + self.verificationSettleDelay = verificationSettleDelay + self.snapshot = Self.unsupportedSnapshot(localization: localization) + refreshSnapshot() + } + + func refreshSnapshot() { + guard service.isSupported else { + snapshot = Self.unsupportedSnapshot(localization: localization) + return + } + + let displays = service.listDisplays() + if store.snapshot != nil { + // On reinitialization (app restart, plugin reload, or plugin rebuild), keep the + // built-in display disabled while a survivor is still online. Restore only after + // every external survivor is gone, not merely because this session did not disable it. + reconcileStoredSnapshot(displays: displays) + return + } + + updateAvailableSnapshot(displays: displays) + } + + private func updateAvailableSnapshot(displays: [DisplayDisableDisplay]) { + guard displays.contains(where: \.isBuiltin) else { + snapshot = DisplayDisableSnapshot( + status: .unavailable, + isDisableAllowed: false, + isRestoreAllowed: store.snapshot != nil, + externalDisplayCount: externalSurvivors(in: displays).count, + message: localization.string( + "displayDisable.message.noBuiltInDisplay", + defaultValue: "未检测到内建显示屏" + ) + ) + return + } + + let externalCount = externalSurvivors(in: displays).count + snapshot = DisplayDisableSnapshot( + status: .available, + isDisableAllowed: externalCount > 0, + isRestoreAllowed: store.snapshot != nil, + externalDisplayCount: externalCount, + message: externalCount > 0 + ? nil + : localization.string( + "displayDisable.message.needsExternalDisplay", + defaultValue: "连接外接显示器后可关闭内建显示屏" + ) + ) + } + + func disableBuiltInDisplay() async { + guard service.isSupported else { + snapshot = Self.unsupportedSnapshot(localization: localization) + return + } + + let displays = service.listDisplays() + guard let builtIn = displays.first(where: \.isBuiltin) else { + snapshot = DisplayDisableSnapshot( + status: .unavailable, + isDisableAllowed: false, + isRestoreAllowed: store.snapshot != nil, + externalDisplayCount: externalSurvivors(in: displays).count, + message: localization.string( + "displayDisable.message.noBuiltInDisplay", + defaultValue: "未检测到内建显示屏" + ) + ) + return + } + + let survivors = externalSurvivors(in: displays) + guard !survivors.isEmpty else { + snapshot = DisplayDisableSnapshot( + status: .available, + isDisableAllowed: false, + isRestoreAllowed: store.snapshot != nil, + externalDisplayCount: 0, + message: localization.string( + "displayDisable.message.needsExternalDisplay", + defaultValue: "连接外接显示器后可关闭内建显示屏" + ) + ) + return + } + + guard !builtIn.isInMirrorSet else { + snapshot = DisplayDisableSnapshot( + status: .available, + isDisableAllowed: false, + isRestoreAllowed: store.snapshot != nil, + externalDisplayCount: survivors.count, + message: localization.string( + "displayDisable.message.mirrorUnsupported", + defaultValue: "镜像显示时暂不支持关闭内建显示屏" + ) + ) + return + } + + let recoverySnapshot = DisplayDisableRecoverySnapshot( + createdAt: Date(), + builtInDisplayID: builtIn.id, + vendorNumber: builtIn.vendorNumber, + modelNumber: builtIn.modelNumber, + serialNumber: builtIn.serialNumber, + survivorDisplayIDs: survivors.map(\.id), + survivorIdentities: survivors.map(survivorIdentity(for:)), + originalMainDisplayID: nil + ) + store.snapshot = recoverySnapshot + + do { + try service.setDisplay(builtIn.id, enabled: false) + } catch { + store.snapshot = nil + snapshot = DisplayDisableSnapshot( + status: .failed, + isDisableAllowed: true, + isRestoreAllowed: false, + externalDisplayCount: survivors.count, + message: localization.string( + "displayDisable.message.disableFailed", + defaultValue: "关闭内建显示屏失败" + ) + ) + return + } + + do { + try await Task.sleep(for: verificationSettleDelay) + } catch { + return + } + + guard !Task.isCancelled else { + return + } + + let verifiedDisplays = service.listDisplays() + if disableSucceeded(targetID: builtIn.id, survivorIDs: Set(survivors.map(\.id)), displays: verifiedDisplays) { + snapshot = DisplayDisableSnapshot( + status: .disabled, + isDisableAllowed: false, + isRestoreAllowed: true, + externalDisplayCount: survivors.count, + message: nil + ) + return + } + + guard !Task.isCancelled else { + return + } + + try? service.setDisplay(builtIn.id, enabled: true) + snapshot = DisplayDisableSnapshot( + status: .failed, + isDisableAllowed: true, + isRestoreAllowed: true, + externalDisplayCount: externalSurvivors(in: verifiedDisplays).count, + message: localization.string( + "displayDisable.message.disableFailedRestored", + defaultValue: "关闭内建显示屏失败,已尝试恢复" + ) + ) + } + + func restoreBuiltInDisplay() { + guard let recoverySnapshot = store.snapshot else { + restoreCurrentBuiltInDisplay() + return + } + + let displays = service.listDisplays() + let restoreCandidates = restoreTargetIDs( + storedID: recoverySnapshot.builtInDisplayID, + displays: displays + ) + + for displayID in restoreCandidates { + do { + try service.setDisplay(displayID, enabled: true) + store.snapshot = nil + updateAvailableSnapshot(displays: service.listDisplays()) + return + } catch { + continue + } + } + + snapshot = DisplayDisableSnapshot( + status: .failed, + isDisableAllowed: false, + isRestoreAllowed: true, + externalDisplayCount: externalSurvivors(in: displays).count, + message: localization.string( + "displayDisable.message.restoreFailed", + defaultValue: "恢复内建显示屏失败" + ) + ) + } + + private func restoreCurrentBuiltInDisplay() { + let displays = service.listDisplays() + guard let builtIn = displays.first(where: \.isBuiltin) else { + updateAvailableSnapshot(displays: displays) + return + } + + do { + try service.setDisplay(builtIn.id, enabled: true) + store.snapshot = nil + updateAvailableSnapshot(displays: service.listDisplays()) + } catch { + snapshot = DisplayDisableSnapshot( + status: .failed, + isDisableAllowed: false, + isRestoreAllowed: false, + externalDisplayCount: externalSurvivors(in: displays).count, + message: localization.string( + "displayDisable.message.restoreFailed", + defaultValue: "恢复内建显示屏失败" + ) + ) + } + } + + func reconcileTopology() async { + let displays = service.listDisplays() + guard store.snapshot != nil else { + updateAvailableSnapshot(displays: displays) + return + } + + // Keep the built-in display disabled while any original external survivor remains online. + // Restore only after all survivors disappear, which indicates a real external disconnect. + // A time window is not reliable: sleep/wake or resolution changes can otherwise trigger + // an unintended restore during benign topology transitions. + reconcileStoredSnapshot(displays: displays) + } + + private func reconcileStoredSnapshot(displays: [DisplayDisableDisplay]) { + guard let recoverySnapshot = store.snapshot else { + updateAvailableSnapshot(displays: displays) + return + } + + if let builtIn = restoreTarget(storedID: recoverySnapshot.builtInDisplayID, displays: displays), + builtIn.isActive || builtIn.isVisibleToAppKit { + store.snapshot = nil + updateAvailableSnapshot(displays: displays) + return + } + + if !storedSurvivorRemains(snapshot: recoverySnapshot, displays: displays) { + restoreBuiltInDisplay() + return + } + + snapshot = DisplayDisableSnapshot( + status: .disabled, + isDisableAllowed: false, + isRestoreAllowed: true, + externalDisplayCount: externalSurvivors(in: displays).count, + message: nil + ) + } + + private func externalSurvivors(in displays: [DisplayDisableDisplay]) -> [DisplayDisableDisplay] { + displays.filter { display in + !display.isBuiltin && (display.isActive || display.isVisibleToAppKit) + } + } + + private func storedSurvivorRemains( + snapshot: DisplayDisableRecoverySnapshot, + displays: [DisplayDisableDisplay] + ) -> Bool { + let onlineSurvivors = externalSurvivors(in: displays) + + if let identities = snapshot.survivorIdentities, !identities.isEmpty { + return identities.contains { identity in + onlineSurvivors.contains { matchesSurvivor(identity: identity, display: $0) } + } + } + + // Older snapshots do not contain EDID identity data, so fall back to displayID matching. + let survivorIDs = Set(snapshot.survivorDisplayIDs) + return onlineSurvivors.contains { survivorIDs.contains($0.id) } + } + + // Prefer EDID identity (vendor/model/serial) so a CGDirectDisplayID change after external + // display sleep/wake is not misclassified as a disconnect. Fall back to displayID when EDID + // data is unavailable. + private func matchesSurvivor( + identity: DisplaySurvivorIdentity, + display: DisplayDisableDisplay + ) -> Bool { + if identity.vendorNumber != nil || identity.modelNumber != nil || identity.serialNumber != nil { + return identity.vendorNumber == display.vendorNumber + && identity.modelNumber == display.modelNumber + && identity.serialNumber == display.serialNumber + } + return identity.id == display.id + } + + private func survivorIdentity(for display: DisplayDisableDisplay) -> DisplaySurvivorIdentity { + DisplaySurvivorIdentity( + id: display.id, + vendorNumber: display.vendorNumber, + modelNumber: display.modelNumber, + serialNumber: display.serialNumber + ) + } + + private func disableSucceeded( + targetID: CGDirectDisplayID, + survivorIDs: Set, + displays: [DisplayDisableDisplay] + ) -> Bool { + let targetIsGone = displays.first(where: { $0.id == targetID }).map { + !$0.isActive && !$0.isVisibleToAppKit + } ?? true + let survivorRemains = displays.contains { display in + survivorIDs.contains(display.id) && (display.isActive || display.isVisibleToAppKit) + } + return targetIsGone && survivorRemains + } + + private func restoreTarget( + storedID: CGDirectDisplayID, + displays: [DisplayDisableDisplay] + ) -> DisplayDisableDisplay? { + if let storedDisplay = displays.first(where: { $0.id == storedID && $0.isBuiltin }) { + return storedDisplay + } + + return displays.first(where: \.isBuiltin) + } + + private func restoreTargetIDs( + storedID: CGDirectDisplayID, + displays: [DisplayDisableDisplay] + ) -> [CGDirectDisplayID] { + var ids = [storedID] + if let visibleBuiltInID = displays.first(where: \.isBuiltin)?.id, + visibleBuiltInID != storedID { + ids.append(visibleBuiltInID) + } + return ids + } + + private static func unsupportedSnapshot(localization: PluginLocalization) -> DisplayDisableSnapshot { + DisplayDisableSnapshot( + status: .unsupported, + isDisableAllowed: false, + isRestoreAllowed: false, + externalDisplayCount: 0, + message: localization.string( + "displayDisable.unsupported", + defaultValue: "当前系统不支持关闭内建显示屏" + ) + ) + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayDisableModels.swift b/Plugins/DisplayBrightness/Sources/DisplayDisableModels.swift new file mode 100644 index 0000000..61376d8 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayDisableModels.swift @@ -0,0 +1,135 @@ +import CoreGraphics +import Foundation + +struct DisplayDisableDisplay: Equatable { + let id: CGDirectDisplayID + let name: String + let isBuiltin: Bool + let isActive: Bool + let isInMirrorSet: Bool + let isVisibleToAppKit: Bool + let vendorNumber: UInt32? + let modelNumber: UInt32? + let serialNumber: UInt32? + + init( + id: CGDirectDisplayID, + name: String, + isBuiltin: Bool, + isActive: Bool, + isInMirrorSet: Bool, + isVisibleToAppKit: Bool, + vendorNumber: UInt32? = nil, + modelNumber: UInt32? = nil, + serialNumber: UInt32? = nil + ) { + self.id = id + self.name = name + self.isBuiltin = isBuiltin + self.isActive = isActive + self.isInMirrorSet = isInMirrorSet + self.isVisibleToAppKit = isVisibleToAppKit + self.vendorNumber = vendorNumber + self.modelNumber = modelNumber + self.serialNumber = serialNumber + } +} + +extension DisplayDisableDisplay { + func withActive(_ value: Bool) -> DisplayDisableDisplay { + DisplayDisableDisplay( + id: id, + name: name, + isBuiltin: isBuiltin, + isActive: value, + isInMirrorSet: isInMirrorSet, + isVisibleToAppKit: isVisibleToAppKit, + vendorNumber: vendorNumber, + modelNumber: modelNumber, + serialNumber: serialNumber + ) + } + + func withVisibleToAppKit(_ value: Bool) -> DisplayDisableDisplay { + DisplayDisableDisplay( + id: id, + name: name, + isBuiltin: isBuiltin, + isActive: isActive, + isInMirrorSet: isInMirrorSet, + isVisibleToAppKit: value, + vendorNumber: vendorNumber, + modelNumber: modelNumber, + serialNumber: serialNumber + ) + } +} + +enum DisplayDisableStatus: Equatable { + case available + case disabled + case unavailable + case unsupported + case busy + case failed +} + +struct DisplayDisableSnapshot: Equatable { + let status: DisplayDisableStatus + let isDisableAllowed: Bool + let isRestoreAllowed: Bool + let externalDisplayCount: Int + let message: String? + + static let unsupported = DisplayDisableSnapshot( + status: .unsupported, + isDisableAllowed: false, + isRestoreAllowed: false, + externalDisplayCount: 0, + message: DisplayBrightnessLocalization.string( + "displayDisable.unsupported", + defaultValue: "当前系统不支持关闭内建显示屏" + ) + ) +} + +struct DisplaySurvivorIdentity: Codable, Equatable { + let id: CGDirectDisplayID + let vendorNumber: UInt32? + let modelNumber: UInt32? + let serialNumber: UInt32? +} + +struct DisplayDisableRecoverySnapshot: Codable, Equatable { + let createdAt: Date + let builtInDisplayID: CGDirectDisplayID + let vendorNumber: UInt32? + let modelNumber: UInt32? + let serialNumber: UInt32? + let survivorDisplayIDs: [CGDirectDisplayID] + // Stable EDID identity for each external survivor. CGDirectDisplayID can change after sleep/wake, + // so restore decisions prefer identity matching and fall back to IDs. Optional for snapshots + // persisted before this field existed. + let survivorIdentities: [DisplaySurvivorIdentity]? + let originalMainDisplayID: CGDirectDisplayID? + + init( + createdAt: Date, + builtInDisplayID: CGDirectDisplayID, + vendorNumber: UInt32?, + modelNumber: UInt32?, + serialNumber: UInt32?, + survivorDisplayIDs: [CGDirectDisplayID], + survivorIdentities: [DisplaySurvivorIdentity]? = nil, + originalMainDisplayID: CGDirectDisplayID? + ) { + self.createdAt = createdAt + self.builtInDisplayID = builtInDisplayID + self.vendorNumber = vendorNumber + self.modelNumber = modelNumber + self.serialNumber = serialNumber + self.survivorDisplayIDs = survivorDisplayIDs + self.survivorIdentities = survivorIdentities + self.originalMainDisplayID = originalMainDisplayID + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayDisableService.swift b/Plugins/DisplayBrightness/Sources/DisplayDisableService.swift new file mode 100644 index 0000000..3a31126 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayDisableService.swift @@ -0,0 +1,149 @@ +import CoreGraphics +import Foundation +import AppKit + +@_silgen_name("MTConfigureDisplayEnabled") +private func MTConfigureDisplayEnabled( + _ config: CGDisplayConfigRef, + _ display: CGDirectDisplayID, + _ enabled: Bool +) -> CGError + +@_silgen_name("MTDisplayEnableSPIAvailable") +private func MTDisplayEnableSPIAvailable() -> Bool + +@MainActor +protocol DisplayDisableServicing: AnyObject { + var isSupported: Bool { get } + + func listDisplays() -> [DisplayDisableDisplay] + func setDisplay(_ displayID: CGDirectDisplayID, enabled: Bool) throws +} + +@MainActor +protocol DisplayDisableStateStoring: AnyObject { + var snapshot: DisplayDisableRecoverySnapshot? { get set } +} + +enum DisplayDisableServiceError: Error, LocalizedError { + case privateSPIUnavailable + case beginConfigurationFailed(CGError) + case configureDisplayFailed(CGError) + case completeConfigurationFailed(CGError) + + var errorDescription: String? { + switch self { + case .privateSPIUnavailable: + return DisplayBrightnessLocalization.string( + "displayDisable.unsupported", + defaultValue: "当前系统不支持关闭内建显示屏" + ) + case .beginConfigurationFailed: + return DisplayBrightnessLocalization.string( + "displayDisable.error.beginConfiguration", + defaultValue: "无法开始显示器配置" + ) + case .configureDisplayFailed: + return DisplayBrightnessLocalization.string( + "displayDisable.error.configureDisplay", + defaultValue: "无法切换显示器状态" + ) + case .completeConfigurationFailed: + return DisplayBrightnessLocalization.string( + "displayDisable.error.completeConfiguration", + defaultValue: "无法提交显示器配置" + ) + } + } +} + +@MainActor +final class SystemDisplayDisableService: DisplayDisableServicing { + var isSupported: Bool { + MTDisplayEnableSPIAvailable() + } + + func listDisplays() -> [DisplayDisableDisplay] { + let activeDisplayIDs = Set(Self.activeDisplayIDs()) + let visibleDisplayIDs = Set(Self.visibleAppKitDisplayIDs()) + + return Self.onlineDisplayIDs().enumerated().map { index, displayID in + let screen = NSScreen.screens.first(where: { screen in + Self.displayID(for: screen) == displayID + }) + let vendorNumber = CGDisplayVendorNumber(displayID) + let modelNumber = CGDisplayModelNumber(displayID) + let serialNumber = CGDisplaySerialNumber(displayID) + + return DisplayDisableDisplay( + id: displayID, + name: screen?.localizedName ?? "Display \(index + 1)", + isBuiltin: CGDisplayIsBuiltin(displayID) != 0, + isActive: activeDisplayIDs.contains(displayID), + isInMirrorSet: CGDisplayIsInMirrorSet(displayID) != 0, + isVisibleToAppKit: visibleDisplayIDs.contains(displayID), + vendorNumber: vendorNumber == 0 ? nil : vendorNumber, + modelNumber: modelNumber == 0 ? nil : modelNumber, + serialNumber: serialNumber == 0 ? nil : serialNumber + ) + } + } + + func setDisplay(_ displayID: CGDirectDisplayID, enabled: Bool) throws { + guard isSupported else { + throw DisplayDisableServiceError.privateSPIUnavailable + } + + var config: CGDisplayConfigRef? + let beginError = CGBeginDisplayConfiguration(&config) + guard beginError == .success, let config else { + throw DisplayDisableServiceError.beginConfigurationFailed(beginError) + } + + var committed = false + defer { + if !committed { + CGCancelDisplayConfiguration(config) + } + } + + let configureError = MTConfigureDisplayEnabled(config, displayID, enabled) + guard configureError == .success else { + throw DisplayDisableServiceError.configureDisplayFailed(configureError) + } + + let completeError = CGCompleteDisplayConfiguration(config, .forSession) + committed = completeError == .success + guard completeError == .success else { + throw DisplayDisableServiceError.completeConfigurationFailed(completeError) + } + } + + private static func onlineDisplayIDs() -> [CGDirectDisplayID] { + var count: UInt32 = 0 + CGGetOnlineDisplayList(0, nil, &count) + + var displayIDs = Array(repeating: CGDirectDisplayID(), count: Int(count)) + CGGetOnlineDisplayList(count, &displayIDs, &count) + return Array(displayIDs.prefix(Int(count))) + } + + private static func activeDisplayIDs() -> [CGDirectDisplayID] { + var count: UInt32 = 0 + CGGetActiveDisplayList(0, nil, &count) + + var displayIDs = Array(repeating: CGDirectDisplayID(), count: Int(count)) + CGGetActiveDisplayList(count, &displayIDs, &count) + return Array(displayIDs.prefix(Int(count))) + } + + private static func visibleAppKitDisplayIDs() -> [CGDirectDisplayID] { + NSScreen.screens.compactMap(displayID(for:)) + } + + private static func displayID(for screen: NSScreen) -> CGDirectDisplayID? { + ( + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber + )?.uint32Value + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayDisableStateStore.swift b/Plugins/DisplayBrightness/Sources/DisplayDisableStateStore.swift new file mode 100644 index 0000000..71031df --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayDisableStateStore.swift @@ -0,0 +1,42 @@ +import Foundation +import OSLog + +@MainActor +final class UserDefaultsDisplayDisableStateStore: DisplayDisableStateStoring { + private enum Constants { + static let key = "DisplayBrightness.DisplayDisableRecoverySnapshot" + } + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", + category: "DisplayDisableStateStore" + ) + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + var snapshot: DisplayDisableRecoverySnapshot? { + get { + guard let data = userDefaults.data(forKey: Constants.key) else { + return nil + } + + return try? JSONDecoder().decode(DisplayDisableRecoverySnapshot.self, from: data) + } + set { + guard let newValue else { + userDefaults.removeObject(forKey: Constants.key) + return + } + + do { + let data = try JSONEncoder().encode(newValue) + userDefaults.set(data, forKey: Constants.key) + } catch { + logger.error("failed to encode display disable recovery snapshot: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayEnableSPI.c b/Plugins/DisplayBrightness/Sources/DisplayEnableSPI.c new file mode 100644 index 0000000..19e3721 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayEnableSPI.c @@ -0,0 +1,73 @@ +#include "DisplayEnableSPI.h" + +#include + +typedef CGError (*MTConfigureDisplayEnabledFn)( + CGDisplayConfigRef config, + CGDirectDisplayID display, + bool enabled +); + +static MTConfigureDisplayEnabledFn MTResolveConfigureDisplayEnabled(void) { + // The Swift caller is @MainActor-isolated, so this lazy cache is only + // touched from one thread. Concurrent calls would race on these statics. + static MTConfigureDisplayEnabledFn cachedFunction = NULL; + static bool didResolve = false; + + if (didResolve) { + return cachedFunction; + } + + didResolve = true; + + void *skyLight = dlopen( + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", + RTLD_LAZY + ); + + if (skyLight) { + cachedFunction = (MTConfigureDisplayEnabledFn)dlsym( + skyLight, + "SLSConfigureDisplayEnabled" + ); + } + + if (!cachedFunction) { + void *coreGraphics = dlopen( + "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", + RTLD_LAZY + ); + if (coreGraphics) { + cachedFunction = (MTConfigureDisplayEnabledFn)dlsym( + coreGraphics, + "CGSConfigureDisplayEnabled" + ); + } + } + + if (!cachedFunction) { + cachedFunction = (MTConfigureDisplayEnabledFn)dlsym( + RTLD_DEFAULT, + "CGSConfigureDisplayEnabled" + ); + } + + return cachedFunction; +} + +bool MTDisplayEnableSPIAvailable(void) { + return MTResolveConfigureDisplayEnabled() != NULL; +} + +CGError MTConfigureDisplayEnabled( + CGDisplayConfigRef config, + CGDirectDisplayID display, + bool enabled +) { + MTConfigureDisplayEnabledFn function = MTResolveConfigureDisplayEnabled(); + if (!function) { + return kCGErrorFailure; + } + + return function(config, display, enabled); +} diff --git a/Plugins/DisplayBrightness/Sources/DisplayEnableSPI.h b/Plugins/DisplayBrightness/Sources/DisplayEnableSPI.h new file mode 100644 index 0000000..6b64cf3 --- /dev/null +++ b/Plugins/DisplayBrightness/Sources/DisplayEnableSPI.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include + +CGError MTConfigureDisplayEnabled(CGDisplayConfigRef config, CGDirectDisplayID display, bool enabled); +bool MTDisplayEnableSPIAvailable(void); diff --git a/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift b/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift new file mode 100644 index 0000000..8d2c03d --- /dev/null +++ b/Plugins/DisplayBrightness/Tests/DisplayBrightnessPluginTests.swift @@ -0,0 +1,218 @@ +import XCTest +@testable import MacTools +@testable import DisplayBrightnessPlugin + +@MainActor +final class DisplayBrightnessPluginTests: XCTestCase { + func testParseDisplayIDExtractsNumericIdentifier() { + XCTAssertEqual( + DisplayBrightnessPlugin.parseDisplayID(from: "display.42.brightness"), + 42 + ) + } + + func testParseDisplayIDRejectsUnexpectedControlID() { + XCTAssertNil(DisplayBrightnessPlugin.parseDisplayID(from: "display.42")) + XCTAssertNil(DisplayBrightnessPlugin.parseDisplayID(from: "brightness.42")) + XCTAssertNil(DisplayBrightnessPlugin.parseDisplayID(from: "display.foo.brightness")) + } + + func testEmptySnapshotDisablesPluginAndSuppressesDetail() { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot(displays: [], errorMessage: nil) + + let plugin = DisplayBrightnessPlugin(controller: controller) + plugin.handleAction(.setDisclosureExpanded(true)) + + let state = plugin.primaryPanelState + + XCTAssertEqual(state.subtitle, "未检测到可调节亮度的显示器") + XCTAssertFalse(state.isEnabled) + XCTAssertFalse(state.isExpanded) + XCTAssertNil(state.detail) + } + + func testSingleDisplaySummaryIncludesDisplayNameAndBrightness() { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay(id: 7, name: "Studio Display", brightness: 0.72) + ], + errorMessage: nil + ) + + let plugin = DisplayBrightnessPlugin(controller: controller) + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "Studio Display 72%") + } + + func testMultipleDisplaysSummaryUsesDisplayCount() { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay(id: 7, name: "Studio Display", brightness: 0.72), + makeBrightnessDisplay(id: 9, name: "LG UltraFine", brightness: 0.41) + ], + errorMessage: nil + ) + + let plugin = DisplayBrightnessPlugin(controller: controller) + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "2 个显示器") + } + + func testExpandedStateBuildsOneSliderPerDisplay() throws { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay(id: 7, name: "Studio Display", brightness: 0.72), + makeBrightnessDisplay(id: 9, name: "LG UltraFine", brightness: 0.41) + ], + errorMessage: nil + ) + + let plugin = DisplayBrightnessPlugin(controller: controller) + plugin.handleAction(.setDisclosureExpanded(true)) + + let controls = try XCTUnwrap(plugin.primaryPanelState.detail?.primaryControls) + let sliders = controls.filter { $0.kind == .slider } + + XCTAssertEqual(sliders.count, 2) + XCTAssertEqual(sliders.map(\.id), ["display.7.brightness", "display.9.brightness"]) + XCTAssertEqual(sliders.map(\.sectionTitle), ["Studio Display", "LG UltraFine"]) + XCTAssertEqual(sliders.map(\.valueLabel), ["72%", "41%"]) + XCTAssertEqual(sliders.first?.sliderBounds, 0...1) + XCTAssertEqual(sliders.first?.sliderStep, 0.01) + } + + func testShortcutDefinitionsIncludeDecreaseAndIncreaseOnly() { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay( + id: 7, + name: "Studio Display", + brightness: 0.72, + vendorNumber: 0x610, + modelNumber: 32, + serialNumber: 9001 + ) + ], + errorMessage: nil + ) + + let plugin = DisplayBrightnessPlugin(controller: controller) + let definitions = plugin.shortcutDefinitions + + XCTAssertEqual(definitions.count, 2) + XCTAssertEqual(definitions.map(\.id), ["display-brightness.decrease", "display-brightness.increase"]) + XCTAssertEqual(definitions.map(\.title), ["降低亮度", "增加亮度"]) + XCTAssertEqual(definitions.map(\.description), ["降低显示器亮度。", "增加显示器亮度。"]) + XCTAssertEqual(definitions.map(\.settingsGroupTitle), ["亮度快捷键", "亮度快捷键"]) + XCTAssertEqual( + definitions.map(\.settingsGroupDescription), + [ + "按所选作用范围调整显示器亮度。", + "按所选作用范围调整显示器亮度。" + ] + ) + XCTAssertEqual(definitions.map(\.settingsControlTitle), ["降低", "增加"]) + XCTAssertEqual(definitions.map(\.settingsControlSystemImage), ["sun.min.fill", "sun.max.fill"]) + XCTAssertEqual(definitions.map(\.settingsGroupID), ["display-brightness.shortcuts", "display-brightness.shortcuts"]) + XCTAssertEqual(definitions.map(\.sharedBindingGroupID), [nil, nil]) + XCTAssertEqual(definitions.map(\.scope), [.global, .global]) + } + + func testShortcutPreferencesDefaultToFollowingMouse() { + let preferences = DisplayBrightnessShortcutPreferences(storage: DisplayBrightnessMemoryStorage()) + + XCTAssertEqual(preferences.targetMode, .followsMouse) + } + + func testShortcutPreferencesPersistTargetMode() { + let storage = DisplayBrightnessMemoryStorage() + let preferences = DisplayBrightnessShortcutPreferences(storage: storage) + + preferences.targetMode = .allDisplays + + XCTAssertEqual(DisplayBrightnessShortcutPreferences(storage: storage).targetMode, .allDisplays) + } + + func testShortcutDirectionResolvesFixedActionIDs() { + XCTAssertEqual( + DisplayBrightnessPlugin.shortcutDirection(for: "display-brightness.decrease"), + .decrease + ) + XCTAssertEqual( + DisplayBrightnessPlugin.shortcutDirection(for: "display-brightness.increase"), + .increase + ) + XCTAssertNil(DisplayBrightnessPlugin.shortcutDirection(for: "display-brightness.display.7.increase")) + } + + func testShortcutFollowingMouseAdjustsOnlyMouseDisplay() throws { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay(id: 7, name: "Studio Display", brightness: 0.72), + makeBrightnessDisplay(id: 9, name: "LG UltraFine", brightness: 0.41) + ], + errorMessage: nil + ) + let preferences = DisplayBrightnessShortcutPreferences(storage: DisplayBrightnessMemoryStorage()) + preferences.targetMode = .followsMouse + let plugin = DisplayBrightnessPlugin( + controller: controller, + shortcutPreferences: preferences, + mouseDisplayIDProvider: { 9 } + ) + + plugin.handleShortcutEvent(id: "display-brightness.increase", phase: .pressed) + + XCTAssertEqual(controller.brightnessWrites.count, 1) + let write = try XCTUnwrap(controller.brightnessWrites.first) + XCTAssertEqual(write.displayID, 9) + XCTAssertEqual(write.value, 0.42, accuracy: 0.0001) + XCTAssertEqual(write.phase, .changed) + } + + func testShortcutAllDisplaysAdjustsEveryDisplay() { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay(id: 7, name: "Studio Display", brightness: 0.72), + makeBrightnessDisplay(id: 9, name: "LG UltraFine", brightness: 0.41) + ], + errorMessage: nil + ) + let preferences = DisplayBrightnessShortcutPreferences(storage: DisplayBrightnessMemoryStorage()) + preferences.targetMode = .allDisplays + let plugin = DisplayBrightnessPlugin( + controller: controller, + shortcutPreferences: preferences, + mouseDisplayIDProvider: { 9 } + ) + + plugin.handleShortcutEvent(id: "display-brightness.decrease", phase: .pressed) + + XCTAssertEqual(controller.brightnessWrites.count, 2) + XCTAssertEqual(controller.brightnessWrites.map(\.displayID), [7, 9]) + XCTAssertEqual(controller.brightnessWrites.map(\.phase), [.changed, .changed]) + XCTAssertEqual(controller.brightnessWrites[0].value, 0.71, accuracy: 0.0001) + XCTAssertEqual(controller.brightnessWrites[1].value, 0.40, accuracy: 0.0001) + } + + func testErrorMessageIsExposedFromSnapshot() { + let controller = MockDisplayBrightnessController() + controller.snapshotValue = DisplayBrightnessSnapshot( + displays: [ + makeBrightnessDisplay(id: 7, name: "Studio Display", brightness: 0.72) + ], + errorMessage: "调节失败:DDC 写入失败" + ) + + let plugin = DisplayBrightnessPlugin(controller: controller) + + XCTAssertEqual(plugin.primaryPanelState.errorMessage, "调节失败:DDC 写入失败") + } +} diff --git a/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift b/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift new file mode 100644 index 0000000..578d945 --- /dev/null +++ b/Plugins/DisplayBrightness/Tests/DisplayBrightnessTestSupport.swift @@ -0,0 +1,117 @@ +import CoreGraphics +import MacToolsPluginKit +@testable import MacTools +@testable import DisplayBrightnessPlugin + +func makeTestDisplay( + id: CGDirectDisplayID, + name: String, + isBuiltin: Bool = false, + isMain: Bool = false, + vendorNumber: UInt32? = nil, + modelNumber: UInt32? = nil, + serialNumber: UInt32? = nil +) -> DisplayInfo { + DisplayInfo( + id: id, + name: name, + isBuiltin: isBuiltin, + isMain: isMain, + vendorNumber: vendorNumber, + modelNumber: modelNumber, + serialNumber: serialNumber + ) +} + +func makeBrightnessDisplay( + id: CGDirectDisplayID, + name: String, + brightness: Double, + vendorNumber: UInt32? = nil, + modelNumber: UInt32? = nil, + serialNumber: UInt32? = nil +) -> DisplayBrightnessDisplay { + DisplayBrightnessDisplay( + display: makeTestDisplay( + id: id, + name: name, + vendorNumber: vendorNumber, + modelNumber: modelNumber, + serialNumber: serialNumber + ), + brightness: brightness, + isPendingWrite: false + ) +} + +@MainActor +final class MockDisplayBrightnessController: DisplayBrightnessControlling { + struct BrightnessWrite: Equatable { + let value: Double + let displayID: CGDirectDisplayID + let phase: PluginPanelAction.SliderPhase + } + + var onStateChange: (() -> Void)? + var snapshotValue = DisplayBrightnessSnapshot(displays: [], errorMessage: nil) + private(set) var brightnessWrites: [BrightnessWrite] = [] + + func refresh() {} + + func snapshot() -> DisplayBrightnessSnapshot { + snapshotValue + } + + func setBrightness( + _ value: Double, + for displayID: CGDirectDisplayID, + phase: PluginPanelAction.SliderPhase + ) { + brightnessWrites.append(BrightnessWrite(value: value, displayID: displayID, phase: phase)) + snapshotValue = DisplayBrightnessSnapshot( + displays: snapshotValue.displays.map { display in + guard display.id == displayID else { return display } + return DisplayBrightnessDisplay( + display: display.display, + brightness: min(max(value, 0), 1), + isPendingWrite: phase != .ended + ) + }, + errorMessage: snapshotValue.errorMessage + ) + } +} + +@MainActor +final class DisplayBrightnessMemoryStorage: 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) { + guard let value else { + removeObject(forKey: key) + return + } + + 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[legacyKey] else { + return + } + + values[key] = value + values.removeValue(forKey: legacyKey) + } +} diff --git a/Plugins/DisplayBrightness/plugin.json b/Plugins/DisplayBrightness/plugin.json new file mode 100644 index 0000000..60eb7b9 --- /dev/null +++ b/Plugins/DisplayBrightness/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "display-brightness", + "displayName": "显示器亮度", + "summary": "快速调节每个显示器的亮度", + "localizedMetadata": { + "ar": { + "displayName": "سطوع الشاشة", + "summary": "اضبط السطوع لكل شاشة بسرعة." + }, + "de": { + "displayName": "Displayhelligkeit", + "summary": "Passen Sie die Helligkeit für jedes Display schnell an." + }, + "en": { + "displayName": "Display Brightness", + "summary": "Adjust brightness for each display quickly." + }, + "es": { + "displayName": "Brillo de pantalla", + "summary": "Ajuste el brillo de cada pantalla rápidamente." + }, + "fr": { + "displayName": "Luminosité de l’écran", + "summary": "Ajustez rapidement la luminosité de chaque écran." + }, + "ja": { + "displayName": "ディスプレイの明るさ", + "summary": "各ディスプレイの明るさを素早く調整します。" + }, + "ko": { + "displayName": "디스플레이 밝기", + "summary": "각 디스플레이의 밝기를 빠르게 조정하세요." + }, + "pt": { + "displayName": "Brilho da tela", + "summary": "Ajuste o brilho de cada tela rapidamente." + }, + "ru": { + "displayName": "Яркость дисплея", + "summary": "Быстро отрегулируйте яркость каждого дисплея." + }, + "zh-Hans": { + "displayName": "显示器亮度", + "summary": "快速调节每个显示器的亮度" + }, + "zh-Hant": { + "displayName": "顯示器亮度", + "summary": "快速調節每個顯示器的亮度" + } + }, + "version": "1.0.11", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "DisplayBrightness.bundle", + "factoryClass": "DisplayBrightnessPlugin.DisplayBrightnessPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "DisplayBrightnessPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/DisplayBrightness/project.yml b/Plugins/DisplayBrightness/project.yml new file mode 100644 index 0000000..3fd34d1 --- /dev/null +++ b/Plugins/DisplayBrightness/project.yml @@ -0,0 +1,3 @@ +settings: + base: + OTHER_LDFLAGS: -framework IOKit diff --git a/Plugins/DisplayResolution/Bundle/DisplayResolutionPluginBundleEntrypoint.swift b/Plugins/DisplayResolution/Bundle/DisplayResolutionPluginBundleEntrypoint.swift new file mode 100644 index 0000000..ce746f8 --- /dev/null +++ b/Plugins/DisplayResolution/Bundle/DisplayResolutionPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import DisplayResolutionPlugin + +private let displayResolutionPluginFactoryAnchor: Any.Type = DisplayResolutionPluginFactory.self diff --git a/Plugins/DisplayResolution/Resources/Localizable.xcstrings b/Plugins/DisplayResolution/Resources/Localizable.xcstrings new file mode 100644 index 0000000..f25f9ea --- /dev/null +++ b/Plugins/DisplayResolution/Resources/Localizable.xcstrings @@ -0,0 +1,1142 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "دقة العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigeauflösung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display Resolution" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resolución de pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résolution d'affichage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ解像度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 해상도" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resolução da tela" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешение экрана" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示器分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示器分辨率" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض وتبديل الدقة لكل شاشة عرض." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigen Sie die Auflösung für jede Anzeige an und wechseln Sie sie." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View and switch the resolution for each display." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver y cambiar la resolución de cada pantalla." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affichez et changez la résolution de chaque écran." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各ディスプレイの解像度を表示および切り替えます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "각 디스플레이의 해상도를 보고 전환합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Visualize e altere a resolução de cada monitor." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотр и переключение разрешения для каждого дисплея." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看并切换每个显示器的分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看並切換每個顯示器的分辨率" + } + } + } + }, + "panel.subtitle.noDisplays": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف شاشات عرض متاحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine verfügbaren Displays erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No available displays detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectaron pantallas disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun affichage disponible détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能なディスプレイが検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 디스플레이가 감지되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma tela disponível detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступные дисплеи не обнаружены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到可用显示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到可用顯示器" + } + } + } + }, + "panel.subtitle.noModes": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف أي دقة متاحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine verfügbaren Auflösungen erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No available resolutions detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectaron resoluciones disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune résolution disponible détectée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能な解像度が検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 해상도가 감지되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma resolução disponível detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Доступные разрешения не обнаружены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到可用分辨率" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到可用分辨率" + } + } + } + }, + "display.main": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشاشة الرئيسية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hauptanzeige" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Main Display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pantalla principal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affichage principal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メインディスプレイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메인 디스플레이" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exibição principal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Основной дисплей" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "主螢幕" + } + } + } + }, + "panel.subtitle.displayCountFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يعرض %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d wird angezeigt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d displays" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d muestra" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d affiche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d が表示されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 표시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d é exibido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отображается %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个显示器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個顯示器" + } + } + } + }, + "display.currentResolution.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "panel.action.openSystemSettings": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح إعدادات العرض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigeeinstellungen öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Display Settings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir configuración de pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les paramètres d'affichage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ設定を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 설정 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir configurações de exibição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть настройки дисплея" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开系统显示器设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開系統顯示器設定" + } + } + } + }, + "resolution.badge.native": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " (أصلي)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " (Nativ)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " (Native)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " (Nativo)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " (Natif)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " (ネイティブ)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " (기본)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " (Nativo)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " (Родной)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " (原生)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " (原生)" + } + } + } + }, + "resolution.badge.default": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " (افتراضي)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " (Standard)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " (Default)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " (Predeterminado)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " (par défaut)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " (デフォルト)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " (기본값)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " (padrão)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " (по умолчанию)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " (默认)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " (預設)" + } + } + } + }, + "error.displayUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم قطع اتصال الشاشة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anzeige wurde getrennt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The display has disconnected." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La pantalla se ha desconectado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'écran s'est déconnecté." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイが切断されました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 연결이 끊어졌습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A tela foi desconectada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дисплей отключился." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示器已断开连接" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示器已斷開連接" + } + } + } + }, + "error.modeNotFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يعد وضع الدقة متاحًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Auflösungsmodus ist nicht mehr verfügbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The resolution mode is no longer available." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El modo de resolución ya no está disponible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le mode résolution n'est plus disponible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解像度モードは使用できなくなりました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "해상도 모드는 더 이상 사용할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O modo de resolução não está mais disponível." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Режим разрешения больше недоступен." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分辨率模式已失效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分辨率模式已失效" + } + } + } + }, + "error.beginConfigFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على بدء تكوين العرض." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigekonfiguration konnte nicht gestartet werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to start display configuration." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede iniciar la configuración de visualización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de démarrer la configuration de l'affichage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ構成を開始できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 구성을 시작할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível iniciar a configuração do monitor." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно начать настройку дисплея." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法开始显示配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法開始顯示配置" + } + } + } + }, + "error.configureFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على تكوين وضع العرض." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Anzeigemodus konnte nicht konfiguriert werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to configure the display mode." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede configurar el modo de visualización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de configurer le mode d'affichage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示モードを設定できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 모드를 구성할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível configurar o modo de exibição." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно настроить режим отображения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置显示模式失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "配置顯示模式失敗" + } + } + } + }, + "error.completeFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على تطبيق تكوين العرض." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigekonfiguration konnte nicht angewendet werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to apply display configuration." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede aplicar la configuración de visualización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'appliquer la configuration d'affichage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ構成を適用できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 구성을 적용할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível aplicar a configuração de exibição." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно применить конфигурацию дисплея." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "提交显示配置失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "提交顯示配置失敗" + } + } + } + }, + "error.applyFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التبديل: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Switch fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en el cambio: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du commutateur : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切り替えに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스위치 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na troca: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось переключиться: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換失敗:%@" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/DisplayResolution/Sources/DisplayModeFlags.swift b/Plugins/DisplayResolution/Sources/DisplayModeFlags.swift new file mode 100644 index 0000000..e489994 --- /dev/null +++ b/Plugins/DisplayResolution/Sources/DisplayModeFlags.swift @@ -0,0 +1,7 @@ +enum DisplayModeFlags { + static let valid: UInt32 = 0x0000_0001 + static let safe: UInt32 = 0x0000_0002 + static let `default`: UInt32 = 0x0000_0004 + static let notPreset: UInt32 = 0x0000_0200 + static let native: UInt32 = 0x0200_0000 +} diff --git a/Plugins/DisplayResolution/Sources/DisplayResolutionController.swift b/Plugins/DisplayResolution/Sources/DisplayResolutionController.swift new file mode 100644 index 0000000..eba913d --- /dev/null +++ b/Plugins/DisplayResolution/Sources/DisplayResolutionController.swift @@ -0,0 +1,157 @@ +import AppKit +import CoreGraphics +import Foundation +import OSLog +import MacToolsPluginKit + +@MainActor +protocol DisplayResolutionControlling { + func listConnectedDisplays() -> [DisplayInfo] + func listAvailableResolutions(for displayID: CGDirectDisplayID) -> [DisplayResolutionInfo] + func applyResolution( + _ info: DisplayResolutionInfo, + for displayID: CGDirectDisplayID + ) -> Result +} + +@MainActor +final class DisplayResolutionController { + private let displayProvider: DisplayProviding + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "DisplayResolutionController") + + init(displayProvider: DisplayProviding = SystemDisplayService()) { + self.displayProvider = displayProvider + } + + func listConnectedDisplays() -> [DisplayInfo] { + displayProvider.listConnectedDisplays() + } + + func listAvailableResolutions(for displayID: CGDirectDisplayID) -> [DisplayResolutionInfo] { + let options = [kCGDisplayShowDuplicateLowResolutionModes: kCFBooleanTrue] as CFDictionary + guard let rawModes = CGDisplayCopyAllDisplayModes(displayID, options) as? [CGDisplayMode] else { + logger.error("CGDisplayCopyAllDisplayModes returned nil for displayID \(displayID)") + return [] + } + + let currentID = CGDisplayCopyDisplayMode(displayID)?.ioDisplayModeID ?? 0 + + let candidates = rawModes.filter { mode in + let flags = mode.ioFlags + return mode.isUsableForDesktopGUI() && flags & DisplayModeFlags.safe != 0 && flags & DisplayModeFlags.notPreset == 0 + } + + let infos = candidates.map { mode in + let flags = mode.ioFlags + return DisplayResolutionInfo( + modeId: mode.ioDisplayModeID, + width: mode.width, + height: mode.height, + pixelWidth: mode.pixelWidth, + pixelHeight: mode.pixelHeight, + refreshRate: mode.refreshRate, + isHiDPI: mode.pixelWidth > mode.width, + isNative: flags & DisplayModeFlags.native != 0, + isDefault: flags & DisplayModeFlags.default != 0, + isCurrent: mode.ioDisplayModeID == currentID + ) + } + return Self.sortModes(Self.deduplicateModes(infos)) + } + + nonisolated static func deduplicateModes(_ modes: [DisplayResolutionInfo]) -> [DisplayResolutionInfo] { + var bestByKey: [String: DisplayResolutionInfo] = [:] + + for mode in modes { + let key = deduplicationKey(for: mode) + + if let existing = bestByKey[key] { + bestByKey[key] = preferredMode(existing: existing, candidate: mode) + } else { + bestByKey[key] = mode + } + } + + return Array(bestByKey.values) + } + + nonisolated static func sortModes(_ modes: [DisplayResolutionInfo]) -> [DisplayResolutionInfo] { + modes.sorted { + if $0.width != $1.width { + return $0.width > $1.width + } + + if $0.height != $1.height { + return $0.height > $1.height + } + + if $0.isCurrent != $1.isCurrent { + return $0.isCurrent + } + + if $0.isHiDPI != $1.isHiDPI { + return $0.isHiDPI + } + + return $0.refreshRate > $1.refreshRate + } + } + + @discardableResult + func applyResolution( + _ info: DisplayResolutionInfo, + for displayID: CGDirectDisplayID + ) -> Result { + guard let target = fetchCGDisplayMode(modeId: info.modeId, displayID: displayID) else { + return .failure(.modeNotFound(modeId: info.modeId)) + } + + var config: CGDisplayConfigRef? + let beginError = CGBeginDisplayConfiguration(&config) + guard beginError == .success, let config else { + return .failure(.beginConfigFailed(beginError)) + } + + let configureError = CGConfigureDisplayWithDisplayMode(config, displayID, target, nil) + guard configureError == .success else { + CGCancelDisplayConfiguration(config) + return .failure(.configureFailed(configureError)) + } + + let completeError = CGCompleteDisplayConfiguration(config, .permanently) + guard completeError == .success else { + return .failure(.completeFailed(completeError)) + } + + return .success(()) + } + + private func fetchCGDisplayMode(modeId: Int32, displayID: CGDirectDisplayID) -> CGDisplayMode? { + let options = [kCGDisplayShowDuplicateLowResolutionModes: kCFBooleanTrue] as CFDictionary + guard let modes = CGDisplayCopyAllDisplayModes(displayID, options) as? [CGDisplayMode] else { + return nil + } + return modes.first(where: { $0.ioDisplayModeID == modeId }) + } + + nonisolated private static func deduplicationKey(for mode: DisplayResolutionInfo) -> String { + "\(mode.width)x\(mode.height)" + } + + nonisolated private static func preferredMode( + existing: DisplayResolutionInfo, + candidate: DisplayResolutionInfo + ) -> DisplayResolutionInfo { + if existing.isCurrent != candidate.isCurrent { + return existing.isCurrent ? existing : candidate + } + + if existing.isHiDPI != candidate.isHiDPI { + return existing.isHiDPI ? existing : candidate + } + + return existing.refreshRate >= candidate.refreshRate ? existing : candidate + } +} + +extension DisplayResolutionController: DisplayResolutionControlling {} diff --git a/Plugins/DisplayResolution/Sources/DisplayResolutionInfo.swift b/Plugins/DisplayResolution/Sources/DisplayResolutionInfo.swift new file mode 100644 index 0000000..466e2d4 --- /dev/null +++ b/Plugins/DisplayResolution/Sources/DisplayResolutionInfo.swift @@ -0,0 +1,50 @@ +import CoreGraphics +import Foundation +import MacToolsPluginKit + +struct DisplayResolutionInfo: Equatable { + let modeId: Int32 + let width: Int + let height: Int + let pixelWidth: Int + let pixelHeight: Int + let refreshRate: Double + let isHiDPI: Bool + let isNative: Bool + let isDefault: Bool + let isCurrent: Bool + + var displayTitle: String { "\(width)×\(height)" } + var aspectRatio: Double { Double(width) / Double(height) } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.modeId == rhs.modeId + } +} + +enum DisplayResolutionError: Error, LocalizedError { + case displayUnavailable(displayID: CGDirectDisplayID) + case modeNotFound(modeId: Int32) + case beginConfigFailed(CGError) + case configureFailed(CGError) + case completeFailed(CGError) + + var errorDescription: String? { + localizedDescription(localization: PluginLocalization(bundle: .main)) + } + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case .displayUnavailable: + return localization.string("error.displayUnavailable", defaultValue: "显示器已断开连接") + case .modeNotFound: + return localization.string("error.modeNotFound", defaultValue: "分辨率模式已失效") + case .beginConfigFailed: + return localization.string("error.beginConfigFailed", defaultValue: "无法开始显示配置") + case .configureFailed: + return localization.string("error.configureFailed", defaultValue: "配置显示模式失败") + case .completeFailed: + return localization.string("error.completeFailed", defaultValue: "提交显示配置失败") + } + } +} diff --git a/Plugins/DisplayResolution/Sources/DisplayResolutionPlugin.swift b/Plugins/DisplayResolution/Sources/DisplayResolutionPlugin.swift new file mode 100644 index 0000000..d233edc --- /dev/null +++ b/Plugins/DisplayResolution/Sources/DisplayResolutionPlugin.swift @@ -0,0 +1,395 @@ +import AppKit +import CoreGraphics +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class DisplayResolutionPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + DisplayResolutionPluginProvider(context: context) + } +} + +@MainActor +private struct DisplayResolutionPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [DisplayResolutionPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +private enum ControlID { + static let displayNavigation = "display-navigation" + static let openSystemSettings = "display-open-system-settings" +} + +@MainActor +protocol DisplaySystemSettingsLauncher { + @discardableResult + func openDisplaySettings() -> Bool +} + +@MainActor +struct WorkspaceDisplaySystemSettingsLauncher: DisplaySystemSettingsLauncher { + // Opens System Settings > Displays. The URL works for Ventura+ System Settings and the + // legacy System Preferences app. + private static let systemDisplaySettingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.displays")! + + @discardableResult + func openDisplaySettings() -> Bool { + NSWorkspace.shared.open(Self.systemDisplaySettingsURL) + } +} + +@MainActor +final class DisplayResolutionPlugin: MacToolsPlugin, PluginPrimaryPanel, DisplayTopologyRefreshing { + private static let openSystemSettingsIcon = "gearshape" + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private var isExpanded = false + private var selectedDisplayID: CGDirectDisplayID? + private var lastErrorMessage: String? + private let controller: DisplayResolutionControlling + private let systemSettingsLauncher: DisplaySystemSettingsLauncher + private let localization: PluginLocalization + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "DisplayResolutionPlugin") + private var snapshot = DisplayResolutionSnapshot(displays: []) + + init( + controller: DisplayResolutionControlling = DisplayResolutionController(), + systemSettingsLauncher: DisplaySystemSettingsLauncher = WorkspaceDisplaySystemSettingsLauncher(), + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.controller = controller + self.systemSettingsLauncher = systemSettingsLauncher + self.metadata = PluginMetadata( + id: "display-resolution", + title: localization.string("metadata.title", defaultValue: "显示器分辨率"), + iconName: "display", + iconTint: Color(nsColor: .systemBlue), + order: 30, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "查看并切换每个显示器的分辨率" + ) + ) + refreshSnapshot() + } + + var primaryPanelState: PluginPanelState { + let displays = snapshot.displays + let panelDisplays = displays.filter { !$0.modes.isEmpty } + + if !panelDisplays.contains(where: { $0.display.id == selectedDisplayID }) { + selectedDisplayID = nil + } + + guard !displays.isEmpty else { + selectedDisplayID = nil + return PluginPanelState( + subtitle: localization.string("panel.subtitle.noDisplays", defaultValue: "未检测到可用显示器"), + isOn: false, + isExpanded: false, + isEnabled: false, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + guard !panelDisplays.isEmpty else { + selectedDisplayID = nil + return PluginPanelState( + subtitle: localization.string("panel.subtitle.noModes", defaultValue: "未检测到可用分辨率"), + isOn: false, + isExpanded: false, + isEnabled: false, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + return PluginPanelState( + subtitle: subtitleForRowState(panelDisplays), + isOn: false, + isExpanded: isExpanded, + isEnabled: true, + isVisible: true, + detail: isExpanded ? buildDetail(for: panelDisplays) : nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func refresh() { + refreshSnapshot() + } + + func refreshDisplayTopology() { + refreshSnapshot() + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(value): + isExpanded = value + if !value { + selectedDisplayID = nil + } + lastErrorMessage = nil + onStateChange?() + case let .setNavigationSelection(controlID, optionID): + guard + controlID == ControlID.displayNavigation, + let rawDisplayID = UInt32(optionID) + else { + return + } + + let displayID = CGDirectDisplayID(rawDisplayID) + selectedDisplayID = displayID + lastErrorMessage = nil + onStateChange?() + case let .clearNavigationSelection(controlID): + guard controlID == ControlID.displayNavigation else { + return + } + + selectedDisplayID = nil + lastErrorMessage = nil + onStateChange?() + case let .setSelection(controlID, optionID): + guard let displayID = Self.parseDisplayID(from: controlID), let modeId = Int32(optionID) else { + logger.error("invalid selection payload controlID=\(controlID, privacy: .public) optionID=\(optionID, privacy: .public)") + return + } + + refreshSnapshot() + + guard snapshot.displays.contains(where: { $0.display.id == displayID }) else { + handleApplyFailure(.displayUnavailable(displayID: displayID), displayID: displayID, modeId: modeId) + return + } + + guard let target = snapshot.displays.first(where: { $0.display.id == displayID })? + .allModes + .first(where: { $0.modeId == modeId }) else { + handleApplyFailure(.modeNotFound(modeId: modeId), displayID: displayID, modeId: modeId) + return + } + + logger.info("applying \(target.width)×\(target.height) on display \(displayID)") + + switch controller.applyResolution(target, for: displayID) { + case .success: + refreshSnapshot() + lastErrorMessage = nil + logger.info("applied \(target.width)×\(target.height) on display \(displayID)") + onStateChange?() + case .failure(let error): + handleApplyFailure(error, displayID: displayID, modeId: modeId) + } + case let .invokeAction(controlID): + guard controlID == ControlID.openSystemSettings else { + return + } + + let opened = systemSettingsLauncher.openDisplaySettings() + if !opened { + logger.error("failed to open system display settings via NSWorkspace") + } + case .setSwitch, .setDate, .setSlider: + return + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + nonisolated static func visibleModes(_ modes: [DisplayResolutionInfo]) -> [DisplayResolutionInfo] { + guard let first = modes.first else { return [] } + let nativeAspect = modes.first(where: { $0.isNative })?.aspectRatio ?? first.aspectRatio + return modes.filter { mode in + abs(mode.aspectRatio - nativeAspect) < 0.005 || mode.isCurrent + } + } + + nonisolated static func optionTitle( + for mode: DisplayResolutionInfo, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) -> String { + var title = "\(mode.width)×\(mode.height)" + if mode.isNative { + title += localization.string("resolution.badge.native", defaultValue: " (原生)") + } else if mode.isDefault { + title += localization.string("resolution.badge.default", defaultValue: " (默认)") + } else if mode.isHiDPI { + title += " (HiDPI)" + } else { + title += " (LoDPI)" + } + return title + } + + nonisolated static func parseDisplayID(from controlID: String) -> CGDirectDisplayID? { + let prefix = "display." + guard controlID.hasPrefix(prefix) else { return nil } + return CGDirectDisplayID(controlID.dropFirst(prefix.count)) + } + + private func refreshSnapshot() { + let displays = controller.listConnectedDisplays() + let panelDisplays = displays.map { display in + let modes = controller.listAvailableResolutions(for: display.id) + return PanelDisplay( + display: display, + modes: Self.visibleModes(modes), + allModes: modes + ) + } + + snapshot = DisplayResolutionSnapshot(displays: panelDisplays) + } + + private func subtitleForRowState(_ displays: [PanelDisplay]) -> String { + if displays.count == 1, let display = displays.first { + let current = display.modes.first(where: { $0.isCurrent }) + return current.map { + let displayName = display.display.isMain + ? localization.string("display.main", defaultValue: "主屏") + : display.display.name + return "\(displayName) \($0.displayTitle)" + } ?? metadata.defaultDescription + } + return localization.format("panel.subtitle.displayCountFormat", defaultValue: "%d 个显示器", displays.count) + } + + private func buildDetail(for displays: [PanelDisplay]) -> PluginPanelDetail { + let displayNavigation = PluginPanelControl( + id: ControlID.displayNavigation, + kind: .navigationList, + options: displays.map { display in + let currentSummary = display.modes.first(where: { $0.isCurrent })?.displayTitle + ?? localization.string("display.currentResolution.unknown", defaultValue: "未知") + + return PluginPanelControlOption( + id: String(display.display.id), + title: display.display.name, + subtitle: currentSummary + ) + }, + selectedOptionID: selectedDisplayID.map(String.init), + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + isEnabled: true + ) + + let openSystemSettings = PluginPanelControl( + id: ControlID.openSystemSettings, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.openSystemSettings", defaultValue: "打开系统显示器设置"), + actionIconSystemName: Self.openSystemSettingsIcon, + actionBehavior: .dismissBeforeHandling, + showsLeadingDivider: true, + isEnabled: true + ) + + let navigationSecondaryPanels = displays.map { display in + PluginPanelNavigationSecondaryPanel( + controlID: ControlID.displayNavigation, + optionID: String(display.display.id), + panel: secondaryPanel(for: display) + ) + } + let selectedSecondaryPanel = selectedDisplayID.flatMap { selectedID in + displays.first(where: { $0.display.id == selectedID }).map(secondaryPanel(for:)) + } + + return PluginPanelDetail( + primaryControls: [displayNavigation, openSystemSettings], + secondaryPanel: selectedSecondaryPanel, + navigationSecondaryPanels: navigationSecondaryPanels + ) + } + + private func secondaryPanel(for display: PanelDisplay) -> PluginPanelSecondaryPanel { + let resolutionControl = PluginPanelControl( + id: "display.\(display.display.id)", + kind: .selectList, + options: display.modes.map { + PluginPanelControlOption( + id: String($0.modeId), + title: Self.optionTitle(for: $0, localization: localization), + subtitle: nil + ) + }, + selectedOptionID: display.modes.first(where: { $0.isCurrent }).map { String($0.modeId) }, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + isEnabled: true + ) + + return PluginPanelSecondaryPanel(title: display.display.name, controls: [resolutionControl]) + } + + private func handleApplyFailure( + _ error: DisplayResolutionError, + displayID: CGDirectDisplayID, + modeId: Int32 + ) { + logger.error( + "apply failed display=\(displayID) modeId=\(modeId) reason=\(error.localizedDescription, privacy: .public)" + ) + lastErrorMessage = localization.format( + "error.applyFailedFormat", + defaultValue: "切换失败:%@", + error.localizedDescription(localization: localization) + ) + onStateChange?() + } +} + +private struct PanelDisplay { + let display: DisplayInfo + let modes: [DisplayResolutionInfo] + let allModes: [DisplayResolutionInfo] +} + +private struct DisplayResolutionSnapshot { + let displays: [PanelDisplay] +} diff --git a/Plugins/DisplayResolution/Tests/DisplayResolutionPluginTests.swift b/Plugins/DisplayResolution/Tests/DisplayResolutionPluginTests.swift new file mode 100644 index 0000000..adb0797 --- /dev/null +++ b/Plugins/DisplayResolution/Tests/DisplayResolutionPluginTests.swift @@ -0,0 +1,98 @@ +import CoreGraphics +import XCTest +@testable import DisplayResolutionPlugin + +final class DisplayResolutionPluginTests: XCTestCase { + func testParseDisplayIDAcceptsOnlyExpectedPrefix() { + XCTAssertEqual(DisplayResolutionPlugin.parseDisplayID(from: "display.1"), 1) + XCTAssertNil(DisplayResolutionPlugin.parseDisplayID(from: "display.abc")) + XCTAssertNil(DisplayResolutionPlugin.parseDisplayID(from: "foo.1")) + } + + func testOptionTitleMarksNativeDefaultAndDPI() { + XCTAssertEqual( + DisplayResolutionPlugin.optionTitle(for: makeMode(modeId: 1, width: 3008, height: 1692, isNative: true)), + "3008×1692 (原生)" + ) + XCTAssertEqual( + DisplayResolutionPlugin.optionTitle(for: makeMode(modeId: 2, width: 3008, height: 1692, isDefault: true)), + "3008×1692 (默认)" + ) + XCTAssertEqual( + DisplayResolutionPlugin.optionTitle( + for: makeMode( + modeId: 3, + width: 4096, + height: 2304, + pixelWidth: 4096, + pixelHeight: 2304, + isHiDPI: false + ) + ), + "4096×2304 (LoDPI)" + ) + } + + func testVisibleModesKeepNativeAndCurrentModes() { + let modes = [ + makeMode(modeId: 20, width: 1728, height: 1117, isNative: true), + makeMode(modeId: 21, width: 1440, height: 900, isCurrent: true) + ] + + XCTAssertEqual(Set(DisplayResolutionPlugin.visibleModes(modes).map(\.modeId)), Set([20, 21])) + } + + func testDedupeModesPrefersCurrentThenHiDPIOrHigherRefresh() { + XCTAssertEqual( + DisplayResolutionController.deduplicateModes([ + makeMode(modeId: 40, width: 1512, height: 982, refreshRate: 60, isCurrent: true), + makeMode(modeId: 41, width: 1512, height: 982, refreshRate: 120) + ]).map(\.modeId), + [40] + ) + XCTAssertEqual( + DisplayResolutionController.deduplicateModes([ + makeMode(modeId: 60, width: 2560, height: 1440, pixelWidth: 2560, pixelHeight: 1440, isHiDPI: false), + makeMode(modeId: 61, width: 2560, height: 1440, pixelWidth: 5120, pixelHeight: 2880, isHiDPI: true) + ]).map(\.modeId), + [61] + ) + } + + func testSortModesOrdersByLogicalResolutionDescending() { + let modes = [ + makeMode(modeId: 80, width: 3200, height: 1800, pixelWidth: 6400, pixelHeight: 3600), + makeMode(modeId: 81, width: 5120, height: 2880, pixelWidth: 5120, pixelHeight: 2880, isHiDPI: false, isNative: true), + makeMode(modeId: 82, width: 4096, height: 2304, pixelWidth: 4096, pixelHeight: 2304, isHiDPI: false), + makeMode(modeId: 83, width: 2560, height: 1440, pixelWidth: 5120, pixelHeight: 2880, isNative: true) + ] + + XCTAssertEqual(DisplayResolutionController.sortModes(modes).map(\.modeId), [81, 82, 80, 83]) + } + + private func makeMode( + modeId: Int32, + width: Int, + height: Int, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil, + refreshRate: Double = 60, + isHiDPI: Bool = true, + isNative: Bool = false, + isDefault: Bool = false, + isCurrent: Bool = false + ) -> DisplayResolutionInfo { + DisplayResolutionInfo( + modeId: modeId, + width: width, + height: height, + pixelWidth: pixelWidth ?? width * 2, + pixelHeight: pixelHeight ?? height * 2, + refreshRate: refreshRate, + isHiDPI: isHiDPI, + isNative: isNative, + isDefault: isDefault, + isCurrent: isCurrent + ) + } +} diff --git a/Plugins/DisplayResolution/plugin.json b/Plugins/DisplayResolution/plugin.json new file mode 100644 index 0000000..4f0eb8d --- /dev/null +++ b/Plugins/DisplayResolution/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "display-resolution", + "displayName": "显示器分辨率", + "summary": "查看并切换每个显示器的分辨率", + "localizedMetadata": { + "ar": { + "displayName": "دقة الشاشة", + "summary": "عرض وتبديل القرارات لكل شاشة." + }, + "de": { + "displayName": "Displayauflösung", + "summary": "Sehen Sie sich die Auflösungen für jede Anzeige an und wechseln Sie sie." + }, + "en": { + "displayName": "Display Resolution", + "summary": "View and switch resolutions for each display." + }, + "es": { + "displayName": "Resolución de pantalla", + "summary": "Vea y cambie resoluciones para cada pantalla." + }, + "fr": { + "displayName": "Résolution de l’écran", + "summary": "Affichez et changez de résolution pour chaque écran." + }, + "ja": { + "displayName": "ディスプレイ解像度", + "summary": "各ディスプレイの解像度を表示および切り替えます。" + }, + "ko": { + "displayName": "디스플레이 해상도", + "summary": "각 디스플레이의 해상도를 보고 전환합니다." + }, + "pt": { + "displayName": "Resolução da tela", + "summary": "Visualize e alterne as resoluções para cada monitor." + }, + "ru": { + "displayName": "Разрешение дисплея", + "summary": "Просмотр и переключение разрешения для каждого дисплея." + }, + "zh-Hans": { + "displayName": "显示器分辨率", + "summary": "查看并切换每个显示器的分辨率" + }, + "zh-Hant": { + "displayName": "顯示器解析度", + "summary": "查看並切換每個顯示器的分辨率" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "DisplayResolution.bundle", + "factoryClass": "DisplayResolutionPlugin.DisplayResolutionPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "DisplayResolutionPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/DisplaySleep/Bundle/DisplaySleepPluginBundleEntrypoint.swift b/Plugins/DisplaySleep/Bundle/DisplaySleepPluginBundleEntrypoint.swift new file mode 100644 index 0000000..c051af4 --- /dev/null +++ b/Plugins/DisplaySleep/Bundle/DisplaySleepPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import DisplaySleepPlugin + +private let displaySleepPluginFactoryAnchor: Any.Type = DisplaySleepPluginFactory.self diff --git a/Plugins/DisplaySleep/Resources/Localizable.xcstrings b/Plugins/DisplaySleep/Resources/Localizable.xcstrings new file mode 100644 index 0000000..9676a28 --- /dev/null +++ b/Plugins/DisplaySleep/Resources/Localizable.xcstrings @@ -0,0 +1,219 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض وضع السكون" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ruhezustand anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display Sleep" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar suspensión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la veille" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイスリープ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 절전" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exibir suspensão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сон дисплея" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示器休眠" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示器休眠" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل شاشات العرض في وضع السكون على الفور." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigen sofort in den Ruhezustand versetzen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Put displays to sleep immediately." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pon las pantallas en modo de suspensión inmediatamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettez les écrans en veille immédiatement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイをすぐにスリープ状態にします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이를 즉시 절전 모드로 전환하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Coloque os monitores em hibernação imediatamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Немедленно перевести дисплеи в спящий режим." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "立即让显示器休眠" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "立即讓顯示器休眠" + } + } + } + }, + "panel.button.sleep": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlaf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sleep" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dormir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dormir" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "睡眠" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수면" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Dormir" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сон" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "休眠" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "休眠" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift b/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift new file mode 100644 index 0000000..e45a475 --- /dev/null +++ b/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift @@ -0,0 +1,84 @@ +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class DisplaySleepPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + DisplaySleepPluginProvider(context: context) + } +} + +@MainActor +private struct DisplaySleepPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [DisplaySleepPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class DisplaySleepPlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", + category: "DisplaySleepPlugin" + ) + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.metadata = PluginMetadata( + id: "display-sleep", + title: localization.string("metadata.title", defaultValue: "显示器休眠"), + iconName: "display", + iconTint: Color(nsColor: .systemIndigo), + order: 97, + defaultDescription: localization.string("metadata.description", defaultValue: "立即让显示器休眠") + ) + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .dismissBeforeHandling, + buttonTitleProvider: { localization.string("panel.button.sleep", defaultValue: "休眠") } + ) + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: metadata.defaultDescription, + isOn: false, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + func handleAction(_ action: PluginPanelAction) { + guard case let .invokeAction(controlID) = action, controlID == "execute" else { + return + } + + sleepDisplays() + } + + private func sleepDisplays() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") + task.arguments = ["displaysleepnow"] + + do { + try task.run() + logger.info("Requested display sleep via pmset") + } catch { + logger.error("Failed to invoke pmset displaysleepnow: \(error.localizedDescription)") + } + } +} diff --git a/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift b/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift new file mode 100644 index 0000000..4610faa --- /dev/null +++ b/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import MacTools +@testable import DisplaySleepPlugin + +@MainActor +final class DisplaySleepPluginTests: XCTestCase { + func testMetadataIdentifiesDisplaySleepPlugin() { + let plugin = DisplaySleepPlugin() + + XCTAssertEqual(plugin.metadata.id, "display-sleep") + XCTAssertEqual(plugin.metadata.title, "显示器休眠") + } + + func testControlStyleIsButton() { + let plugin = DisplaySleepPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button) + XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "休眠") + } + + func testInitialStateIsOffAndEnabled() { + let plugin = DisplaySleepPlugin() + + let state = plugin.primaryPanelState + XCTAssertFalse(state.isOn) + XCTAssertTrue(state.isEnabled) + } + + func testPermissionRequirementsIsEmpty() { + let plugin = DisplaySleepPlugin() + + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } + + func testMenuActionBehaviorDismissesBeforeHandling() { + let plugin = DisplaySleepPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .dismissBeforeHandling) + } + + func testPluginHostIncludesDisplaySleepWhenProvided() { + let host = makePluginHostForTests(plugins: [DisplaySleepPlugin()]) + + XCTAssertTrue(host.featureManagementItems.contains { $0.id == "display-sleep" }) + } + + func testPluginDescriptionMatches() { + let plugin = DisplaySleepPlugin() + + XCTAssertEqual(plugin.metadata.defaultDescription, "立即让显示器休眠") + } + + func testHandleUnknownActionDoesNothing() { + let plugin = DisplaySleepPlugin() + + plugin.handleAction(.setSwitch(true)) + } +} diff --git a/Plugins/DisplaySleep/plugin.json b/Plugins/DisplaySleep/plugin.json new file mode 100644 index 0000000..9b8ce83 --- /dev/null +++ b/Plugins/DisplaySleep/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "display-sleep", + "displayName": "显示器休眠", + "summary": "一键让所有显示器立即进入休眠", + "localizedMetadata": { + "ar": { + "displayName": "إسبات الشاشات", + "summary": "ضع جميع شاشات العرض في وضع السكون على الفور." + }, + "de": { + "displayName": "Display-Ruhezustand", + "summary": "Versetzen Sie alle Displays sofort in den Ruhezustand." + }, + "en": { + "displayName": "Display Sleep", + "summary": "Put all displays to sleep immediately." + }, + "es": { + "displayName": "Reposo de pantallas", + "summary": "Ponga todas las pantallas a dormir inmediatamente." + }, + "fr": { + "displayName": "Mise en veille des écrans", + "summary": "Mettez immédiatement tous les écrans en veille." + }, + "ja": { + "displayName": "ディスプレイをスリープ", + "summary": "すべてのディスプレイをすぐにスリープ状態にします。" + }, + "ko": { + "displayName": "디스플레이 잠자기", + "summary": "모든 디스플레이를 즉시 절전 모드로 전환하세요." + }, + "pt": { + "displayName": "Repouso das telas", + "summary": "Coloque todos os monitores em hibernação imediatamente." + }, + "ru": { + "displayName": "Сон дисплеев", + "summary": "Немедленно переведите все дисплеи в спящий режим." + }, + "zh-Hans": { + "displayName": "显示器休眠", + "summary": "一键让所有显示器立即进入休眠" + }, + "zh-Hant": { + "displayName": "顯示器睡眠", + "summary": "一鍵讓所有顯示器立即進入休眠" + } + }, + "version": "1.0.5", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "DisplaySleep.bundle", + "factoryClass": "DisplaySleepPlugin.DisplaySleepPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "DisplaySleepPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/DisplayTrueColor/Bundle/DisplayTrueColorPluginBundleEntrypoint.swift b/Plugins/DisplayTrueColor/Bundle/DisplayTrueColorPluginBundleEntrypoint.swift new file mode 100644 index 0000000..25724a5 --- /dev/null +++ b/Plugins/DisplayTrueColor/Bundle/DisplayTrueColorPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import DisplayTrueColorPlugin + +private let displayTrueColorPluginFactoryAnchor: Any.Type = DisplayTrueColorPluginFactory.self diff --git a/Plugins/DisplayTrueColor/Resources/Localizable.xcstrings b/Plugins/DisplayTrueColor/Resources/Localizable.xcstrings new file mode 100644 index 0000000..f8d97a0 --- /dev/null +++ b/Plugins/DisplayTrueColor/Resources/Localizable.xcstrings @@ -0,0 +1,361 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "True Tone" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原彩显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原彩顯示" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ضبط لون الشاشة تلقائيًا ليناسب الإضاءة المحيطة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigefarbe automatisch an das Umgebungslicht anpassen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically adapt display color to ambient light." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Adapte automáticamente el color de la pantalla a la luz ambiental." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Adaptez automatiquement la couleur de l'affichage à la lumière ambiante." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイの色を周囲の光に自動的に適応させます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 색상을 주변 조명에 자동으로 적응시킵니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adaptar automaticamente a cor da tela à luz ambiente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически адаптировать цвет дисплея к окружающему освещению." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动调节显示器颜色以适应环境光" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動調節顯示器顏色以適應環境光" + } + } + } + }, + "panel.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "켜기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開啟" + } + } + } + }, + "panel.subtitle.disabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關閉" + } + } + } + }, + "panel.subtitle.unsupported": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير مدعوم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht unterstützt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsupported" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No compatible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non pris en charge" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サポートされていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não compatível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не поддерживается" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不支持" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不支持" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/DisplayTrueColor/Sources/DisplayTrueColorPlugin.swift b/Plugins/DisplayTrueColor/Sources/DisplayTrueColorPlugin.swift new file mode 100644 index 0000000..c03f339 --- /dev/null +++ b/Plugins/DisplayTrueColor/Sources/DisplayTrueColorPlugin.swift @@ -0,0 +1,170 @@ +import AppKit +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class DisplayTrueColorPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + DisplayTrueColorPluginProvider(context: context) + } +} + +@MainActor +private struct DisplayTrueColorPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [DisplayTrueColorPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +/// Controls True Tone through CoreBrightness's private `CBAdaptationClient`. +@MainActor +final class DisplayTrueColorPlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "DisplayTrueColorPlugin") + private let localization: PluginLocalization + private let client: TrueToneClient + private var isTrueColorEnabled: Bool = false + private var isSupported: Bool = false + + init( + client: TrueToneClient = CoreBrightnessTrueToneClient(), + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.client = client + self.metadata = PluginMetadata( + id: "display-true-color", + title: localization.string("metadata.title", defaultValue: "原彩显示"), + iconName: "circle.righthalf.filled", + iconTint: Color(nsColor: .systemCyan), + order: 25, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "自动调节显示器颜色以适应环境光" + ) + ) + isSupported = client.isSupported + isTrueColorEnabled = isSupported ? (client.isEnabled ?? false) : false + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: subtitle, + isOn: isTrueColorEnabled, + isExpanded: false, + isEnabled: isSupported, + isVisible: true, + detail: nil, + errorMessage: nil + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + func refresh() { + guard isSupported else { return } + let current = client.isEnabled ?? false + if current != isTrueColorEnabled { + isTrueColorEnabled = current + onStateChange?() + } + } + + func handleAction(_ action: PluginPanelAction) { + guard case let .setSwitch(enable) = action else { return } + guard isSupported else { return } + client.setEnabled(enable) + isTrueColorEnabled = client.isEnabled ?? enable + onStateChange?() + logger.info("True Tone set to \(enable ? "enabled" : "disabled")") + } + + // MARK: - Private + + private var subtitle: String { + if !isSupported { + return localization.string("panel.subtitle.unsupported", defaultValue: "不支持") + } + return isTrueColorEnabled + ? localization.string("panel.subtitle.enabled", defaultValue: "已开启") + : localization.string("panel.subtitle.disabled", defaultValue: "已关闭") + } +} + +// MARK: - TrueToneClient Protocol + +@MainActor +protocol TrueToneClient { + var isSupported: Bool { get } + var isEnabled: Bool? { get } + func setEnabled(_ enabled: Bool) +} + +// MARK: - CoreBrightness Implementation + +final class CoreBrightnessTrueToneClient: TrueToneClient { + private typealias BoolIMP = @convention(c) (AnyObject, Selector) -> Bool + private typealias SetBoolIMP = @convention(c) (AnyObject, Selector, Bool) -> Void + + private let adaptationClient: NSObject? + private let supportedSel = NSSelectorFromString("supported") + private let getEnabledSel = NSSelectorFromString("getEnabled") + private let setEnabledSel = NSSelectorFromString("setEnabled:") + + init() { + let bundle = Bundle(path: "/System/Library/PrivateFrameworks/CoreBrightness.framework") + _ = bundle?.load() + guard let cls = NSClassFromString("CBAdaptationClient") as? NSObject.Type else { + adaptationClient = nil + return + } + adaptationClient = cls.init() + } + + var isSupported: Bool { + guard let obj = adaptationClient, + let imp = class_getMethodImplementation(type(of: obj), supportedSel) else { + return false + } + return unsafeBitCast(imp, to: BoolIMP.self)(obj, supportedSel) + } + + var isEnabled: Bool? { + guard let obj = adaptationClient, + let imp = class_getMethodImplementation(type(of: obj), getEnabledSel) else { + return nil + } + return unsafeBitCast(imp, to: BoolIMP.self)(obj, getEnabledSel) + } + + func setEnabled(_ enabled: Bool) { + guard let obj = adaptationClient, + let imp = class_getMethodImplementation(type(of: obj), setEnabledSel) else { + return + } + unsafeBitCast(imp, to: SetBoolIMP.self)(obj, setEnabledSel, enabled) + } +} diff --git a/Plugins/DisplayTrueColor/Tests/DisplayTrueColorPluginTests.swift b/Plugins/DisplayTrueColor/Tests/DisplayTrueColorPluginTests.swift new file mode 100644 index 0000000..58d46d4 --- /dev/null +++ b/Plugins/DisplayTrueColor/Tests/DisplayTrueColorPluginTests.swift @@ -0,0 +1,130 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import DisplayTrueColorPlugin + +@MainActor +final class DisplayTrueColorPluginTests: XCTestCase { + private let suiteName = "DisplayTrueColorPluginTests" + + override func tearDown() { + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + func testPluginMetadataAndPrimaryPanelDescriptor() { + let plugin = DisplayTrueColorPlugin(client: MockTrueToneClient()) + + XCTAssertEqual(plugin.metadata.id, "display-true-color") + XCTAssertEqual(plugin.metadata.title, "原彩显示") + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .switch) + XCTAssertEqual(plugin.metadata.order, 25) + } + + func testPluginHostIncludesDisplayTrueColorWhenProvided() { + let host = makePluginHostForTests( + plugins: [DisplayTrueColorPlugin(client: MockTrueToneClient())], + suiteName: suiteName + ) + + XCTAssertTrue(host.panelItems.contains { $0.id == "display-true-color" }) + } + + func testPanelStateWhenSupportedAndEnabled() { + let client = MockTrueToneClient(isSupported: true, isEnabled: true) + let plugin = DisplayTrueColorPlugin(client: client) + + let state = plugin.primaryPanelState + XCTAssertTrue(state.isOn) + XCTAssertTrue(state.isEnabled) + XCTAssertEqual(state.subtitle, "已开启") + } + + func testPanelStateWhenSupportedAndDisabled() { + let client = MockTrueToneClient(isSupported: true, isEnabled: false) + let plugin = DisplayTrueColorPlugin(client: client) + + let state = plugin.primaryPanelState + XCTAssertFalse(state.isOn) + XCTAssertTrue(state.isEnabled) + XCTAssertEqual(state.subtitle, "已关闭") + } + + func testPanelStateWhenNotSupported() { + let client = MockTrueToneClient(isSupported: false, isEnabled: nil) + let plugin = DisplayTrueColorPlugin(client: client) + + let state = plugin.primaryPanelState + XCTAssertFalse(state.isOn) + XCTAssertFalse(state.isEnabled) + XCTAssertEqual(state.subtitle, "不支持") + } + + func testToggleOnCallsSetEnabled() { + let client = MockTrueToneClient(isSupported: true, isEnabled: false) + let plugin = DisplayTrueColorPlugin(client: client) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertTrue(client.lastSetEnabled == true) + XCTAssertTrue(plugin.primaryPanelState.isOn) + } + + func testToggleOffCallsSetEnabled() { + let client = MockTrueToneClient(isSupported: true, isEnabled: true) + let plugin = DisplayTrueColorPlugin(client: client) + + plugin.handleAction(.setSwitch(false)) + + XCTAssertTrue(client.lastSetEnabled == false) + XCTAssertFalse(plugin.primaryPanelState.isOn) + } + + func testToggleDoesNothingWhenNotSupported() { + let client = MockTrueToneClient(isSupported: false, isEnabled: nil) + let plugin = DisplayTrueColorPlugin(client: client) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertNil(client.lastSetEnabled) + } + + func testRefreshUpdatesState() { + let client = MockTrueToneClient(isSupported: true, isEnabled: false) + let plugin = DisplayTrueColorPlugin(client: client) + + XCTAssertFalse(plugin.primaryPanelState.isOn) + + client.stubbedEnabled = true + plugin.refresh() + + XCTAssertTrue(plugin.primaryPanelState.isOn) + } + + func testPermissionRequirementsAreEmpty() { + let plugin = DisplayTrueColorPlugin(client: MockTrueToneClient()) + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } +} + +// MARK: - Mock + +@MainActor +final class MockTrueToneClient: TrueToneClient { + private let supported: Bool + var stubbedEnabled: Bool? + private(set) var lastSetEnabled: Bool? + + init(isSupported: Bool = true, isEnabled: Bool? = false) { + self.supported = isSupported + self.stubbedEnabled = isEnabled + } + + var isSupported: Bool { supported } + var isEnabled: Bool? { stubbedEnabled } + + func setEnabled(_ enabled: Bool) { + stubbedEnabled = enabled + lastSetEnabled = enabled + } +} diff --git a/Plugins/DisplayTrueColor/plugin.json b/Plugins/DisplayTrueColor/plugin.json new file mode 100644 index 0000000..544a9f8 --- /dev/null +++ b/Plugins/DisplayTrueColor/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "display-true-color", + "displayName": "原彩显示", + "summary": "自动调节显示器颜色以适应环境光", + "localizedMetadata": { + "ar": { + "displayName": "True Tone", + "summary": "ضبط ألوان العرض تلقائيًا لتتناسب مع الإضاءة المحيطة." + }, + "de": { + "displayName": "True Tone", + "summary": "Passen Sie die Displayfarben automatisch an das Umgebungslicht an." + }, + "en": { + "displayName": "True Tone", + "summary": "Automatically adjust display colors to match ambient light." + }, + "es": { + "displayName": "True Tone", + "summary": "Ajuste automáticamente los colores de la pantalla para que coincidan con la luz ambiental." + }, + "fr": { + "displayName": "True Tone", + "summary": "Ajustez automatiquement les couleurs de l’affichage en fonction de la lumière ambiante." + }, + "ja": { + "displayName": "True Tone", + "summary": "周囲の光に合わせてディスプレイの色を自動的に調整します。" + }, + "ko": { + "displayName": "True Tone", + "summary": "주변 조명에 맞게 디스플레이 색상을 자동으로 조정합니다." + }, + "pt": { + "displayName": "True Tone", + "summary": "Ajuste automaticamente as cores da tela para corresponder à luz ambiente." + }, + "ru": { + "displayName": "True Tone", + "summary": "Автоматически настраивайте цвета дисплея в соответствии с окружающим освещением." + }, + "zh-Hans": { + "displayName": "原彩显示", + "summary": "自动调节显示器颜色以适应环境光" + }, + "zh-Hant": { + "displayName": "原彩顯示", + "summary": "自動調節顯示器顏色以適應環境光" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "DisplayTrueColor.bundle", + "factoryClass": "DisplayTrueColorPlugin.DisplayTrueColorPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "DisplayTrueColorPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/DisplayTrueColor/project.yml b/Plugins/DisplayTrueColor/project.yml new file mode 100644 index 0000000..8d105dd --- /dev/null +++ b/Plugins/DisplayTrueColor/project.yml @@ -0,0 +1,4 @@ +settings: + base: + SWIFT_INCLUDE_PATHS: $(SRCROOT)/Sources/Core/CoreBrightness + OTHER_LDFLAGS: -framework CoreBrightness -F /System/Library/PrivateFrameworks diff --git a/Plugins/EjectDisk/Bundle/EjectDiskPluginBundleEntrypoint.swift b/Plugins/EjectDisk/Bundle/EjectDiskPluginBundleEntrypoint.swift new file mode 100644 index 0000000..b014bcf --- /dev/null +++ b/Plugins/EjectDisk/Bundle/EjectDiskPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import EjectDiskPlugin + +private let ejectDiskPluginFactoryAnchor: Any.Type = EjectDiskPluginFactory.self diff --git a/Plugins/EjectDisk/Resources/Localizable.xcstrings b/Plugins/EjectDisk/Resources/Localizable.xcstrings new file mode 100644 index 0000000..070e00e --- /dev/null +++ b/Plugins/EjectDisk/Resources/Localizable.xcstrings @@ -0,0 +1,716 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخراج الأقراص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Datenträger auswerfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Eject Disks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expulsar discos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éjecter les disques" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスクの取り出し" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스크 꺼내기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ejetar discos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлечь диски" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推出磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推出磁盤" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أخرج كافة الأقراص القابلة للإزالة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Wechseldatenträger auswerfen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Eject all removable disks." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expulse todos los discos extraíbles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éjectez tous les disques amovibles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのリムーバブル ディスクを取り出します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 이동식 디스크를 꺼냅니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ejete todos os discos removíveis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлечь все съемные диски." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推出所有可移动磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推出所有可移動磁盤" + } + } + } + }, + "panel.button.eject": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخراج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswerfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Eject" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expulsar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éjecter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イジェクト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼내기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ejetar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлечь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推出" + } + } + } + }, + "panel.subtitle.ejecting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخراج..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird ausgeworfen..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ejecting…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expulsando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éjection…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "排出中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼내는 중…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ejetando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлечение…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推出中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推出中..." + } + } + } + }, + "panel.subtitle.none": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد أقراص قابلة للإخراج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine auswerfbaren Datenträger" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No ejectable disks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin discos expulsables" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun disque éjectable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イジェクト可能なディスクがありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼낼 수 있는 디스크 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sem discos ejetáveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет съемных дисков" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无可推出的磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無可推出的磁盤" + } + } + } + }, + "panel.subtitle.countFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d الأقراص القابلة للإخراج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d auswerfbare Datenträger" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d ejectable disks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d discos expulsables" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d disques éjectables" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d イジェクト可能ディスク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 추출 가능한 디스크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d discos ejetáveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d съемные диски" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个可推出的磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個可推出的磁盤" + } + } + } + }, + "error.volumesUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الدليل /Volumes غير متاح." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Verzeichnis /Volumes ist nicht verfügbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The /Volumes directory is unavailable." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El directorio /Volumes no está disponible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le répertoire /Volumes n'est pas disponible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "/Volumes ディレクトリは使用できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "/Volumes 디렉터리를 사용할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O diretório /Volumes não está disponível." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каталог /Volumes недоступен." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "/Volumes 目录不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "/Volumes 目錄不可用" + } + } + } + }, + "error.noEjectableDisks": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على أقراص قابلة للإخراج." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine auswerfbaren Datenträger gefunden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No ejectable disks found." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontraron discos expulsables." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun disque éjectable trouvé." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "イジェクト可能なディスクが見つかりません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼낼 수 있는 디스크를 찾을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum disco ejetável encontrado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлекаемые диски не найдены." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到可推出的磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未找到可推出的磁盤" + } + } + } + }, + "error.partialFailureFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إخراج %d من الأقراص. %d فشل:\n%@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgeworfene %d Datenträger. %d fehlgeschlagen:\n%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ejected %d disks. %d failed:\n%@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Discos %d expulsados. %d falló:\n%@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disques %d éjectés. %d a échoué :\n%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のディスクが排出されました。 %d が失敗しました:\n%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 디스크를 꺼냈습니다. %d 실패:\n%@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Discos %d ejetados. %d falhou:\n%@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Извлечено %d дисков. %d не удалось: \n%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已推出 %d 个磁盘,%d 个失败:\n%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已推出 %d 個磁盤,%d 個失敗:\n%@" + } + } + } + }, + "error.ejectFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الإخراج." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswerfen fehlgeschlagen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Eject failed." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de expulsión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'éjection." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "取り出しに失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "꺼내기에 실패했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao ejetar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось извлечь." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "推出失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "推出失敗" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/EjectDisk/Sources/EjectDiskPlugin.swift b/Plugins/EjectDisk/Sources/EjectDiskPlugin.swift new file mode 100644 index 0000000..d1f4bd1 --- /dev/null +++ b/Plugins/EjectDisk/Sources/EjectDiskPlugin.swift @@ -0,0 +1,320 @@ +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class EjectDiskPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + EjectDiskPluginProvider(context: context) + } +} + +@MainActor +private struct EjectDiskPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [EjectDiskPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class EjectDiskPlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let localization: PluginLocalization + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "EjectDiskPlugin") + private var isEjecting = false + private var ejectableDiskCount: Int = 0 + private var lastErrorMessage: String? + private var volumeMountObservers: [NSObjectProtocol] = [] + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + self.metadata = PluginMetadata( + id: "eject-disk", + title: localization.string("metadata.title", defaultValue: "推出磁盘"), + iconName: "eject", + iconTint: Color(nsColor: .systemGray), + order: 92, + defaultDescription: localization.string("metadata.description", defaultValue: "推出所有可移动磁盘") + ) + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .keepPresented, + buttonTitleProvider: { localization.string("panel.button.eject", defaultValue: "推出") } + ) + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: subtitle, + isOn: false, + isExpanded: false, + isEnabled: !isEjecting && ejectableDiskCount > 0, + isVisible: true, + detail: nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func refresh() { + let count = Self.ejectableDiskCountSync() + if self.ejectableDiskCount != count { + self.ejectableDiskCount = count + self.onStateChange?() + } + + setupVolumeMountObserver() + } + + private func setupVolumeMountObserver() { + guard volumeMountObservers.isEmpty else { return } + + let workspace = NSWorkspace.shared + let notificationCenter = NSWorkspace.shared.notificationCenter + + let mountObserver = notificationCenter.addObserver( + forName: NSWorkspace.didMountNotification, + object: workspace, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.checkForEjectableDiskAndUpdate() + } + } + + let unmountObserver = notificationCenter.addObserver( + forName: NSWorkspace.didUnmountNotification, + object: workspace, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.checkForEjectableDiskAndUpdate() + } + } + + volumeMountObservers.append(mountObserver) + volumeMountObservers.append(unmountObserver) + } + + private func checkForEjectableDiskAndUpdate() { + let count = Self.ejectableDiskCountSync() + if self.ejectableDiskCount != count { + self.ejectableDiskCount = count + self.onStateChange?() + } + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .invokeAction(controlID): + if controlID == "execute" { + ejectAllDisks() + } + default: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + // MARK: - Private + + private var subtitle: String { + if isEjecting { + return localization.string("panel.subtitle.ejecting", defaultValue: "推出中...") + } + if ejectableDiskCount == 0 { + return localization.string("panel.subtitle.none", defaultValue: "无可推出的磁盘") + } + return localization.format( + "panel.subtitle.countFormat", + defaultValue: "%d 个可推出的磁盘", + ejectableDiskCount + ) + } + + nonisolated private static func ejectableDiskCountSync() -> Int { + let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "EjectDiskPlugin") + + let ejectableVolumes = getEjectableVolumes() + logger.debug("Found \(ejectableVolumes.count) ejectable volumes") + return ejectableVolumes.count + } + + nonisolated private static let volumeResourceKeys: Set = [ + .volumeIsEjectableKey, + .volumeIsInternalKey, + .volumeIsLocalKey + ] + + nonisolated static func isEjectableVolume( + isEjectable: Bool?, + isInternal: Bool?, + isLocal: Bool? + ) -> Bool { + isEjectable == true && isInternal == false && isLocal == true + } + + nonisolated private static func getEjectableVolumes() -> [URL] { + let fileManager = FileManager.default + let volumes = fileManager.mountedVolumeURLs( + includingResourceValuesForKeys: Array(volumeResourceKeys), + options: [] + ) ?? [] + + return volumes.filter { volumeURL in + isEjectableVolume(at: volumeURL) + } + } + + nonisolated private static func isEjectableVolume(at volumeURL: URL) -> Bool { + guard let values = try? volumeURL.resourceValues(forKeys: volumeResourceKeys) else { + return false + } + + return isEjectableVolume( + isEjectable: values.volumeIsEjectable, + isInternal: values.volumeIsInternal, + isLocal: values.volumeIsLocal + ) + } + + private func ejectAllDisks() { + guard !isEjecting else { return } + + isEjecting = true + lastErrorMessage = nil + onStateChange?() + + Task { + do { + try await executeEjectAll() + + await MainActor.run { + isEjecting = false + lastErrorMessage = nil + ejectableDiskCount = Self.ejectableDiskCountSync() + onStateChange?() + } + } catch { + let errorMessage = error.localizedDescription + logger.error("Failed to eject disks: \(errorMessage)") + + await MainActor.run { + isEjecting = false + lastErrorMessage = errorMessage + ejectableDiskCount = Self.ejectableDiskCountSync() + onStateChange?() + } + } + } + } + + private func executeEjectAll() async throws { + let ejectableVolumes = Self.getEjectableVolumes() + + guard !ejectableVolumes.isEmpty else { + throw NSError( + domain: "EjectDiskPlugin", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: localization.string( + "error.noEjectableDisks", + defaultValue: "未找到可推出的磁盘" + ) + ] + ) + } + + var successCount = 0 + var errorMessages: [String] = [] + + for volumeURL in ejectableVolumes { + // Ejecting one volume can remove sibling volumes on the same physical disk. + guard Self.isEjectableVolume(at: volumeURL) else { continue } + + let volumeName = volumeURL.lastPathComponent + do { + try await ejectVolume(at: volumeURL) + successCount += 1 + logger.info("Successfully ejected volume: \(volumeName)") + } catch { + errorMessages.append("- \(volumeName): \(error.localizedDescription)") + logger.error("Failed to eject volume '\(volumeName)': \(error.localizedDescription)") + } + } + + if !errorMessages.isEmpty { + let message = localization.format( + "error.partialFailureFormat", + defaultValue: "已推出 %d 个磁盘,%d 个失败:\n%@", + successCount, + errorMessages.count, + errorMessages.joined(separator: "\n") + ) + throw NSError(domain: "EjectDiskPlugin", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } + } + + private func ejectVolume(at volumeURL: URL) async throws { + let result = try await Self.runEjectCommand(at: volumeURL) + + if result.terminationStatus != 0 { + let output = result.output + let errorMessage = output.isEmpty + ? localization.string("error.ejectFailed", defaultValue: "推出失败") + : output + throw NSError( + domain: "EjectDiskPlugin", + code: Int(result.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + } + } + + nonisolated private static func runEjectCommand(at volumeURL: URL) async throws -> EjectCommandResult { + try await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["eject", volumeURL.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + let output = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return EjectCommandResult( + terminationStatus: process.terminationStatus, + output: output + ) + }.value + } +} + +private struct EjectCommandResult: Sendable { + let terminationStatus: Int32 + let output: String +} diff --git a/Plugins/EjectDisk/Tests/EjectDiskPluginTests.swift b/Plugins/EjectDisk/Tests/EjectDiskPluginTests.swift new file mode 100644 index 0000000..762ac1f --- /dev/null +++ b/Plugins/EjectDisk/Tests/EjectDiskPluginTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import MacTools +@testable import EjectDiskPlugin + +@MainActor +final class EjectDiskPluginTests: XCTestCase { + func testMetadataIdentifiesEjectDiskPlugin() { + let plugin = EjectDiskPlugin() + + XCTAssertEqual(plugin.metadata.id, "eject-disk") + XCTAssertEqual(plugin.metadata.title, "推出磁盘") + } + + func testControlStyleIsButton() { + let plugin = EjectDiskPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button) + XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "推出") + } + + func testInitialStateHasEjectedOffAndIsDisabled() { + let plugin = EjectDiskPlugin() + + let state = plugin.primaryPanelState + XCTAssertFalse(state.isOn) + XCTAssertFalse(state.isEnabled) + } + + func testPermissionRequirementsIsEmpty() { + let plugin = EjectDiskPlugin() + + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } + + func testPluginHostIncludesEjectDiskWhenProvided() { + let host = makePluginHostForTests(plugins: [EjectDiskPlugin()]) + + XCTAssertTrue(host.featureManagementItems.contains { $0.id == "eject-disk" }) + } + + func testPluginDescriptionMatches() { + let plugin = EjectDiskPlugin() + + XCTAssertEqual(plugin.metadata.defaultDescription, "推出所有可移动磁盘") + } + + func testSubtitleShowsNoEjectableDiskWhenCountIsZero() { + let plugin = EjectDiskPlugin() + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "无可推出的磁盘") + } + + func testEjectableVolumeRequiresExplicitExternalLocalVolumeProperties() { + XCTAssertTrue(EjectDiskPlugin.isEjectableVolume( + isEjectable: true, + isInternal: false, + isLocal: true + )) + } + + func testEjectableVolumeRejectsInternalSystemVolume() { + XCTAssertFalse(EjectDiskPlugin.isEjectableVolume( + isEjectable: true, + isInternal: true, + isLocal: true + )) + } + + func testEjectableVolumeRejectsNetworkVolume() { + XCTAssertFalse(EjectDiskPlugin.isEjectableVolume( + isEjectable: true, + isInternal: false, + isLocal: false + )) + } + + func testEjectableVolumeRejectsUnknownProperties() { + XCTAssertFalse(EjectDiskPlugin.isEjectableVolume( + isEjectable: true, + isInternal: nil, + isLocal: true + )) + XCTAssertFalse(EjectDiskPlugin.isEjectableVolume( + isEjectable: nil, + isInternal: false, + isLocal: true + )) + } +} diff --git a/Plugins/EjectDisk/plugin.json b/Plugins/EjectDisk/plugin.json new file mode 100644 index 0000000..59e2b8a --- /dev/null +++ b/Plugins/EjectDisk/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "eject-disk", + "displayName": "推出磁盘", + "summary": "推出所有可移动磁盘", + "localizedMetadata": { + "ar": { + "displayName": "إخراج الأقراص", + "summary": "أخرج كافة الأقراص القابلة للإزالة." + }, + "de": { + "displayName": "Datenträger auswerfen", + "summary": "Alle Wechseldatenträger auswerfen." + }, + "en": { + "displayName": "Eject Disks", + "summary": "Eject all removable disks." + }, + "es": { + "displayName": "Expulsar discos", + "summary": "Expulse todos los discos extraíbles." + }, + "fr": { + "displayName": "Éjecter les disques", + "summary": "Éjectez tous les disques amovibles." + }, + "ja": { + "displayName": "ディスクを取り出す", + "summary": "すべてのリムーバブル ディスクを取り出します。" + }, + "ko": { + "displayName": "디스크 추출", + "summary": "모든 이동식 디스크를 꺼냅니다." + }, + "pt": { + "displayName": "Ejetar discos", + "summary": "Ejete todos os discos removíveis." + }, + "ru": { + "displayName": "Извлечь диски", + "summary": "Извлечь все съемные диски." + }, + "zh-Hans": { + "displayName": "推出磁盘", + "summary": "推出所有可移动磁盘" + }, + "zh-Hant": { + "displayName": "退出磁碟", + "summary": "推出所有可移動磁盤" + } + }, + "version": "1.0.8", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "EjectDisk.bundle", + "factoryClass": "EjectDiskPlugin.EjectDiskPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "EjectDiskPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "storage" +} diff --git a/Plugins/EmptyTrash/Bundle/EmptyTrashPluginBundleEntrypoint.swift b/Plugins/EmptyTrash/Bundle/EmptyTrashPluginBundleEntrypoint.swift new file mode 100644 index 0000000..6a47537 --- /dev/null +++ b/Plugins/EmptyTrash/Bundle/EmptyTrashPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import EmptyTrashPlugin + +private let emptyTrashPluginFactoryAnchor: Any.Type = EmptyTrashPluginFactory.self diff --git a/Plugins/EmptyTrash/Resources/Localizable.xcstrings b/Plugins/EmptyTrash/Resources/Localizable.xcstrings new file mode 100644 index 0000000..740901d --- /dev/null +++ b/Plugins/EmptyTrash/Resources/Localizable.xcstrings @@ -0,0 +1,503 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إفراغ سلة المهملات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Papierkorb leeren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty Trash" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vaciar papelera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vider la corbeille" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱を空にする" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통 비우기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esvaziar lixeira" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить корзину" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空废纸篓" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空垃圾桶" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إفراغ كافة العناصر الموجودة في سلة المهملات." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leeren Sie alle Elemente im Papierkorb." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty all items in the Trash." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vaciar todos los elementos de la Papelera." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Videz tous les éléments de la corbeille." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱内のすべてのアイテムを空にします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통의 모든 항목을 비웁니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esvazie todos os itens da Lixeira." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить все элементы в корзине." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空废纸篓中的所有项目" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空垃圾桶中的所有項目" + } + } + } + }, + "panel.button.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فارغ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Empty" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vacío" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비어 있음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Vazio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пусто" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + } + } + }, + "panel.subtitle.emptying": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إفراغ..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entleeren…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Emptying…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vaciando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vidage…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "空にしています…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비우는 중…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esvaziando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Опорожнение…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空中..." + } + } + } + }, + "panel.subtitle.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المهملات فارغة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Papierkorb ist leer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trash is empty" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La papelera está vacía" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La corbeille est vide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱は空です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통이 비어 있습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A lixeira está vazia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Корзина пуста" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "废纸篓为空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "垃圾桶為空" + } + } + } + }, + "panel.subtitle.countFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d العناصر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d elementos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d éléments" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d アイテム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 항목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d itens" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d элементов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个项目" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個項目" + } + } + } + }, + "error.emptyFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل في إفراغ سلة المهملات. تحقق من إذن الأتمتة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Papierkorb konnte nicht geleert werden. Überprüfen Sie die Automatisierungsberechtigung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to empty Trash. Check Automation permission." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo vaciar la Papelera. Verifique el permiso de automatización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la suppression de la corbeille. Vérifiez l'autorisation d'automatisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱を空にできませんでした。自動化の許可を確認してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통을 비우지 못했습니다. 자동화 권한을 확인하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao esvaziar a Lixeira. Verifique a permissão de automação." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось очистить корзину. Проверьте разрешение автоматизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空废纸篓失败,请检查“自动操作”权限" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清空垃圾桶失敗,請檢查“自動操作”權限" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift b/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift new file mode 100644 index 0000000..04c9b5e --- /dev/null +++ b/Plugins/EmptyTrash/Sources/EmptyTrashPlugin.swift @@ -0,0 +1,253 @@ +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class EmptyTrashPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + EmptyTrashPluginProvider(context: context) + } +} + +@MainActor +private struct EmptyTrashPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [EmptyTrashPlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class EmptyTrashPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPanelSurfaceLifecycleHandling { + let metadata: PluginMetadata + + let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let localization: PluginLocalization + private let countItems: @Sendable () async -> Int + private let countRefreshDelay: Duration + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "EmptyTrashPlugin") + private var itemCount: Int = 0 + private var isEmptying = false + private var lastErrorMessage: String? + private var isPrimaryPanelVisible = false + private var countRefreshTask: Task? + + init( + localization: PluginLocalization = PluginLocalization(bundle: .main), + countItems: @escaping @Sendable () async -> Int = EmptyTrashPlugin.fetchTrashItemCount, + countRefreshDelay: Duration = .milliseconds(150) + ) { + self.localization = localization + self.countItems = countItems + self.countRefreshDelay = countRefreshDelay + self.metadata = PluginMetadata( + id: "empty-trash", + title: localization.string("metadata.title", defaultValue: "清空废纸篓"), + iconName: "trash", + iconTint: Color(nsColor: .systemGray), + order: 93, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "清空废纸篓中的所有项目" + ) + ) + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .keepPresented, + buttonTitleProvider: { localization.string("panel.button.empty", defaultValue: "清空") } + ) + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: subtitle, + isOn: false, + isExpanded: false, + isEnabled: !isEmptying && itemCount > 0, + isVisible: true, + detail: nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func refresh() { + scheduleCountRefreshIfVisible() + } + + func deactivate(reason _: PluginDeactivationReason) { + countRefreshTask?.cancel() + countRefreshTask = nil + isPrimaryPanelVisible = false + } + + func panelSurfaceDidBecomeVisible(_ surface: PluginPanelSurface) { + guard surface == .primary else { + return + } + + isPrimaryPanelVisible = true + scheduleCountRefresh() + } + + func panelSurfaceDidBecomeHidden(_ surface: PluginPanelSurface) { + guard surface == .primary else { + return + } + + isPrimaryPanelVisible = false + countRefreshTask?.cancel() + countRefreshTask = nil + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .invokeAction(controlID): + if controlID == "execute" { + emptyTrash() + } + default: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + // MARK: - Private + + private func scheduleCountRefresh() { + countRefreshTask?.cancel() + let delay = countRefreshDelay + countRefreshTask = Task { @MainActor [weak self, delay] in + do { + try await Task.sleep(for: delay) + } catch { + return + } + + guard let self, !Task.isCancelled else { + return + } + + let count = await self.countItems() + guard !Task.isCancelled else { + return + } + + if self.itemCount != count { + self.itemCount = count + self.onStateChange?() + } + self.countRefreshTask = nil + } + } + + private func scheduleCountRefreshIfVisible() { + guard isPrimaryPanelVisible else { + return + } + + scheduleCountRefresh() + } + + private var subtitle: String { + if isEmptying { + return localization.string("panel.subtitle.emptying", defaultValue: "清空中...") + } + if itemCount == 0 { + return localization.string("panel.subtitle.empty", defaultValue: "废纸篓为空") + } + return localization.format("panel.subtitle.countFormat", defaultValue: "%d 个项目", itemCount) + } + + @MainActor + private func emptyTrash() { + guard !isEmptying, itemCount > 0 else { return } + isEmptying = true + lastErrorMessage = nil + onStateChange?() + + Task { + do { + try await self.emptyTrashViaAppleScript() + await MainActor.run { + self.isEmptying = false + self.itemCount = 0 + self.onStateChange?() + self.scheduleCountRefreshIfVisible() + } + } catch { + await MainActor.run { + self.isEmptying = false + self.lastErrorMessage = error.localizedDescription + self.onStateChange?() + self.scheduleCountRefreshIfVisible() + self.logger.error("Empty trash failed: \(error)") + } + } + } + } + + // MARK: - AppleScript helpers + + private static func fetchTrashItemCount() async -> Int { + let script = "tell application \"Finder\" to count items of trash" + return await Task.detached(priority: .userInitiated) { + runOsascriptStandalone(script).flatMap { Int($0) } ?? 0 + }.value + } + + private func emptyTrashViaAppleScript() async throws { + let script = "tell application \"Finder\" to empty trash" + let errorMessage = localization.string( + "error.emptyFailed", + defaultValue: "清空废纸篓失败,请检查“自动操作”权限" + ) + try await Task.detached(priority: .userInitiated) { + if runOsascriptStandalone(script) == nil { + throw NSError( + domain: "EmptyTrashPlugin", + code: 1, + userInfo: [NSLocalizedDescriptionKey: errorMessage] + ) + } + }.value + } +} + +private func runOsascriptStandalone(_ script: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return nil + } + let data = outPipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + return nil + } +} diff --git a/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift b/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift new file mode 100644 index 0000000..77d36ae --- /dev/null +++ b/Plugins/EmptyTrash/Tests/EmptyTrashPluginTests.swift @@ -0,0 +1,150 @@ +import XCTest +@testable import MacTools +@testable import EmptyTrashPlugin + +@MainActor +final class EmptyTrashPluginTests: XCTestCase { + func testMetadataIdentifiesEmptyTrashPlugin() { + let plugin = EmptyTrashPlugin() + + XCTAssertEqual(plugin.metadata.id, "empty-trash") + XCTAssertEqual(plugin.metadata.title, "清空废纸篓") + } + + func testControlStyleIsButton() { + let plugin = EmptyTrashPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button) + XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "清空") + } + + func testInitialStateIsOffAndDisabled() { + let plugin = EmptyTrashPlugin() + + let state = plugin.primaryPanelState + XCTAssertFalse(state.isOn) + XCTAssertFalse(state.isEnabled) + } + + func testInitialSubtitleShowsEmpty() { + let plugin = EmptyTrashPlugin() + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "废纸篓为空") + } + + func testPermissionRequirementsIsEmpty() { + let plugin = EmptyTrashPlugin() + + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } + + func testPluginHostIncludesEmptyTrashWhenProvided() { + let host = makePluginHostForTests(plugins: [EmptyTrashPlugin()]) + + XCTAssertTrue(host.featureManagementItems.contains { $0.id == "empty-trash" }) + } + + func testPluginDescriptionMatches() { + let plugin = EmptyTrashPlugin() + + XCTAssertEqual(plugin.metadata.defaultDescription, "清空废纸篓中的所有项目") + } + + func testMenuActionBehaviorIsKeepPresented() { + let plugin = EmptyTrashPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .keepPresented) + } + + func testRefreshDoesNotCountItemsWhilePrimaryPanelIsHidden() async { + let counter = TrashCountProbe(itemCount: 3) + let plugin = EmptyTrashPlugin( + countItems: { await counter.countItems() }, + countRefreshDelay: .zero + ) + + plugin.refresh() + await Task.yield() + + let requestCount = await counter.requestCountValue() + XCTAssertEqual(requestCount, 0) + XCTAssertFalse(plugin.primaryPanelState.isEnabled) + } + + func testPrimaryPanelVisibilityRefreshesTrashCount() async { + let counter = TrashCountProbe(itemCount: 3) + let plugin = EmptyTrashPlugin( + countItems: { await counter.countItems() }, + countRefreshDelay: .zero + ) + + plugin.panelSurfaceDidBecomeVisible(.primary) + + await waitForRequestCount(1, counter: counter) + let requestCount = await counter.requestCountValue() + XCTAssertEqual(requestCount, 1) + XCTAssertTrue(plugin.primaryPanelState.isEnabled) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "3 个项目") + } + + func testVisibleCountRefreshesAreDebounced() async { + let counter = TrashCountProbe(itemCount: 2) + let plugin = EmptyTrashPlugin( + countItems: { await counter.countItems() }, + countRefreshDelay: .zero + ) + + plugin.panelSurfaceDidBecomeVisible(.primary) + plugin.refresh() + plugin.refresh() + + await waitForRequestCount(1, counter: counter) + for _ in 0..<5 { + await Task.yield() + } + + let requestCount = await counter.requestCountValue() + XCTAssertEqual(requestCount, 1) + } + + private func waitForRequestCount( + _ expectedRequestCount: Int, + counter: TrashCountProbe, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0..<20 { + if await counter.requestCountValue() == expectedRequestCount { + return + } + + await Task.yield() + } + + try? await Task.sleep(for: .milliseconds(50)) + if await counter.requestCountValue() == expectedRequestCount { + return + } + + let requestCount = await counter.requestCountValue() + XCTAssertEqual(requestCount, expectedRequestCount, file: file, line: line) + } +} + +private actor TrashCountProbe { + private(set) var requestCount = 0 + private let itemCount: Int + + init(itemCount: Int) { + self.itemCount = itemCount + } + + func countItems() async -> Int { + requestCount += 1 + return itemCount + } + + func requestCountValue() -> Int { + requestCount + } +} diff --git a/Plugins/EmptyTrash/plugin.json b/Plugins/EmptyTrash/plugin.json new file mode 100644 index 0000000..c3f6c50 --- /dev/null +++ b/Plugins/EmptyTrash/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "empty-trash", + "displayName": "清空废纸篓", + "summary": "清空废纸篓中的所有项目", + "localizedMetadata": { + "ar": { + "displayName": "إفراغ سلة المهملات", + "summary": "إفراغ كافة العناصر الموجودة في سلة المهملات." + }, + "de": { + "displayName": "Papierkorb leeren", + "summary": "Leeren Sie alle Elemente im Papierkorb." + }, + "en": { + "displayName": "Empty Trash", + "summary": "Empty all items in the Trash." + }, + "es": { + "displayName": "Vaciar papelera", + "summary": "Vaciar todos los elementos de la Papelera." + }, + "fr": { + "displayName": "Vider la corbeille", + "summary": "Videz tous les éléments de la corbeille." + }, + "ja": { + "displayName": "ゴミ箱を空にする", + "summary": "ゴミ箱内のすべてのアイテムを空にします。" + }, + "ko": { + "displayName": "휴지통 비우기", + "summary": "휴지통의 모든 항목을 비웁니다." + }, + "pt": { + "displayName": "Esvaziar Lixo", + "summary": "Esvazie todos os itens da Lixeira." + }, + "ru": { + "displayName": "Очистить корзину", + "summary": "Очистить все элементы в корзине." + }, + "zh-Hans": { + "displayName": "清空废纸篓", + "summary": "清空废纸篓中的所有项目" + }, + "zh-Hant": { + "displayName": "清空垃圾桶", + "summary": "清空垃圾桶中的所有項目" + } + }, + "version": "1.0.8", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "EmptyTrash.bundle", + "factoryClass": "EmptyTrashPlugin.EmptyTrashPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "EmptyTrashPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "storage" +} diff --git a/Plugins/FanControl/Bundle/FanControlPluginBundleEntrypoint.swift b/Plugins/FanControl/Bundle/FanControlPluginBundleEntrypoint.swift new file mode 100644 index 0000000..cd6f341 --- /dev/null +++ b/Plugins/FanControl/Bundle/FanControlPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import FanControlPlugin + +private let fanControlPluginFactoryAnchor: Any.Type = FanControlPluginFactory.self diff --git a/Plugins/FanControl/Resources/Localizable.xcstrings b/Plugins/FanControl/Resources/Localizable.xcstrings new file mode 100644 index 0000000..a0d7588 --- /dev/null +++ b/Plugins/FanControl/Resources/Localizable.xcstrings @@ -0,0 +1,2136 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة الإعدادات المسبقة لسرعة المروحة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellungen für die Lüftergeschwindigkeit verwalten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage fan speed presets." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrar ajustes preestablecidos de velocidad del ventilador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les préréglages de vitesse du ventilateur." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファン速度のプリセットを管理します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팬 속도 사전 설정을 관리합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar predefinições de velocidade do ventilador." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление предустановками скорости вентилятора." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理风扇转速预设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理風扇轉速預設" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحكم في المروحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüftersteuerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan Control" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Control del ventilador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contrôle du ventilateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファン制御" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팬 제어" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Controle do ventilador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление вентилятором" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "风扇控制" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "風扇控制" + } + } + } + }, + "panel.action.deletePreset": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "احذف هذا الإعداد المسبق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Voreinstellung löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete This Preset" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar este ajuste preestablecido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer ce préréglage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このプリセットを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 사전 설정 삭제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluir esta predefinição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить этот пресет" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除此预设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除此預設" + } + } + } + }, + "panel.action.managePresets": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة الإعدادات المسبقة..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellungen verwalten…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage Presets…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrar ajustes preestablecidos…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les préréglages…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プリセットの管理…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사전 설정 관리..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar predefinições…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управление пресетами…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理预设…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理預設…" + } + } + } + }, + "panel.action.missingHelper": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مكون التحكم في المروحة مفقود" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüftersteuerungskomponente fehlt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control component missing" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Falta el componente de control del ventilador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Composant de contrôle du ventilateur manquant" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファン制御コンポーネントがありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팬 제어 구성요소 누락" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Componente de controle do ventilador ausente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отсутствует компонент управления вентилятором" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "风扇控制组件缺失" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "風扇控制組件缺失" + } + } + } + }, + "panel.slider.targetRPM": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "العائد لكل ألف ظهور المستهدف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ziel-RPM" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Target RPM" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RPM objetivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RPM cible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "目標 RPM" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목표 RPM" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "RPM desejado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Целевой доход на тысячу показов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目标转速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目標轉速" + } + } + } + }, + "panel.subtitle.withRPM": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "preset.auto.name": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تلقائي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "preset.auto.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتم إدارته بواسطة macOS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwaltet von macOS" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Managed by macOS" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrado por macOS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Géré par macOS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "macOS によって管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "macOS에서 관리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciado pelo macOS" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляется macOS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "由 macOS 管理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由 macOS 管理" + } + } + } + }, + "preset.custom.defaultName": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعداد مسبق مخصص %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Voreinstellung %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Preset %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preajuste personalizado %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préréglage personnalisé %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム プリセット %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 정의 사전 설정 %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Predefinição personalizada %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательская предустановка %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义预设 %d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自定義預設 %d" + } + } + } + }, + "preset.fullSpeed.name": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السرعة الكاملة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Volle Geschwindigkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Full Speed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Velocidad máxima" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pleine vitesse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルスピード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 속도" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Velocidade máxima" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полная скорость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全速" + } + } + } + }, + "preset.fullSpeed.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السرعة القصوى" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Höchstgeschwindigkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Maximum speed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Velocidad máxima" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vitesse maximale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最大速度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 속도" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Velocidade máxima" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Максимальная скорость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最高转速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最高轉速" + } + } + } + }, + "preset.fullSpeed.subtitleWithRPM": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ما يصل إلى %d دورة في الدقيقة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bis zu %d U/min" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to %d RPM" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hasta %d RPM" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jusqu'à %d tr/min" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最大 %d RPM" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최대 %dRPM" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Até %d RPM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "До %d об/мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最高 %d RPM" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最高 %d RPM" + } + } + } + }, + "settings.builtIn.auto.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتم إدارتها تلقائيًا بواسطة macOS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch von macOS verwaltet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Managed automatically by macOS" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Administrado automáticamente por macOS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Géré automatiquement par macOS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "macOS によって自動的に管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "macOS에서 자동으로 관리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciado automaticamente pelo macOS" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляется автоматически macOS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "由 macOS 自动管理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "由 macOS 自動管理" + } + } + } + }, + "settings.builtIn.badge": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدمج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingebaut" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Integrado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Integrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "內置" + } + } + } + }, + "settings.builtIn.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإعدادات المسبقة المضمنة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eingebaute Voreinstellungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in Presets" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preajustes integrados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préréglages intégrés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵プリセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 사전 설정" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Predefinições integradas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенные настройки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内置预设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "內置預設" + } + } + } + }, + "settings.custom.add": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + } + } + }, + "settings.custom.deleteHelp": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "احذف هذا الإعداد المسبق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Voreinstellung löschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete this preset" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar este ajuste preestablecido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer ce préréglage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このプリセットを削除します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 사전 설정 삭제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluir esta predefinição" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить этот пресет" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除此预设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除此預設" + } + } + } + }, + "settings.custom.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقر فوق \"إضافة\" لإنشاء إعداد مسبق مخصص لسرعة المروحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klicken Sie auf „Hinzufügen“, um eine benutzerdefinierte Voreinstellung für die Lüftergeschwindigkeit zu erstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Click Add to create a custom fan speed preset" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Haga clic en Agregar para crear un ajuste preestablecido de velocidad del ventilador personalizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez sur Ajouter pour créer un préréglage de vitesse de ventilateur personnalisé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[追加] をクリックしてカスタム ファン速度プリセットを作成します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "추가를 클릭하여 사용자 정의 팬 속도 사전 설정을 생성하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Clique em Adicionar para criar uma predefinição personalizada de velocidade do ventilador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите «Добавить», чтобы создать собственную предустановку скорости вращения вентилятора" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击「添加」创建自定义转速预设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊「添加」創建自定義轉速預設" + } + } + } + }, + "settings.custom.namePlaceholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاسم المحدد مسبقًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Voreinstellungsname" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preset name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre preestablecido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom du préréglage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プリセット名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사전 설정 이름" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nome predefinido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя предустановки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "预设名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "預設名稱" + } + } + } + }, + "settings.custom.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإعدادات المسبقة المخصصة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Voreinstellungen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom Presets" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preajustes personalizados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préréglages personnalisés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム プリセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 정의 사전 설정" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Predefinições personalizadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательские настройки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义预设" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自定義預設" + } + } + } + }, + "writeError.helperInstallFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تثبيت مكون التحكم في المروحة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Lüftersteuerungskomponente konnte nicht installiert werden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to install fan control component: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al instalar el componente de control del ventilador: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'installation du composant de contrôle du ventilateur : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファン制御コンポーネントのインストールに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팬 제어 구성요소 설치 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao instalar o componente de controle do ventilador: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось установить компонент управления вентилятором: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装风扇控制组件失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝風扇控制組件失敗:%@" + } + } + } + }, + "writeError.helperNotFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مكون التحكم في المروحة المضمنة. أعد تثبيت المكون الإضافي للتحكم في المروحة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die integrierte Lüftersteuerungskomponente wurde nicht gefunden. Installieren Sie das Fan Control-Plugin neu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The built-in fan control component was not found. Reinstall the Fan Control plugin." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró el componente de control del ventilador integrado. Vuelva a instalar el complemento Fan Control." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le composant de contrôle du ventilateur intégré est introuvable. Réinstallez le plugin Fan Control." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ファン制御コンポーネントが見つかりませんでした。ファン制御プラグインを再インストールします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내장 팬 제어 구성 요소를 찾을 수 없습니다. 팬 제어 플러그인을 다시 설치하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O componente de controle do ventilador integrado não foi encontrado. Reinstale o plug-in Fan Control." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенный компонент управления вентилятором не найден. Переустановите плагин Fan Control." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到内置风扇控制组件。请重新安装风扇控制插件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未找到內置風扇控制組件。請重新安裝風扇控制外掛。" + } + } + } + }, + "writeError.helperVerificationFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التحقق من مكون التحكم في المروحة. أعد تثبيت المكون الإضافي للتحكم في المروحة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfung der Lüftersteuerungskomponente fehlgeschlagen. Installieren Sie das Fan Control-Plugin neu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control component verification failed. Reinstall the Fan Control plugin." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en la verificación del componente de control del ventilador. Vuelva a instalar el complemento Fan Control." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La vérification des composants de commande du ventilateur a échoué. Réinstallez le plugin Fan Control." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファン制御コンポーネントの検証に失敗しました。ファン制御プラグインを再インストールします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팬 제어 구성 요소 확인에 실패했습니다. 팬 제어 플러그인을 다시 설치하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na verificação do componente de controle do ventilador. Reinstale o plug-in Fan Control." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка компонента управления вентилятором не удалась. Переустановите плагин Fan Control." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "风扇控制组件校验失败。请重新安装风扇控制插件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "風扇控制組件校驗失敗。請重新安裝風扇控制外掛。" + } + } + } + }, + "writeError.writeFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت كتابة سرعة المروحة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüftergeschwindigkeit konnte nicht geschrieben werden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to write fan speed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al escribir la velocidad del ventilador: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'écriture de la vitesse du ventilateur : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファン速度の書き込みに失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팬 속도 쓰기 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao gravar a velocidade do ventilador: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось записать скорость вентилятора: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "写入风扇速度失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寫入風扇速度失敗:%@" + } + } + } + }, + "writer.error.authorizationScriptFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر إنشاء البرنامج النصي للتفويض." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Autorisierungsskript konnte nicht erstellt werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not create the authorization script." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo crear el script de autorización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de créer le script d'autorisation." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認可スクリプトを作成できませんでした。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증 스크립트를 생성할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível criar o script de autorização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось создать сценарий авторизации." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法创建授权脚本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法創建授權腳本" + } + } + } + }, + "writer.error.fixedRPMPartial": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت بعض المراوح في ضبط السرعة المستهدفة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Lüfter konnten die Zielgeschwindigkeit nicht einstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Some fans failed to set the target speed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Algunos fans no pudieron establecer la velocidad objetivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Certains ventilateurs n'ont pas réussi à définir la vitesse cible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部のファンが目標速度を設定できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부 팬이 목표 속도를 설정하지 못했습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alguns fãs não conseguiram definir a velocidade alvo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Некоторым вентиляторам не удалось установить целевую скорость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分风扇设置目标转速失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部分風扇設定目標轉速失敗" + } + } + } + }, + "writer.error.fullSpeedPartial": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت بعض المراوح في التبديل إلى السرعة الكاملة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Lüfter konnten nicht auf volle Geschwindigkeit umschalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Some fans failed to switch to full speed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Algunos ventiladores no pudieron cambiar a velocidad máxima" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Certains ventilateurs n'ont pas réussi à passer à pleine vitesse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部のファンがフルスピードに切り替えることができませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부 팬이 최대 속도로 전환하지 못함" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alguns ventiladores não conseguiram mudar para velocidade máxima" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Некоторые вентиляторы не смогли переключиться на полную скорость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分风扇设置全速失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部分風扇設定全速失敗" + } + } + } + }, + "writer.error.helperMissingAfterInstall": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يزال يتعذر العثور على المكون بعد التثبيت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Komponente konnte nach der Installation immer noch nicht gefunden werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Component still could not be found after installation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no se pudo encontrar el componente después de la instalación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le composant est toujours introuvable après l'installation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール後もコンポーネントが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치 후에도 여전히 구성 요소를 찾을 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O componente ainda não foi encontrado após a instalação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Компонент по-прежнему не найден после установки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装后仍无法找到组件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝後仍無法找到組件" + } + } + } + }, + "writer.error.restoreAutoPartial": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت بعض المراوح في استعادة التحكم التلقائي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Lüfter konnten die automatische Steuerung nicht wiederherstellen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Some fans failed to restore automatic control" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Algunos ventiladores no pudieron restablecer el control automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Certains ventilateurs n'ont pas réussi à restaurer le contrôle automatique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部のファンが自動制御を復元できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부 팬이 자동 제어를 복원하지 못했습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alguns ventiladores não conseguiram restaurar o controle automático" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Некоторым вентиляторам не удалось восстановить автоматическое управление" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "部分风扇恢复自动控制失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "部分風扇恢復自動控制失敗" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/FanControl/SMCHelper/README.md b/Plugins/FanControl/SMCHelper/README.md new file mode 100644 index 0000000..b533d51 --- /dev/null +++ b/Plugins/FanControl/SMCHelper/README.md @@ -0,0 +1,5 @@ +This directory holds the source for the bundled Fan Control SMC helper. + +The helper binary is built as `mactools-fan-smc-helper` and copied into +`FanControl.bundle/Contents/Resources/SMCHelper/` by the generated Xcode +project. diff --git a/Plugins/FanControl/SMCHelper/Sources/main.swift b/Plugins/FanControl/SMCHelper/Sources/main.swift new file mode 100644 index 0000000..8604c56 --- /dev/null +++ b/Plugins/FanControl/SMCHelper/Sources/main.swift @@ -0,0 +1,423 @@ +import Foundation +import IOKit + +private typealias SMCBytes = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 +) + +private struct SMCKeyInfo { + var dataSize: UInt32 = 0 + var dataType: UInt32 = 0 + var dataAttributes: UInt8 = 0 +} + +private struct SMCVers { + var major: UInt8 = 0 + var minor: UInt8 = 0 + var build: UInt8 = 0 + var reserved: UInt8 = 0 + var release: UInt16 = 0 +} + +private struct SMCPLimit { + var version: UInt16 = 0 + var length: UInt16 = 0 + var cpuPLimit: UInt32 = 0 + var gpuPLimit: UInt32 = 0 + var memPLimit: UInt32 = 0 +} + +private struct SMCParam { + var key: UInt32 = 0 + var vers = SMCVers() + var pLimit = SMCPLimit() + var keyInfo = SMCKeyInfo() + var padding: UInt16 = 0 + var result: UInt8 = 0 + var status: UInt8 = 0 + var data8: UInt8 = 0 + var data32: UInt32 = 0 + var bytes: SMCBytes = ( + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 + ) +} + +private struct SMCValue { + var dataSize: UInt32 + var dataType: UInt32 + var bytes: [UInt8] +} + +private let kernelIndexSMC: UInt32 = 2 +private let smcCmdReadBytes: UInt8 = 5 +private let smcCmdWriteBytes: UInt8 = 6 +private let smcCmdReadKeyInfo: UInt8 = 9 + +private let typeFLT = fourCC("flt ") +private let typeFPE2 = fourCC("fpe2") +private let typeSP78 = fourCC("sp78") +private let typeUI8 = fourCC("ui8 ") +private let typeUI16 = fourCC("ui16") +private let typeUI32 = fourCC("ui32") + +private enum SMCHelperError: LocalizedError { + case serviceNotFound + case openFailed(kern_return_t) + case keyInfoFailed(String, kern_return_t) + case invalidKeyInfo(String) + case readFailed(String, kern_return_t) + case writeFailed(String, kern_return_t) + case unsupportedWriteType(String, UInt32, UInt32) + case invalidArguments + + var errorDescription: String? { + switch self { + case .serviceNotFound: + return "AppleSMC service not found" + case .openFailed(let code): + return "Failed to open SMC connection: \(String(format: "%08x", code))" + case .keyInfoFailed(let key, let code): + return "Failed to read key info for \(key): \(String(format: "%08x", code))" + case .invalidKeyInfo(let key): + return "Invalid key info for \(key)" + case .readFailed(let key, let code): + return "Failed to read \(key): \(String(format: "%08x", code))" + case .writeFailed(let key, let code): + return "Failed to write \(key): \(String(format: "%08x", code))" + case .unsupportedWriteType(let key, let type, let size): + return "Unsupported SMC type for \(key): \(fourCCString(type))/\(size)" + case .invalidArguments: + return "Invalid arguments" + } + } +} + +private final class SMCConnection { + private var connection: io_connect_t = 0 + private var keyInfoCache: [UInt32: SMCKeyInfo] = [:] + + init() throws { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC")) + guard service != 0 else { + throw SMCHelperError.serviceNotFound + } + defer { IOObjectRelease(service) } + + let result = IOServiceOpen(service, mach_task_self_, 0, &connection) + guard result == kIOReturnSuccess else { + throw SMCHelperError.openFailed(result) + } + } + + deinit { + if connection != 0 { + IOServiceClose(connection) + connection = 0 + } + } + + func readValue(key: String) throws -> SMCValue { + let keyCode = fourCC(key) + let info = try keyInfo(for: key, keyCode: keyCode) + + var input = SMCParam() + input.key = keyCode + input.keyInfo.dataSize = info.dataSize + input.data8 = smcCmdReadBytes + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + guard result == kIOReturnSuccess, output.result == 0 else { + throw SMCHelperError.readFailed(key, result) + } + + return SMCValue( + dataSize: info.dataSize, + dataType: info.dataType, + bytes: byteArray(output.bytes) + ) + } + + func writeValue(key: String, value: SMCValue) throws { + var bytes = value.bytes + if bytes.count < 32 { + bytes.append(contentsOf: Array(repeating: 0, count: 32 - bytes.count)) + } + + var input = SMCParam() + input.key = fourCC(key) + input.keyInfo.dataSize = value.dataSize + input.data8 = smcCmdWriteBytes + input.bytes = bytesTuple(bytes) + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + guard result == kIOReturnSuccess, output.result == 0 else { + throw SMCHelperError.writeFailed(key, result) + } + } + + private func keyInfo(for key: String, keyCode: UInt32) throws -> SMCKeyInfo { + if let cached = keyInfoCache[keyCode] { + return cached + } + + var input = SMCParam() + input.key = keyCode + input.data8 = smcCmdReadKeyInfo + + var output = SMCParam() + var outputSize = MemoryLayout.size + let result = IOConnectCallStructMethod( + connection, + kernelIndexSMC, + &input, + MemoryLayout.size, + &output, + &outputSize + ) + guard result == kIOReturnSuccess, output.result == 0 else { + throw SMCHelperError.keyInfoFailed(key, result) + } + guard output.keyInfo.dataSize > 0, output.keyInfo.dataSize <= 32 else { + throw SMCHelperError.invalidKeyInfo(key) + } + + keyInfoCache[keyCode] = output.keyInfo + return output.keyInfo + } +} + +private func setFanSpeed(_ rpm: Int, fanIndex: Int, connection: SMCConnection) throws { + try setFanMode(1, fanIndex: fanIndex, connection: connection) + + let key = "F\(fanIndex)Tg" + var value = try connection.readValue(key: key) + + switch (value.dataType, value.dataSize) { + case (typeFLT, 4): + var float = Float32(rpm) + value.bytes = withUnsafeBytes(of: &float) { Array($0) } + case (typeFPE2, 2): + let encoded = UInt16(rpm << 2) + value.bytes[0] = UInt8((encoded >> 8) & 0xff) + value.bytes[1] = UInt8(encoded & 0xff) + default: + throw SMCHelperError.unsupportedWriteType(key, value.dataType, value.dataSize) + } + + try connection.writeValue(key: key, value: value) +} + +private func setFanMode(_ mode: UInt8, fanIndex: Int, connection: SMCConnection) throws { + let key = "F\(fanIndex)Md" + + do { + var value = try connection.readValue(key: key) + guard value.dataSize == 1 else { + return + } + value.bytes[0] = mode + try connection.writeValue(key: key, value: value) + } catch SMCHelperError.keyInfoFailed, + SMCHelperError.invalidKeyInfo, + SMCHelperError.readFailed { + // Some Macs do not expose F{n}Md. F{n}Tg still controls the target. + return + } +} + +private func fanCount(connection: SMCConnection) throws -> Int { + let value = try connection.readValue(key: "FNum") + return Int(parseDouble(value) ?? 0) +} + +private func printFanInfo(connection: SMCConnection) throws { + let count = try fanCount(connection: connection) + print("Total fans: \(count)") + + for index in 0.. Double? { + switch value.dataType { + case typeFLT: + guard value.dataSize == 4, value.bytes.count >= 4 else { return nil } + let float = value.bytes.withUnsafeBufferPointer { + $0.baseAddress!.withMemoryRebound(to: Float32.self, capacity: 1) { $0.pointee } + } + return Double(float) + case typeSP78: + guard value.dataSize == 2, value.bytes.count >= 2 else { return nil } + let raw = (Int(value.bytes[0]) << 8) | Int(value.bytes[1]) + return Double(Int16(bitPattern: UInt16(raw))) / 256.0 + case typeFPE2: + guard value.dataSize == 2, value.bytes.count >= 2 else { return nil } + return Double((Int(value.bytes[0]) << 6) + (Int(value.bytes[1]) >> 2)) + case typeUI8: + guard value.dataSize == 1, value.bytes.count >= 1 else { return nil } + return Double(value.bytes[0]) + case typeUI16: + guard value.dataSize == 2, value.bytes.count >= 2 else { return nil } + return Double((Int(value.bytes[0]) << 8) | Int(value.bytes[1])) + case typeUI32: + guard value.dataSize == 4, value.bytes.count >= 4 else { return nil } + let raw = (UInt32(value.bytes[0]) << 24) + | (UInt32(value.bytes[1]) << 16) + | (UInt32(value.bytes[2]) << 8) + | UInt32(value.bytes[3]) + return Double(raw) + default: + if value.dataSize == 2, value.bytes.count >= 2 { + return Double((Int(value.bytes[0]) << 8) | Int(value.bytes[1])) + } + return nil + } +} + +private func fourCC(_ value: String) -> UInt32 { + var result: UInt32 = 0 + for (index, byte) in value.utf8.prefix(4).enumerated() { + result |= UInt32(byte) << (8 * (3 - index)) + } + return result +} + +private func fourCCString(_ value: UInt32) -> String { + let bytes = [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff) + ] + return String(bytes: bytes, encoding: .ascii) ?? "\(value)" +} + +private func byteArray(_ bytes: SMCBytes) -> [UInt8] { + [ + bytes.0, bytes.1, bytes.2, bytes.3, + bytes.4, bytes.5, bytes.6, bytes.7, + bytes.8, bytes.9, bytes.10, bytes.11, + bytes.12, bytes.13, bytes.14, bytes.15, + bytes.16, bytes.17, bytes.18, bytes.19, + bytes.20, bytes.21, bytes.22, bytes.23, + bytes.24, bytes.25, bytes.26, bytes.27, + bytes.28, bytes.29, bytes.30, bytes.31 + ] +} + +private func bytesTuple(_ bytes: [UInt8]) -> SMCBytes { + ( + bytes[safe: 0], bytes[safe: 1], bytes[safe: 2], bytes[safe: 3], + bytes[safe: 4], bytes[safe: 5], bytes[safe: 6], bytes[safe: 7], + bytes[safe: 8], bytes[safe: 9], bytes[safe: 10], bytes[safe: 11], + bytes[safe: 12], bytes[safe: 13], bytes[safe: 14], bytes[safe: 15], + bytes[safe: 16], bytes[safe: 17], bytes[safe: 18], bytes[safe: 19], + bytes[safe: 20], bytes[safe: 21], bytes[safe: 22], bytes[safe: 23], + bytes[safe: 24], bytes[safe: 25], bytes[safe: 26], bytes[safe: 27], + bytes[safe: 28], bytes[safe: 29], bytes[safe: 30], bytes[safe: 31] + ) +} + +private extension Array where Element == UInt8 { + subscript(safe index: Int) -> UInt8 { + indices.contains(index) ? self[index] : 0 + } +} + +private func usage() { + let program = (CommandLine.arguments.first as NSString?)?.lastPathComponent ?? "mactools-fan-smc-helper" + print("MacTools Fan SMC Helper") + print("Usage:") + print(" \(program) info") + print(" \(program) read ") + print(" \(program) set ") + print(" \(program) auto ") +} + +private func run() throws { + let arguments = CommandLine.arguments + guard arguments.count >= 2 else { + usage() + throw SMCHelperError.invalidArguments + } + + let connection = try SMCConnection() + + switch arguments[1] { + case "info": + try printFanInfo(connection: connection) + case "read": + guard arguments.count >= 3 else { + throw SMCHelperError.invalidArguments + } + try printKey(arguments[2], connection: connection) + case "set": + guard arguments.count >= 4, + let fanIndex = Int(arguments[2]), + let rpm = Int(arguments[3]) + else { + throw SMCHelperError.invalidArguments + } + try setFanSpeed(rpm, fanIndex: fanIndex, connection: connection) + case "auto": + guard arguments.count >= 3, + let fanIndex = Int(arguments[2]) + else { + throw SMCHelperError.invalidArguments + } + try setFanMode(0, fanIndex: fanIndex, connection: connection) + default: + usage() + throw SMCHelperError.invalidArguments + } +} + +do { + try run() +} catch { + FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8)) + exit(1) +} diff --git a/Plugins/FanControl/Sources/FanControlLog.swift b/Plugins/FanControl/Sources/FanControlLog.swift new file mode 100644 index 0000000..ff84e55 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlLog.swift @@ -0,0 +1,7 @@ +import OSLog + +enum FanControlLog { + static let plugin = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "FanControlPlugin") + static let smc = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "FanControlSMC") + static let writer = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "FanControlWriter") +} diff --git a/Plugins/FanControl/Sources/FanControlModels.swift b/Plugins/FanControl/Sources/FanControlModels.swift new file mode 100644 index 0000000..a0268d9 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlModels.swift @@ -0,0 +1,162 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - Fan Preset + +struct FanPreset: Identifiable, Codable, Equatable { + let id: String + var name: String + var strategy: FanControlStrategy + /// Built-in presets (auto / full-speed) cannot be deleted or renamed. + let isBuiltIn: Bool + + init(id: String, name: String, strategy: FanControlStrategy, isBuiltIn: Bool = false) { + self.id = id + self.name = name + self.strategy = strategy + self.isBuiltIn = isBuiltIn + } + + func displayName(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch id { + case FanPresetBuiltInID.auto: + return localization.string("preset.auto.name", defaultValue: "自动") + case FanPresetBuiltInID.fullSpeed: + return localization.string("preset.fullSpeed.name", defaultValue: "全速") + default: + return name + } + } +} + +enum FanControlStrategy: Codable, Equatable { + /// Restore macOS automatic fan management. + case auto + /// Set all fans to their hardware-reported maximum speed. + case fullSpeed + /// Set all fans to a fixed RPM (clamped to hardware limits at write time). + case fixed(rpm: Int) +} + +// MARK: - Built-in Preset IDs + +enum FanPresetBuiltInID { + static let auto = "builtin-auto" + static let fullSpeed = "builtin-full-speed" +} + +// MARK: - Fan Snapshot + +struct FanSnapshot: Equatable { + var fanCount: Int + var fanSpeeds: [Int] + var fanMinSpeeds: [Int] + var fanMaxSpeeds: [Int] + var cpuTemperature: Double? + + var averageSpeed: Int? { + guard !fanSpeeds.isEmpty else { return nil } + return fanSpeeds.reduce(0, +) / fanSpeeds.count + } + + var globalMaxSpeed: Int { + fanMaxSpeeds.max() ?? FanRPMLimits.fallbackMax + } + + static let empty = FanSnapshot( + fanCount: 0, + fanSpeeds: [], + fanMinSpeeds: [], + fanMaxSpeeds: [], + cpuTemperature: nil + ) + + func isMeaningfullyEquivalent( + to other: FanSnapshot, + rpmTolerance: Int = 10, + temperatureTolerance: Double = 0.5 + ) -> Bool { + guard + fanCount == other.fanCount, + fanMinSpeeds == other.fanMinSpeeds, + fanMaxSpeeds == other.fanMaxSpeeds, + fanSpeeds.count == other.fanSpeeds.count + else { + return false + } + + for (lhs, rhs) in zip(fanSpeeds, other.fanSpeeds) { + if abs(lhs - rhs) > rpmTolerance { + return false + } + } + + switch (cpuTemperature, other.cpuTemperature) { + case (.none, .none): + return true + case let (.some(lhs), .some(rhs)): + return abs(lhs - rhs) <= temperatureTolerance + case (.some, .none), (.none, .some): + return false + } + } +} + +// MARK: - RPM Limits + +enum FanRPMLimits { + static let absoluteMin = 500 + static let absoluteMax = 8000 + static let fallbackMin = 1000 + static let fallbackMax = 5200 + static let defaultCustomRPM = 3000 +} + +// MARK: - Write Error + +enum FanWriteError: Error, LocalizedError { + case helperNotFound + case helperInstallFailed(String) + case helperVerificationFailed + case writeFailed(String) + + var errorDescription: String? { + localizedDescription() + } + + func localizedDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .helperNotFound: + return localization.string( + "writeError.helperNotFound", + defaultValue: "未找到内置风扇控制组件。请重新安装风扇控制插件。" + ) + case .helperInstallFailed(let msg): + return localization.format( + "writeError.helperInstallFailed", + defaultValue: "安装风扇控制组件失败:%@", + msg + ) + case .helperVerificationFailed: + return localization.string( + "writeError.helperVerificationFailed", + defaultValue: "风扇控制组件校验失败。请重新安装风扇控制插件。" + ) + case .writeFailed(let msg): + return localization.format("writeError.writeFailed", defaultValue: "写入风扇速度失败:%@", msg) + } + } +} + +// MARK: - SMC Protocols + +protocol FanControlSMCReading: AnyObject { + @MainActor func readSnapshot() -> FanSnapshot +} + +protocol FanControlSMCWriting: AnyObject { + @MainActor var isHelperAvailable: Bool { get } + @MainActor var isInstalledHelperAvailable: Bool { get } + @MainActor @discardableResult + func apply(strategy: FanControlStrategy, snapshot: FanSnapshot) -> FanWriteError? +} diff --git a/Plugins/FanControl/Sources/FanControlPlugin.swift b/Plugins/FanControl/Sources/FanControlPlugin.swift new file mode 100644 index 0000000..651add4 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlPlugin.swift @@ -0,0 +1,511 @@ +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +// MARK: - Bundle Factory + +public final class FanControlPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + FanControlPluginProvider(context: context) + } +} + +@MainActor +private struct FanControlPluginProvider: PluginProvider { + let context: PluginRuntimeContext + func makePlugins() -> [any MacToolsPlugin] { + [FanControlPlugin(context: context, localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +// MARK: - Control IDs + +private enum ControlID { + static let presetList = "fan-preset-list" + static let customSlider = "fan-custom-rpm" + static let addPreset = "fan-add-preset" + static let deletePreset = "fan-delete-preset" + static let missingHelper = "fan-missing-helper" +} + +// MARK: - Plugin + +@MainActor +final class FanControlPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginFeatureVisibilityLifecycleHandling { + + // MARK: Metadata + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + // MARK: Callbacks + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + // MARK: Private State + + let presetStore: FanControlPresetStore + private let smcReader: any FanControlSMCReading + private let smcWriter: any FanControlSMCWriting + private let localization: PluginLocalization + private let monitoringActiveInterval: Duration + private let monitoringIdleInterval: Duration + + private var isExpanded = false + private var isFeaturePanelVisible = false + private var fanSnapshot = FanSnapshot.empty + private var lastErrorMessage: String? + private var requiresAutoRestore = false + private var monitoringTask: Task? + private var sleepObserver: (any NSObjectProtocol)? + private var wakeObserver: (any NSObjectProtocol)? + + // MARK: Init + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "fan-control"), + smcReader: any FanControlSMCReading = FanControlSMCReader(), + smcWriter: (any FanControlSMCWriting)? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main), + monitoringActiveInterval: Duration = .seconds(2), + monitoringIdleInterval: Duration = .seconds(10) + ) { + self.localization = localization + self.presetStore = FanControlPresetStore(storage: context.storage, localization: localization) + self.smcReader = smcReader + self.smcWriter = smcWriter ?? FanControlSMCWriter( + resourceBundle: context.resourceBundle, + localization: localization + ) + self.monitoringActiveInterval = monitoringActiveInterval + self.monitoringIdleInterval = monitoringIdleInterval + self.metadata = PluginMetadata( + id: "fan-control", + title: localization.string("metadata.title", defaultValue: "风扇控制"), + iconName: "fan", + iconTint: Color(nsColor: .systemCyan), + order: 45, + defaultDescription: localization.string("metadata.description", defaultValue: "管理风扇转速预设") + ) + } + + // MARK: - MacToolsPlugin + + func activate(context: PluginRuntimeContext) { + startMonitoring() + registerSleepWakeObservers() + // Re-apply the persisted active preset so fan state is consistent + // even after the app restarts. Skip if already "auto" to avoid + // spurious admin prompts on launch. + let preset = presetStore.activePreset + if case .auto = preset.strategy { return } + applyActivePreset() + } + + func deactivate(reason: PluginDeactivationReason) { + unregisterSleepWakeObservers() + stopMonitoring() + if reason.requiresStateCleanup { + restoreAutomaticControlIfNeeded(reason: String(describing: reason)) + } + } + + func refresh() { + fanSnapshot = smcReader.readSnapshot() + onStateChange?() + } + + // MARK: - PluginPrimaryPanel + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: panelSubtitle, + isOn: false, + isExpanded: isExpanded, + isEnabled: true, + isVisible: true, + detail: isExpanded ? buildDetail() : nil, + errorMessage: lastErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + var configuration: PluginConfiguration? { + PluginConfiguration(description: metadata.defaultDescription) { [self] _ in + FanControlPresetManagerView( + presetStore: self.presetStore, + fanSnapshot: self.fanSnapshot, + localization: self.localization + ) + } + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(expanded): + guard isExpanded != expanded else { return } + isExpanded = expanded + if !expanded { lastErrorMessage = nil } + restartMonitoringIfRunning() + onStateChange?() + + case let .setSelection(controlID, optionID): + guard controlID == ControlID.presetList else { return } + guard presetStore.allPresets.contains(where: { $0.id == optionID }) else { return } + presetStore.setActivePreset(id: optionID) + lastErrorMessage = nil + onStateChange?() + applyActivePreset() + + case let .setSlider(controlID, value, phase): + guard controlID == ControlID.customSlider, phase == .ended else { return } + let rpm = Int(value) + let activeID = presetStore.activePresetID + presetStore.updateCustomPresetRPM(id: activeID, rpm: rpm) + lastErrorMessage = nil + onStateChange?() + applyActivePreset() + + case let .invokeAction(controlID): + handleInvokeAction(controlID) + + case .setSwitch, .setNavigationSelection, .clearNavigationSelection, .setDate: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + func featureVisibilityDidChange(_ isVisible: Bool) { + guard isFeaturePanelVisible != isVisible else { + return + } + + isFeaturePanelVisible = isVisible + restartMonitoringIfRunning() + } + + // MARK: - Actions + + private func handleInvokeAction(_ controlID: String) { + switch controlID { + case ControlID.addPreset: + // Navigation to settings page is handled by MenuBarContent; + // the host intercepts this action ID and calls + // pluginHost.presentPluginConfiguration(pluginID: "fan-control"). + break + + case ControlID.deletePreset: + let idToDelete = presetStore.activePresetID + guard !presetStore.activePreset.isBuiltIn else { return } + presetStore.deleteCustomPreset(id: idToDelete) + lastErrorMessage = nil + onStateChange?() + // Active preset has been reset to auto by the store + applyActivePreset() + + default: + break + } + } + + private func applyActivePreset() { + let preset = presetStore.activePreset + let snapshot = fanSnapshot.fanCount > 0 ? fanSnapshot : smcReader.readSnapshot() + let result = smcWriter.apply(strategy: preset.strategy, snapshot: snapshot) + updateAutoRestoreRequirement(afterApplying: preset.strategy, result: result) + if let err = result { + lastErrorMessage = err.localizedDescription(localization: localization) + FanControlLog.plugin.error("Apply preset failed: \(err.localizedDescription, privacy: .public)") + } else { + lastErrorMessage = nil + } + onStateChange?() + } + + // MARK: - Monitoring + + private func startMonitoring() { + guard monitoringTask == nil else { return } + monitoringTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + guard let self else { + return + } + + let snapshot = self.smcReader.readSnapshot() + if self.updateFanSnapshotIfNeeded(snapshot) { + self.onStateChange?() + } + try? await Task.sleep(for: self.currentMonitoringInterval) + } + } + } + + private func stopMonitoring() { + monitoringTask?.cancel() + monitoringTask = nil + } + + private var currentMonitoringInterval: Duration { + if isFeaturePanelVisible && isExpanded { + return monitoringActiveInterval + } + return monitoringIdleInterval + } + + private func updateFanSnapshotIfNeeded(_ snapshot: FanSnapshot) -> Bool { + guard !fanSnapshot.isMeaningfullyEquivalent(to: snapshot) else { + return false + } + + fanSnapshot = snapshot + return true + } + + private func restartMonitoringIfRunning() { + guard monitoringTask != nil else { + return + } + + stopMonitoring() + startMonitoring() + } + + private func registerSleepWakeObservers() { + let center = NSWorkspace.shared.notificationCenter + sleepObserver = center.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.handleSystemWillSleep() } + } + wakeObserver = center.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.handleSystemDidWake() } + } + } + + private func unregisterSleepWakeObservers() { + let center = NSWorkspace.shared.notificationCenter + if let obs = sleepObserver { center.removeObserver(obs) } + if let obs = wakeObserver { center.removeObserver(obs) } + sleepObserver = nil + wakeObserver = nil + } + + private func handleSystemWillSleep() { + let preset = presetStore.activePreset + guard case .auto = preset.strategy else { + restoreAutomaticControlIfNeeded(reason: "system sleep") + return + } + } + + private func handleSystemDidWake() { + fanSnapshot = smcReader.readSnapshot() + let preset = presetStore.activePreset + if case .auto = preset.strategy { return } + applyActivePreset() + FanControlLog.plugin.info("System did wake — re-applied active preset: \(preset.name, privacy: .public)") + } + + // MARK: - Panel Builder + + private var panelSubtitle: String { + let preset = presetStore.activePreset + if let rpm = fanSnapshot.averageSpeed, rpm > 0 { + return localization.format( + "panel.subtitle.withRPM", + defaultValue: "%@ · %@", + preset.displayName(localization: localization), + "\(rpm) RPM" + ) + } + return preset.displayName(localization: localization) + } + + private func buildDetail() -> PluginPanelDetail { + var controls: [PluginPanelControl] = [] + + // 1. Preset select list + let presetOptions = presetStore.allPresets.map { + PluginPanelControlOption( + id: $0.id, + title: $0.displayName(localization: localization), + subtitle: presetSubtitle(for: $0) + ) + } + controls.append(PluginPanelControl( + id: ControlID.presetList, + kind: .selectList, + options: presetOptions, + selectedOptionID: presetStore.activePresetID, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + isEnabled: true + )) + + // 2. Slider for the active custom preset + let activePreset = presetStore.activePreset + if case let .fixed(rpm) = activePreset.strategy, !activePreset.isBuiltIn { + let maxSlider = Double(fanSnapshot.globalMaxSpeed > 0 + ? fanSnapshot.globalMaxSpeed + : FanRPMLimits.fallbackMax) + controls.append(PluginPanelControl( + id: ControlID.customSlider, + kind: .slider, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: localization.string("panel.slider.targetRPM", defaultValue: "目标转速"), + sliderValue: Double(rpm), + sliderBounds: Double(FanRPMLimits.absoluteMin)...maxSlider, + sliderStep: 100, + valueLabel: "\(rpm) RPM", + isEnabled: true + )) + + // 3. Delete button for custom preset + controls.append(PluginPanelControl( + id: ControlID.deletePreset, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.deletePreset", defaultValue: "删除此预设"), + actionIconSystemName: "trash", + isEnabled: true + )) + } + + // 4. Add custom preset → opens plugin settings page + controls.append(PluginPanelControl( + id: ControlID.addPreset, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.managePresets", defaultValue: "管理预设…"), + actionIconSystemName: "slider.horizontal.3", + actionBehavior: .dismissBeforeHandling, + showsLeadingDivider: true, + isEnabled: true + )) + + // 5. Helper-not-found warning + if !smcWriter.isHelperAvailable { + controls.append(PluginPanelControl( + id: ControlID.missingHelper, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.missingHelper", defaultValue: "风扇控制组件缺失"), + actionIconSystemName: "exclamationmark.triangle", + actionBehavior: .dismissBeforeHandling, + showsLeadingDivider: true, + isEnabled: false + )) + } + + return PluginPanelDetail(primaryControls: controls, secondaryPanel: nil) + } + + private func presetSubtitle(for preset: FanPreset) -> String? { + switch preset.strategy { + case .auto: + return localization.string("preset.auto.subtitle", defaultValue: "由 macOS 管理") + case .fullSpeed: + let maxRPM = fanSnapshot.globalMaxSpeed + return maxRPM > 0 + ? localization.format("preset.fullSpeed.subtitleWithRPM", defaultValue: "最高 %d RPM", maxRPM) + : localization.string("preset.fullSpeed.subtitle", defaultValue: "最高转速") + case .fixed(let rpm): + return "\(rpm) RPM" + } + } + + private func updateAutoRestoreRequirement(afterApplying strategy: FanControlStrategy, result: FanWriteError?) { + switch strategy { + case .auto: + if result == nil { + requiresAutoRestore = false + } + case .fullSpeed, .fixed: + if result == nil || result?.mayHaveChangedFanState == true { + requiresAutoRestore = true + } + } + } + + private func restoreAutomaticControlIfNeeded(reason: String) { + guard requiresAutoRestore else { + FanControlLog.plugin.info("Skipped fan auto restore for \(reason, privacy: .public); no successful manual fan write in this session") + return + } + + guard smcWriter.isInstalledHelperAvailable else { + FanControlLog.plugin.info("Skipped fan auto restore for \(reason, privacy: .public); SMC helper is not installed") + return + } + + let snapshot = fanSnapshot.fanCount > 0 ? fanSnapshot : smcReader.readSnapshot() + let result = smcWriter.apply(strategy: .auto, snapshot: snapshot) + updateAutoRestoreRequirement(afterApplying: .auto, result: result) + + if let result { + FanControlLog.plugin.error("Failed to restore fan control to auto for \(reason, privacy: .public): \(result.localizedDescription, privacy: .public)") + } else { + FanControlLog.plugin.info("Restored fan control to auto for \(reason, privacy: .public)") + } + } +} + +private extension FanWriteError { + var mayHaveChangedFanState: Bool { + if case .writeFailed = self { + return true + } + + return false + } +} diff --git a/Plugins/FanControl/Sources/FanControlPresetManagerView.swift b/Plugins/FanControl/Sources/FanControlPresetManagerView.swift new file mode 100644 index 0000000..2f58c11 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlPresetManagerView.swift @@ -0,0 +1,282 @@ +import SwiftUI +import MacToolsPluginKit + +// MARK: - FanControlPresetManagerView + +struct FanControlPresetManagerView: View { + @ObservedObject var presetStore: FanControlPresetStore + /// Live snapshot for showing actual hardware max RPM in sliders. + var fanSnapshot: FanSnapshot + var localization: PluginLocalization = PluginLocalization(bundle: .main) + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + builtInSection + customSection + } + } + + // MARK: - Built-in Section + + private var builtInSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader( + title: localization.string("settings.builtIn.title", defaultValue: "内置预设"), + icon: "lock" + ) + + VStack(spacing: 0) { + ForEach(FanControlPresetStore.builtInPresets) { preset in + BuiltInPresetRow( + preset: preset, + fanSnapshot: fanSnapshot, + localization: localization + ) + if preset.id != FanControlPresetStore.builtInPresets.last?.id { + PluginSettingsListDivider() + } + } + } + .pluginSettingsCardBackground(.host) + } + } + + // MARK: - Custom Section + + private var customSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack { + sectionHeader( + title: localization.string("settings.custom.title", defaultValue: "自定义预设"), + icon: "slider.horizontal.3" + ) + Spacer() + Button(action: addPreset) { + Label(localization.string("settings.custom.add", defaultValue: "添加"), systemImage: "plus") + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if presetStore.customPresets.isEmpty { + emptyCustomPresetsView + } else { + VStack(spacing: 0) { + ForEach(presetStore.customPresets) { preset in + CustomPresetRow( + preset: preset, + fanSnapshot: fanSnapshot, + localization: localization, + onRename: { presetStore.renameCustomPreset(id: preset.id, newName: $0) }, + onRPMChange: { presetStore.updateCustomPresetRPM(id: preset.id, rpm: $0) }, + onDelete: { presetStore.deleteCustomPreset(id: preset.id) } + ) + if preset.id != presetStore.customPresets.last?.id { + PluginSettingsListDivider() + } + } + } + .pluginSettingsCardBackground(.host) + } + } + } + + private var emptyCustomPresetsView: some View { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: PluginSettingsTheme.Size.emptyStateIcon)) + .foregroundStyle(.secondary) + Text(localization.string("settings.custom.empty", defaultValue: "点击「添加」创建自定义转速预设")) + .font(PluginSettingsTheme.Typography.pageDescription) + .foregroundStyle(.secondary) + } + .padding(.vertical, PluginSettingsTheme.Spacing.pagePadding) + Spacer() + } + .pluginSettingsCardBackground(.host) + } + + // MARK: - Helpers + + private func sectionHeader(title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + } + + private func addPreset() { + _ = presetStore.addCustomPreset() + } +} + +// MARK: - BuiltInPresetRow + +private struct BuiltInPresetRow: View { + let preset: FanPreset + let fanSnapshot: FanSnapshot + let localization: PluginLocalization + + var body: some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(preset.displayName(localization: localization)) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text(subtitle) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + Spacer() + Text(localization.string("settings.builtIn.badge", defaultValue: "内置")) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule().fill(Color(nsColor: .quaternaryLabelColor).opacity(0.4)) + ) + } + .pluginSettingsListRowPadding() + } + + private var subtitle: String { + switch preset.strategy { + case .auto: + return localization.string("settings.builtIn.auto.subtitle", defaultValue: "由 macOS 自动管理") + case .fullSpeed: + let max = fanSnapshot.globalMaxSpeed + return max > 0 + ? localization.format("preset.fullSpeed.subtitleWithRPM", defaultValue: "最高 %d RPM", max) + : localization.string("preset.fullSpeed.subtitle", defaultValue: "最高转速") + case .fixed(let rpm): + return "\(rpm) RPM" + } + } +} + +// MARK: - CustomPresetRow + +private struct CustomPresetRow: View { + let preset: FanPreset + let fanSnapshot: FanSnapshot + let localization: PluginLocalization + let onRename: (String) -> Void + let onRPMChange: (Int) -> Void + let onDelete: () -> Void + + @State private var nameText: String + @State private var sliderValue: Double + @FocusState private var isNameFocused: Bool + @State private var isNameHovered = false + + init( + preset: FanPreset, + fanSnapshot: FanSnapshot, + localization: PluginLocalization = PluginLocalization(bundle: .main), + onRename: @escaping (String) -> Void, + onRPMChange: @escaping (Int) -> Void, + onDelete: @escaping () -> Void + ) { + self.preset = preset + self.fanSnapshot = fanSnapshot + self.localization = localization + self.onRename = onRename + self.onRPMChange = onRPMChange + self.onDelete = onDelete + let rpm = { if case .fixed(let r) = preset.strategy { return r }; return FanRPMLimits.defaultCustomRPM }() + _nameText = State(initialValue: preset.name) + _sliderValue = State(initialValue: Double(rpm)) + } + + private var currentRPM: Int { + if case .fixed(let r) = preset.strategy { return r } + return FanRPMLimits.defaultCustomRPM + } + + private func resignFocus() { + isNameFocused = false + NSApp.keyWindow?.makeFirstResponder(nil) + } + + private var sliderMax: Double { + let max = fanSnapshot.globalMaxSpeed + return Double(max > 0 ? max : FanRPMLimits.fallbackMax) + } + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + TextField(localization.string("settings.custom.namePlaceholder", defaultValue: "预设名称"), text: $nameText) + .textFieldStyle(.plain) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isNameFocused + ? PluginSettingsTheme.Palette.nativeFieldBackground + : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder( + isNameFocused + ? Color(nsColor: .controlAccentColor) + : isNameHovered + ? PluginSettingsTheme.Palette.nativeSeparator + : Color.clear, + lineWidth: 1 + ) + ) + .frame(maxWidth: 100) + .onHover { isNameHovered = $0 } + .focused($isNameFocused) + .onSubmit { onRename(nameText) } + .onChange(of: nameText) { _, new in + onRename(new) + } + + Spacer() + + Button(role: .destructive, action: onDelete) { + Image(systemName: "trash") + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help(localization.string("settings.custom.deleteHelp", defaultValue: "删除此预设")) + } + + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Slider( + value: $sliderValue, + in: Double(FanRPMLimits.absoluteMin)...sliderMax, + step: 100 + ) { + EmptyView() + } onEditingChanged: { editing in + if editing { resignFocus() } + if !editing { onRPMChange(Int(sliderValue)) } + } + + Text("\(Int(sliderValue)) RPM") + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .frame(width: 80, alignment: .trailing) + } + } + .pluginSettingsListRowPadding(interactive: true) + .contentShape(Rectangle()) + .onTapGesture { resignFocus() } + // Sync external changes (e.g. from panel slider) back to local state + .onChange(of: preset.strategy) { _, newStrategy in + if case .fixed(let r) = newStrategy { + sliderValue = Double(r) + } + } + .onChange(of: preset.name) { _, newName in + if nameText != newName { nameText = newName } + } + } +} diff --git a/Plugins/FanControl/Sources/FanControlPresetStore.swift b/Plugins/FanControl/Sources/FanControlPresetStore.swift new file mode 100644 index 0000000..a0342b7 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlPresetStore.swift @@ -0,0 +1,130 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - FanControlPresetStore + +/// Manages fan presets (built-in + user-created) with persistence via PluginStorage. +@MainActor +final class FanControlPresetStore: ObservableObject { + // MARK: - Storage Keys + + private enum Key { + static let customPresets = "custom-presets" + static let activePresetID = "active-preset-id" + } + + // MARK: - Built-in Presets + + static let builtInPresets: [FanPreset] = [ + FanPreset( + id: FanPresetBuiltInID.auto, + name: "", + strategy: .auto, + isBuiltIn: true + ), + FanPreset( + id: FanPresetBuiltInID.fullSpeed, + name: "", + strategy: .fullSpeed, + isBuiltIn: true + ), + ] + + // MARK: - State + + private let storage: PluginStorage + private let localization: PluginLocalization + @Published private(set) var customPresets: [FanPreset] = [] + @Published private(set) var activePresetID: String = FanPresetBuiltInID.auto + + var allPresets: [FanPreset] { + Self.builtInPresets + customPresets + } + + var activePreset: FanPreset { + allPresets.first(where: { $0.id == activePresetID }) + ?? Self.builtInPresets[0] + } + + // MARK: - Init + + init( + storage: PluginStorage, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.storage = storage + self.localization = localization + load() + } + + // MARK: - Persistence + + private func load() { + if let data = storage.data(forKey: Key.customPresets), + let decoded = try? JSONDecoder().decode([FanPreset].self, from: data) { + customPresets = decoded + } + if let saved = storage.string(forKey: Key.activePresetID) { + activePresetID = saved + } + // Validate active ID still exists + if !allPresets.contains(where: { $0.id == activePresetID }) { + activePresetID = FanPresetBuiltInID.auto + } + } + + private func saveCustomPresets() { + if let data = try? JSONEncoder().encode(customPresets) { + storage.set(data, forKey: Key.customPresets) + } + } + + private func saveActivePresetID() { + storage.set(activePresetID, forKey: Key.activePresetID) + } + + // MARK: - CRUD + + func setActivePreset(id: String) { + guard allPresets.contains(where: { $0.id == id }) else { return } + activePresetID = id + saveActivePresetID() + } + + func addCustomPreset() -> FanPreset { + let index = customPresets.count + 1 + let preset = FanPreset( + id: UUID().uuidString, + name: localization.format("preset.custom.defaultName", defaultValue: "自定义预设 %d", index), + strategy: .fixed(rpm: FanRPMLimits.absoluteMin), + isBuiltIn: false + ) + customPresets.append(preset) + saveCustomPresets() + return preset + } + + func updateCustomPresetRPM(id: String, rpm: Int) { + guard let idx = customPresets.firstIndex(where: { $0.id == id }) else { return } + let clamped = max(FanRPMLimits.absoluteMin, min(FanRPMLimits.absoluteMax, rpm)) + customPresets[idx].strategy = .fixed(rpm: clamped) + saveCustomPresets() + } + + func renameCustomPreset(id: String, newName: String) { + guard let idx = customPresets.firstIndex(where: { $0.id == id }) else { return } + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + customPresets[idx].name = trimmed + saveCustomPresets() + } + + func deleteCustomPreset(id: String) { + customPresets.removeAll(where: { $0.id == id }) + if activePresetID == id { + activePresetID = FanPresetBuiltInID.auto + saveActivePresetID() + } + saveCustomPresets() + } +} diff --git a/Plugins/FanControl/Sources/FanControlSMCReader.swift b/Plugins/FanControl/Sources/FanControlSMCReader.swift new file mode 100644 index 0000000..afef967 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlSMCReader.swift @@ -0,0 +1,241 @@ +import Foundation +import IOKit + +// MARK: - SMC Types + +private typealias SMCBytes = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 +) + +private struct SMCKeyInfo { + var dataSize: UInt32 = 0 + var dataType: UInt32 = 0 + var dataAttributes: UInt8 = 0 +} + +private struct SMCVers { + var major: UInt8 = 0; var minor: UInt8 = 0; var build: UInt8 = 0 + var reserved: UInt8 = 0; var release: UInt16 = 0 +} + +private struct SMCPLimit { + var version: UInt16 = 0; var length: UInt16 = 0 + var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0; var memPLimit: UInt32 = 0 +} + +private struct SMCParam { + var key: UInt32 = 0 + var vers = SMCVers() + var pLimit = SMCPLimit() + var keyInfo = SMCKeyInfo() + var padding: UInt16 = 0 + var result: UInt8 = 0 + var status: UInt8 = 0 + var data8: UInt8 = 0 + var data32: UInt32 = 0 + var bytes: SMCBytes = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) +} + +private let kernelIndexSMC: UInt32 = 2 +private let smcCmdReadBytes: UInt8 = 5 +private let smcCmdReadKeyInfo: UInt8 = 9 + +// MARK: - FanControlSMCReader + +@MainActor +final class FanControlSMCReader: FanControlSMCReading { + // nonisolated(unsafe) so deinit (which is nonisolated) can close the connection + // without crossing actor boundaries. + nonisolated(unsafe) private var connection: io_connect_t = 0 + private var keyInfoCache: [UInt32: SMCKeyInfo] = [:] + + // Temperature keys by priority (Intel + Apple Silicon) + private static let cpuTempKeys = [ + "TC0P", "TCXC", "TC0E", "TC0F", "TC0D", + "Tp09", "Tp0T", "Tp01", "Tp05" + ] + + var isConnected: Bool { connection != 0 } + + init() { + _ = openConnection() + } + + deinit { + if connection != 0 { + IOServiceClose(connection) + connection = 0 + } + } + + // MARK: - Connection + + @discardableResult + func openConnection() -> Bool { + guard connection == 0 else { return true } + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSMC")) + guard service != 0 else { + FanControlLog.smc.error("AppleSMC service not found") + return false + } + defer { IOObjectRelease(service) } + let result = IOServiceOpen(service, mach_task_self_, 0, &connection) + if result == kIOReturnSuccess { + FanControlLog.smc.debug("SMC connection opened") + return true + } + FanControlLog.smc.error("IOServiceOpen failed: \(result)") + return false + } + + private func closeConnection() { + if connection != 0 { + IOServiceClose(connection) + connection = 0 + } + } + + func readSnapshot() -> FanSnapshot { + guard connection != 0 else { return .empty } + + let count = readFanCount() + guard count > 0 else { return .empty } + + var speeds: [Int] = [] + var minSpeeds: [Int] = [] + var maxSpeeds: [Int] = [] + + for i in 0.. 0 }.max() + for i in maxSpeeds.indices where maxSpeeds[i] <= 0 { + maxSpeeds[i] = bestMax ?? FanRPMLimits.fallbackMax + } + + var cpuTemp: Double? + for key in Self.cpuTempKeys { + if let t = readSMCDouble(key: key), t > 0 && t < 150 { + cpuTemp = t + break + } + } + + return FanSnapshot( + fanCount: count, + fanSpeeds: speeds, + fanMinSpeeds: minSpeeds, + fanMaxSpeeds: maxSpeeds, + cpuTemperature: cpuTemp + ) + } + + // MARK: - Helpers + + private func readFanCount() -> Int { + guard let raw = readSMCDouble(key: "FNum"), raw > 0 else { return 0 } + return Int(raw) + } + + private func readFanRPM(key: String) -> Int? { + guard let val = readSMCDouble(key: key), val >= 0 else { return nil } + return Int(val) + } + + // MARK: - SMC Low-Level + + private func fourCC(_ s: String) -> UInt32 { + var r: UInt32 = 0 + for (i, c) in s.utf8.prefix(4).enumerated() { + r |= UInt32(c) << (8 * (3 - i)) + } + return r + } + + private func readSMCDouble(key: String) -> Double? { + guard connection != 0 else { return nil } + let keyCode = fourCC(key) + + // 1. Get key info (use cache) + let info: SMCKeyInfo + if let cached = keyInfoCache[keyCode] { + info = cached + } else { + var inp = SMCParam(); inp.key = keyCode; inp.data8 = smcCmdReadKeyInfo + var out = SMCParam(); var outSize = MemoryLayout.size + let r = IOConnectCallStructMethod(connection, kernelIndexSMC, &inp, MemoryLayout.size, &out, &outSize) + guard r == kIOReturnSuccess, out.result == 0, + out.keyInfo.dataSize > 0, out.keyInfo.dataSize <= 32 else { return nil } + keyInfoCache[keyCode] = out.keyInfo + info = out.keyInfo + } + + // 2. Read bytes + var inp = SMCParam() + inp.key = keyCode + inp.keyInfo.dataSize = info.dataSize + inp.data8 = smcCmdReadBytes + var out = SMCParam(); var outSize = MemoryLayout.size + let r = IOConnectCallStructMethod(connection, kernelIndexSMC, &inp, MemoryLayout.size, &out, &outSize) + guard r == kIOReturnSuccess, out.result == 0 else { return nil } + + return parseBytes(out.bytes, dataType: info.dataType, dataSize: info.dataSize) + } + + private func parseBytes(_ bytes: SMCBytes, dataType: UInt32, dataSize: UInt32) -> Double? { + let arr = [ + bytes.0, bytes.1, bytes.2, bytes.3, + bytes.4, bytes.5, bytes.6, bytes.7, + bytes.8, bytes.9, bytes.10, bytes.11, + bytes.12, bytes.13, bytes.14, bytes.15, + bytes.16, bytes.17, bytes.18, bytes.19, + bytes.20, bytes.21, bytes.22, bytes.23, + bytes.24, bytes.25, bytes.26, bytes.27, + bytes.28, bytes.29, bytes.30, bytes.31 + ] + + let typeFLT = fourCC("flt ") + let typeSP78 = fourCC("sp78") + let typeFPE2 = fourCC("fpe2") + let typeUI8 = fourCC("ui8 ") + let typeUI16 = fourCC("ui16") + let typeUI32 = fourCC("ui32") + + switch dataType { + case typeFLT: + guard dataSize == 4 else { return nil } + let val = arr.withUnsafeBufferPointer { + $0.baseAddress!.withMemoryRebound(to: Float32.self, capacity: 1) { $0.pointee } + } + return Double(val) + case typeSP78: + guard dataSize == 2 else { return nil } + let raw = (Int(arr[0]) << 8) | Int(arr[1]) + return Double(Int16(bitPattern: UInt16(raw))) / 256.0 + case typeFPE2: + guard dataSize == 2 else { return nil } + return Double((Int(arr[0]) << 6) + (Int(arr[1]) >> 2)) + case typeUI8: + guard dataSize == 1 else { return nil } + return Double(arr[0]) + case typeUI16: + guard dataSize == 2 else { return nil } + return Double((Int(arr[0]) << 8) | Int(arr[1])) + case typeUI32: + guard dataSize == 4 else { return nil } + let val = (UInt32(arr[0]) << 24) | (UInt32(arr[1]) << 16) | (UInt32(arr[2]) << 8) | UInt32(arr[3]) + return Double(val) + default: + // Unknown type — try 2-byte big-endian as last resort + if dataSize == 2 { return Double((Int(arr[0]) << 8) | Int(arr[1])) } + return nil + } + } +} diff --git a/Plugins/FanControl/Sources/FanControlSMCWriter.swift b/Plugins/FanControl/Sources/FanControlSMCWriter.swift new file mode 100644 index 0000000..387ac67 --- /dev/null +++ b/Plugins/FanControl/Sources/FanControlSMCWriter.swift @@ -0,0 +1,299 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - FanControlSMCWriter + +/// Writes SMC fan control values through MacTools' bundled SMC helper. +/// The helper is copied to `/Library/PrivilegedHelperTools` with root ownership +/// and setuid permissions on first use, so users do not need to install SoloFan's +/// external helper manually. +@MainActor +final class FanControlSMCWriter: FanControlSMCWriting { + private enum Helper { + static let bundledName = "mactools-fan-smc-helper" + static let bundledSubdirectory = "SMCHelper" + static let installPath = "/Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper" + static let installDirectory = "/Library/PrivilegedHelperTools" + } + + private let fileManager: FileManager + private let resourceBundle: Bundle + private let localization: PluginLocalization + + private var resolvedHelperPath: String? + + init( + resourceBundle: Bundle = .main, + fileManager: FileManager = .default, + localization: PluginLocalization? = nil + ) { + self.resourceBundle = resourceBundle + self.fileManager = fileManager + self.localization = localization ?? PluginLocalization(bundle: resourceBundle) + } + + // MARK: - Public API + + typealias WriteError = FanWriteError + + var isHelperAvailable: Bool { + bundledHelperURL != nil + } + + var isInstalledHelperAvailable: Bool { + guard let bundledHelperURL else { + return false + } + + return installedHelperPath(matching: bundledHelperURL) != nil + } + + /// Apply `strategy` to all fans described by `snapshot`. + /// - Returns: `nil` on success, or an error description on failure. + @discardableResult + func apply(strategy: FanControlStrategy, snapshot: FanSnapshot) -> FanWriteError? { + let resolvedPath: String + switch helperPath() { + case .success(let path): + resolvedPath = path + case .failure(let error): + FanControlLog.writer.error("smc-helper not found") + return error + } + + let fanCount = max(1, snapshot.fanCount) + + switch strategy { + case .auto: + var allOK = true + for i in 0.. String? { + if let cached = resolvedHelperPath, + isExecutable(at: cached), + installedHelperMatchesBundled(installedPath: cached, bundledURL: bundledHelperURL) { + return cached + } + + guard isExecutable(at: Helper.installPath), + installedHelperMatchesBundled(installedPath: Helper.installPath, bundledURL: bundledHelperURL) + else { + return nil + } + + resolvedHelperPath = Helper.installPath + return Helper.installPath + } + + private var bundledHelperURL: URL? { + resourceBundle.url( + forResource: Helper.bundledName, + withExtension: nil, + subdirectory: Helper.bundledSubdirectory + ) + } + + private func helperPath() -> Result { + guard let bundledHelperURL else { + return .failure(.helperNotFound) + } + + guard verifyBundleContainingBundledHelper(bundledHelperURL) else { + return .failure(.helperVerificationFailed) + } + + if let installedHelperPath = installedHelperPath(matching: bundledHelperURL) { + return .success(installedHelperPath) + } + + if let error = installBundledHelper(from: bundledHelperURL) { + return .failure(error) + } + + guard let installedHelperPath = installedHelperPath(matching: bundledHelperURL) else { + return .failure(.helperInstallFailed(localization.string( + "writer.error.helperMissingAfterInstall", + defaultValue: "安装后仍无法找到组件" + ))) + } + + return .success(installedHelperPath) + } + + private func isExecutable(at path: String) -> Bool { + fileManager.isExecutableFile(atPath: path) + } + + private func installedHelperMatchesBundled(installedPath: String, bundledURL: URL) -> Bool { + guard let installedAttributes = try? fileManager.attributesOfItem(atPath: installedPath), + let installedSize = installedAttributes[.size] as? NSNumber, + let installedModifiedAt = installedAttributes[.modificationDate] as? Date, + let bundledAttributes = try? fileManager.attributesOfItem(atPath: bundledURL.path), + let bundledSize = bundledAttributes[.size] as? NSNumber, + let bundledModifiedAt = bundledAttributes[.modificationDate] as? Date, + let installedData = try? Data(contentsOf: URL(fileURLWithPath: installedPath)), + let bundledData = try? Data(contentsOf: bundledURL) + else { + return false + } + + guard installedSize == bundledSize else { + return false + } + + return installedModifiedAt.timeIntervalSince(bundledModifiedAt) >= -1 + && installedData == bundledData + } + + private func verifyBundleContainingBundledHelper(_ helperURL: URL) -> Bool { + var currentURL = helperURL.deletingLastPathComponent() + + while currentURL.path != "/" { + if currentURL.pathExtension == "bundle" { + return verifyCodeSignature(at: currentURL) + } + currentURL.deleteLastPathComponent() + } + + return false + } + + private func verifyCodeSignature(at url: URL) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + task.arguments = ["--verify", "--strict", "--deep", url.path] + task.standardOutput = Pipe() + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + FanControlLog.writer.error("codesign verification failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + private func installBundledHelper(from sourceURL: URL) -> FanWriteError? { + guard isExecutable(at: sourceURL.path) else { + return .helperNotFound + } + + let command = [ + "/bin/mkdir -p \(shellQuoted(Helper.installDirectory))", + "/usr/bin/install -o root -g wheel -m 4755 \(shellQuoted(sourceURL.path)) \(shellQuoted(Helper.installPath))", + "/usr/bin/touch -r \(shellQuoted(sourceURL.path)) \(shellQuoted(Helper.installPath))", + "/bin/chmod 4755 \(shellQuoted(Helper.installPath))" + ].joined(separator: " && ") + + let script = "do shell script \"\(appleScriptEscaped(command))\" with administrator privileges" + var appleScriptError: NSDictionary? + guard let scriptObject = NSAppleScript(source: script) else { + return .helperInstallFailed(localization.string( + "writer.error.authorizationScriptFailed", + defaultValue: "无法创建授权脚本" + )) + } + + _ = scriptObject.executeAndReturnError(&appleScriptError) + if let appleScriptError { + let message = appleScriptError["NSAppleScriptErrorMessage"] as? String + ?? appleScriptError.description + FanControlLog.writer.error("SMC helper install failed: \(message, privacy: .public)") + return .helperInstallFailed(message) + } + + resolvedHelperPath = nil + return nil + } + + @discardableResult + private func runHelper(path: String, args: [String]) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: path) + task.arguments = args + task.environment = ["LANG": "C"] + let errorPipe = Pipe() + task.standardError = errorPipe + + do { + try task.run() + task.waitUntilExit() + if task.terminationStatus == 0 { + return true + } + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + FanControlLog.writer.error( + "smc-helper failed with status \(task.terminationStatus): \(errorMessage ?? "unknown", privacy: .public)" + ) + } catch { + FanControlLog.writer.error("smc-helper launch failed: \(error.localizedDescription, privacy: .public)") + } + + return false + } + + private func shellQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } + + private func appleScriptEscaped(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Plugins/FanControl/Tests/FanControlPluginTests.swift b/Plugins/FanControl/Tests/FanControlPluginTests.swift new file mode 100644 index 0000000..940d9c3 --- /dev/null +++ b/Plugins/FanControl/Tests/FanControlPluginTests.swift @@ -0,0 +1,286 @@ +import XCTest +import MacToolsPluginKit +@testable import FanControlPlugin + +@MainActor +final class FanControlPluginTests: XCTestCase { + func testMetadataAndInitialPanelState() { + let plugin = makePlugin() + + XCTAssertEqual(plugin.metadata.id, "fan-control") + XCTAssertEqual(plugin.metadata.title, "风扇控制") + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .disclosure) + XCTAssertFalse(plugin.primaryPanelState.isExpanded) + XCTAssertTrue(plugin.primaryPanelState.subtitle.contains("自动")) + } + + func testRefreshShowsFanSpeedInSubtitle() { + let plugin = makePlugin(reader: MockSMCReader(snapshot: FanSnapshot( + fanCount: 1, + fanSpeeds: [3600], + fanMinSpeeds: [1200], + fanMaxSpeeds: [5200], + cpuTemperature: 45 + ))) + + plugin.refresh() + + XCTAssertTrue(plugin.primaryPanelState.subtitle.contains("3600 RPM")) + } + + func testSelectingBuiltInPresetAppliesStrategy() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed)) + + XCTAssertEqual(writer.appliedStrategy, .fullSpeed) + } + + func testSliderEndedUpdatesCustomPresetRPM() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + let preset = plugin.presetStore.addCustomPreset() + plugin.presetStore.setActivePreset(id: preset.id) + + plugin.handleAction(.setSlider(controlID: "fan-custom-rpm", value: 4000, phase: .ended)) + + XCTAssertEqual(writer.appliedStrategy, .fixed(rpm: 4000)) + } + + func testWriteErrorAppearsAndCollapseClearsIt() { + let writer = MockSMCWriter() + writer.writeError = .writeFailed("硬件写入失败") + let plugin = makePlugin(writer: writer) + + plugin.handleAction(.setDisclosureExpanded(true)) + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed)) + XCTAssertNotNil(plugin.primaryPanelState.errorMessage) + + plugin.handleAction(.setDisclosureExpanded(false)) + XCTAssertNil(plugin.primaryPanelState.errorMessage) + } + + func testDisclosureNoOpDoesNotNotifyStateChange() { + let plugin = makePlugin() + var stateChangeCount = 0 + plugin.onStateChange = { + stateChangeCount += 1 + } + + plugin.handleAction(.setDisclosureExpanded(false)) + + XCTAssertEqual(stateChangeCount, 0) + } + + func testDeletingActiveCustomPresetResetsToAuto() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + let preset = plugin.presetStore.addCustomPreset() + plugin.presetStore.setActivePreset(id: preset.id) + + plugin.handleAction(.invokeAction(controlID: "fan-delete-preset")) + + XCTAssertEqual(writer.appliedStrategy, .auto) + } + + func testDeactivateWithoutSuccessfulManualPresetDoesNotRestoreAuto() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + + plugin.deactivate(reason: .hostShutdown) + + XCTAssertTrue(writer.appliedStrategies.isEmpty) + } + + func testDeactivateAfterSuccessfulManualPresetRestoresAuto() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed)) + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.appliedStrategies, [.fullSpeed, .auto]) + } + + func testDeactivateAfterHelperInstallFailureDoesNotRestoreAuto() { + let writer = MockSMCWriter() + writer.writeError = .helperInstallFailed("用户取消了授权") + let plugin = makePlugin(writer: writer) + + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed)) + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.appliedStrategies, [.fullSpeed]) + } + + func testDeactivateAfterManualPresetSkipsRestoreWhenInstalledHelperIsUnavailable() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed)) + writer.isInstalledHelperAvailable = false + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.appliedStrategies, [.fullSpeed]) + } + + func testSelectingAutoClearsDeactivateRestoreRequirement() { + let writer = MockSMCWriter() + let plugin = makePlugin(writer: writer) + + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed)) + plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.auto)) + plugin.deactivate(reason: .hostShutdown) + + XCTAssertEqual(writer.appliedStrategies, [.fullSpeed, .auto]) + } + + func testMonitoringOnlyPublishesMeaningfulSnapshotChanges() async throws { + let firstSnapshot = FanSnapshot( + fanCount: 1, + fanSpeeds: [3600], + fanMinSpeeds: [1200], + fanMaxSpeeds: [5200], + cpuTemperature: 45 + ) + let equivalentSnapshot = FanSnapshot( + fanCount: 1, + fanSpeeds: [3605], + fanMinSpeeds: [1200], + fanMaxSpeeds: [5200], + cpuTemperature: 45.2 + ) + let changedSnapshot = FanSnapshot( + fanCount: 1, + fanSpeeds: [3900], + fanMinSpeeds: [1200], + fanMaxSpeeds: [5200], + cpuTemperature: 49 + ) + let reader = MockSMCReader( + snapshot: changedSnapshot, + snapshots: [firstSnapshot, equivalentSnapshot, changedSnapshot] + ) + let plugin = makePlugin( + reader: reader, + monitoringActiveInterval: .milliseconds(10), + monitoringIdleInterval: .milliseconds(10) + ) + var stateChangeCount = 0 + plugin.onStateChange = { + stateChangeCount += 1 + } + + plugin.activate(context: PluginRuntimeContext(pluginID: "fan-control")) + try await Task.sleep(for: .milliseconds(45)) + plugin.deactivate(reason: .disabled) + + XCTAssertGreaterThanOrEqual(reader.readCount, 3) + XCTAssertEqual(stateChangeCount, 2) + } + + func testFeatureVisibilityAndDisclosureControlActiveMonitoring() async throws { + let snapshot = FanSnapshot( + fanCount: 1, + fanSpeeds: [3600], + fanMinSpeeds: [1200], + fanMaxSpeeds: [5200], + cpuTemperature: 45 + ) + let reader = MockSMCReader(snapshot: snapshot) + let plugin = makePlugin( + reader: reader, + monitoringActiveInterval: .milliseconds(10), + monitoringIdleInterval: .milliseconds(200) + ) + + plugin.activate(context: PluginRuntimeContext(pluginID: "fan-control")) + try await Task.sleep(for: .milliseconds(40)) + let idleReadCount = reader.readCount + + plugin.featureVisibilityDidChange(true) + plugin.handleAction(.setDisclosureExpanded(true)) + try await Task.sleep(for: .milliseconds(45)) + let activeReadCount = reader.readCount + + plugin.featureVisibilityDidChange(false) + try await Task.sleep(for: .milliseconds(45)) + let closedReadCount = reader.readCount + + plugin.deactivate(reason: .disabled) + + XCTAssertLessThanOrEqual(idleReadCount, 2) + XCTAssertGreaterThanOrEqual(activeReadCount - idleReadCount, 3) + XCTAssertLessThanOrEqual(closedReadCount - activeReadCount, 2) + } + + private func makePlugin( + reader: MockSMCReader? = nil, + writer: MockSMCWriter? = nil, + monitoringActiveInterval: Duration = .seconds(2), + monitoringIdleInterval: Duration = .seconds(10) + ) -> FanControlPlugin { + FanControlPlugin( + context: PluginRuntimeContext(pluginID: "fan-control", storage: FanControlMemoryStorage()), + smcReader: reader ?? MockSMCReader(), + smcWriter: writer ?? MockSMCWriter(), + monitoringActiveInterval: monitoringActiveInterval, + monitoringIdleInterval: monitoringIdleInterval + ) + } +} + +@MainActor +private final class MockSMCReader: FanControlSMCReading { + var snapshot: FanSnapshot + var snapshots: [FanSnapshot] + private(set) var readCount = 0 + + init(snapshot: FanSnapshot = .empty, snapshots: [FanSnapshot] = []) { + self.snapshot = snapshot + self.snapshots = snapshots + } + + func readSnapshot() -> FanSnapshot { + readCount += 1 + guard !snapshots.isEmpty else { + return snapshot + } + return snapshots.removeFirst() + } +} + +@MainActor +private final class MockSMCWriter: FanControlSMCWriting { + var isHelperAvailable = true + var isInstalledHelperAvailable = true + var appliedStrategy: FanControlStrategy? + var appliedStrategies: [FanControlStrategy] = [] + var writeError: FanWriteError? + + func apply(strategy: FanControlStrategy, snapshot _: FanSnapshot) -> FanWriteError? { + appliedStrategy = strategy + appliedStrategies.append(strategy) + return writeError + } +} + +@MainActor +private final class FanControlMemoryStorage: 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[legacyKey] else { return } + values[key] = value + values.removeValue(forKey: legacyKey) + } +} diff --git a/Plugins/FanControl/plugin.json b/Plugins/FanControl/plugin.json new file mode 100644 index 0000000..296fb77 --- /dev/null +++ b/Plugins/FanControl/plugin.json @@ -0,0 +1,72 @@ +{ + "id": "fan-control", + "displayName": "风扇控制", + "summary": "管理风扇转速预设,支持自动、全速和自定义策略", + "localizedMetadata": { + "ar": { + "displayName": "التحكم بالمراوح", + "summary": "قم بإدارة الإعدادات المسبقة لسرعة المروحة باستخدام إستراتيجيات تلقائية وكاملة السرعة ومخصصة." + }, + "de": { + "displayName": "Lüftersteuerung", + "summary": "Verwalten Sie Voreinstellungen für die Lüftergeschwindigkeit mit automatischen, Vollgeschwindigkeits- und benutzerdefinierten Strategien." + }, + "en": { + "displayName": "Fan Control", + "summary": "Manage fan speed presets with automatic, full-speed, and custom strategies." + }, + "es": { + "displayName": "Control de ventiladores", + "summary": "Administre los ajustes preestablecidos de velocidad del ventilador con estrategias automáticas, de velocidad máxima y personalizadas." + }, + "fr": { + "displayName": "Contrôle des ventilateurs", + "summary": "Gérez les préréglages de vitesse du ventilateur avec des stratégies automatiques, à pleine vitesse et personnalisées." + }, + "ja": { + "displayName": "ファン制御", + "summary": "自動、フルスピード、カスタム戦略を使用してファン速度のプリセットを管理します。" + }, + "ko": { + "displayName": "팬 제어", + "summary": "자동, 최대 속도 및 사용자 정의 전략을 사용하여 팬 속도 사전 설정을 관리합니다." + }, + "pt": { + "displayName": "Controle de ventoinhas", + "summary": "Gerencie predefinições de velocidade do ventilador com estratégias automáticas, de velocidade total e personalizadas." + }, + "ru": { + "displayName": "Управление вентиляторами", + "summary": "Управляйте предустановками скорости вращения вентилятора с помощью автоматических, полноскоростных и пользовательских стратегий." + }, + "zh-Hans": { + "displayName": "风扇控制", + "summary": "管理风扇转速预设,支持自动、全速和自定义策略" + }, + "zh-Hant": { + "displayName": "風扇控制", + "summary": "管理風扇轉速預設,支持自動、全速和自定義策略" + } + }, + "version": "1.0.11", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "FanControl.bundle", + "factoryClass": "FanControlPlugin.FanControlPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "FanControlPlugin" + }, + "package": { + "signPaths": [ + "FanControl.bundle/Contents/Resources/SMCHelper/mactools-fan-smc-helper" + ] + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "system" +} diff --git a/Plugins/FanControl/project.yml b/Plugins/FanControl/project.yml new file mode 100644 index 0000000..86cfe33 --- /dev/null +++ b/Plugins/FanControl/project.yml @@ -0,0 +1,22 @@ +settings: + base: + OTHER_LDFLAGS: -framework IOKit + +targets: + mactools-fan-smc-helper: + type: tool + platform: macOS + deploymentTarget: '14.0' + bundleResourcePath: SMCHelper + sources: + - path: SMCHelper/Sources + settings: + base: + PRODUCT_NAME: mactools-fan-smc-helper + PRODUCT_BUNDLE_IDENTIFIER: "$(BUNDLE_IDENTIFIER_PREFIX).mactools.plugins.fan-control.smc-helper" + GENERATE_INFOPLIST_FILE: true + SWIFT_VERSION: 6.0 + MACOSX_DEPLOYMENT_TARGET: '14.0' + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" + OTHER_LDFLAGS: -framework IOKit diff --git a/Plugins/FixDamagedApp/Bundle/FixDamagedAppPluginBundleEntrypoint.swift b/Plugins/FixDamagedApp/Bundle/FixDamagedAppPluginBundleEntrypoint.swift new file mode 100644 index 0000000..1d3ad42 --- /dev/null +++ b/Plugins/FixDamagedApp/Bundle/FixDamagedAppPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import FixDamagedAppPlugin + +private let fixDamagedAppPluginFactoryAnchor: Any.Type = FixDamagedAppPluginFactory.self diff --git a/Plugins/FixDamagedApp/Resources/Localizable.xcstrings b/Plugins/FixDamagedApp/Resources/Localizable.xcstrings new file mode 100644 index 0000000..ed7b780 --- /dev/null +++ b/Plugins/FixDamagedApp/Resources/Localizable.xcstrings @@ -0,0 +1,1355 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إصلاح التطبيقات التالفة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beschädigte Apps reparieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fix Damaged Apps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reparar aplicaciones dañadas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réparer les applications endommagées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "破損したアプリを修復する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "손상된 앱 수정" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corrigir aplicativos danificados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исправить поврежденные приложения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复损坏应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復損壞應用" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بإزالة سمات العزل لحل تحذيرات التطبيقات التالفة أو غير الموثوقة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen Sie Quarantäneattribute, um Warnungen zu beschädigten oder nicht vertrauenswürdigen Apps zu beheben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove quarantine attributes to resolve damaged or untrusted app warnings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elimine los atributos de cuarentena para resolver advertencias de aplicaciones dañadas o que no son de confianza." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimez les attributs de quarantaine pour résoudre les avertissements d'applications endommagées ou non fiables." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "破損したアプリまたは信頼できないアプリの警告を解決するには、隔離属性を削除します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "손상되었거나 신뢰할 수 없는 앱 경고를 해결하려면 격리 속성을 제거하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remova atributos de quarentena para resolver avisos de aplicativos danificados ou não confiáveis." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалите атрибуты карантина, чтобы устранить предупреждения поврежденных или ненадежных приложений." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除隔离属性,解决「已损坏」或「不受信任」提示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除隔離屬性,解決「已損壞」或「不受信任」提示" + } + } + } + }, + "panel.button.choose": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolher" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇" + } + } + } + }, + "panel.subtitle.chooseApp": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر ملف .app لإصلاحه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie eine .app-Datei zur Reparatur aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a .app file to repair" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija un archivo .app para reparar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez un fichier .app à réparer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修復する .app ファイルを選択してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복구할 .app 파일을 선택하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha um arquivo .app para reparar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите файл .app для восстановления" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择 .app 文件以修复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇 .app 檔案以修復" + } + } + } + }, + "panel.subtitle.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإصلاح..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reparatur…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fixing…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Arreglando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réparer…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修正中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수정 중…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corrigindo…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исправление…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复中…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復中…" + } + } + } + }, + "panel.subtitle.successFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الإصلاح: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Repariert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repaired: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reparado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réparé : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修理しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수리됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Reparado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отремонтировано: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已修复:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已修復:%@" + } + } + } + }, + "openPanel.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر التطبيق المطلوب إصلاحه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie die zu reparierende App aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose App to Repair" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija la aplicación para reparar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez l'application à réparer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修復するアプリを選択してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복구할 앱 선택" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha o aplicativo para reparar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите приложение для восстановления" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择要修复的应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇要修復的應用" + } + } + } + }, + "openPanel.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر تطبيقًا يعرض تحذيرًا \"تالفًا\" أو \"غير موثوق به\"." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie eine App aus, die eine „beschädigte“ oder „nicht vertrauenswürdige“ Warnung anzeigt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an app that shows a \"damaged\" or \"untrusted\" warning." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elija una aplicación que muestre una advertencia de \"dañada\" o \"no confiable\"." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez une application qui affiche un avertissement « endommagé » ou « non fiable »." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「破損しています」または「信頼できない」という警告が表示されるアプリを選択してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "'손상됨' 또는 '신뢰할 수 없음' 경고를 표시하는 앱을 선택하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha um aplicativo que mostre um aviso de \"danificado\" ou \"não confiável\"." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите приложение, которое показывает предупреждение «повреждено» или «ненадежно»." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择显示「已损坏」或「不受信任」的应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇顯示「已損壞」或「不受信任」的應用" + } + } + } + }, + "settings.section.behavior": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السلوك" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Behavior" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comportamiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Comportement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "행동" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Comportamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поведение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "行为" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "行為" + } + } + } + }, + "settings.dragDetection.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتشاف السحب" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Drag-Erkennung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Drag Detection" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detección de arrastre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détection de glissement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドラッグ検出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "드래그 감지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Detecção de arrasto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обнаружение перетаскивания" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拖动检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拖動檢測" + } + } + } + }, + "settings.dragDetection.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أسقط ملف .app لإظهار نافذة الإصلاح والبدء تلقائيًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Legen Sie eine .app-Datei ab, um das Reparaturfenster anzuzeigen und automatisch zu starten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Drop a .app file to show the repair window and start automatically." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Suelte un archivo .app para mostrar la ventana de reparación y comenzar automáticamente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déposez un fichier .app pour afficher la fenêtre de réparation et démarrer automatiquement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": ".app ファイルをドロップすると修復ウィンドウが表示され、自動的に開始されます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복구 창을 표시하고 자동으로 시작하려면 .app 파일을 삭제하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Solte um arquivo .app para mostrar a janela de reparo e iniciar automaticamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перетащите файл .app, чтобы открыть окно восстановления и запустить его автоматически." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拖入 .app 文件后松手,自动弹出修复窗口并开始修复。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拖入 .app 檔案後鬆手,自動彈出修復窗口並開始修復。" + } + } + } + }, + "dropZone.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أسقط ملف .app هنا لإصلاحه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Legen Sie hier eine .app-Datei ab, um sie zu reparieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Drop a .app file here to repair it" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Suelte un archivo .app aquí para repararlo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déposez un fichier .app ici pour le réparer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": ".app ファイルをここにドロップして修復してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복구하려면 여기에 .app 파일을 드롭하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Solte um arquivo .app aqui para repará-lo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перетащите сюда файл .app, чтобы восстановить его" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将 .app 文件拖到此处以修复" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將 .app 檔案拖到此處以修復" + } + } + } + }, + "dropZone.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإصلاح..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reparatur…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fixing…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Arreglando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réparer…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修正中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수정 중…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Corrigindo…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исправляем…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复中…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復中…" + } + } + } + }, + "dropZone.successFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الإصلاح: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Repariert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repaired: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reparado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réparé : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修理済み: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수리됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Reparado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отремонтировано: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已修复:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已修復:%@" + } + } + } + }, + "dropZone.close": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إغلاق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schließen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cerrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fermer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "閉じる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "닫기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fechar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрыть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關閉" + } + } + } + }, + "error.fixFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الإصلاح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reparatur fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error en la reparación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la réparation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修復に失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수리 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha no reparo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление не удалось" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復失敗" + } + } + } + }, + "error.unsupportedPathCharacters": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتوي مسار التطبيق على أحرف غير مدعومة (علامتي الاقتباس)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der App-Pfad enthält nicht unterstützte Zeichen (doppelte Anführungszeichen)." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The app path contains unsupported characters (double quotes)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La ruta de la aplicación contiene caracteres no admitidos (comillas dobles)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le chemin de l'application contient des caractères non pris en charge (guillemets doubles)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリのパスにサポートされていない文字 (二重引用符) が含まれています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 경로에 지원되지 않는 문자(큰따옴표)가 포함되어 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O caminho do aplicativo contém caracteres não suportados (aspas duplas)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Путь к приложению содержит неподдерживаемые символы (двойные кавычки)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用路径包含不支持的字符(双引号)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用路徑包含不支持的字符(雙引號)" + } + } + } + }, + "error.userCancelledAuthorization": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إلغاء المصادقة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Authentifizierung wurde abgebrochen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Authentication was canceled." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La autenticación fue cancelada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'authentification a été annulée." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "認証がキャンセルされました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "인증이 취소되었습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A autenticação foi cancelada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Аутентификация отменена." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户取消了授权" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用戶取消了授權" + } + } + } + }, + "error.fixFailedUnknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الإصلاح بسبب خطأ غير معروف." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Reparatur ist mit einem unbekannten Fehler fehlgeschlagen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repair failed with an unknown error." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La reparación falló con un error desconocido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La réparation a échoué avec une erreur inconnue." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なエラーにより修復に失敗しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 오류로 인해 복구에 실패했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O reparo falhou com um erro desconhecido." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановление не удалось из-за неизвестной ошибки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "修复失败(未知错误)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "修復失敗(未知錯誤)" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/FixDamagedApp/Sources/FixDamagedAppDropZone.swift b/Plugins/FixDamagedApp/Sources/FixDamagedAppDropZone.swift new file mode 100644 index 0000000..8f05da0 --- /dev/null +++ b/Plugins/FixDamagedApp/Sources/FixDamagedAppDropZone.swift @@ -0,0 +1,257 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import MacToolsPluginKit + +// MARK: - View Model + +@MainActor +final class DropZoneViewModel: ObservableObject { + + enum Phase: Equatable { + case waiting + case running + case success(appName: String) + case failure(message: String) + } + + @Published private(set) var phase: Phase = .waiting + + /// True after a drop is accepted but before the async URL load finishes. + /// Prevents `dismissIfIdle` from closing the panel too early. + private var isDropPending = false + + private let localization: PluginLocalization + private let onComplete: (String, Bool, String?) -> Void + private let onDismiss: () -> Void + + init( + localization: PluginLocalization = PluginLocalization(bundle: .main), + onComplete: @escaping (String, Bool, String?) -> Void, + onDismiss: @escaping () -> Void + ) { + self.localization = localization + self.onComplete = onComplete + self.onDismiss = onDismiss + } + + func beginDrop() { + isDropPending = true + } + + func cancelDropPending() { + isDropPending = false + } + + func dropApp(url: URL) { + isDropPending = false + guard phase == .waiting else { return } + let appName = url.deletingPathExtension().lastPathComponent + let appPath = url.path + phase = .running + Task { + do { + let localization = localization + try await Task.detached(priority: .userInitiated) { + try runQuarantineRemoval(appPath: appPath, localization: localization) + }.value + phase = .success(appName: appName) + onComplete(appName, true, nil) + try? await Task.sleep(for: .seconds(1.5)) + onDismiss() + } catch { + let msg = error.localizedDescription + phase = .failure(message: msg) + onComplete(appName, false, msg) + } + } + } + + func dismissIfIdle() { + guard phase == .waiting, !isDropPending else { return } + onDismiss() + } + + func dismiss() { + onDismiss() + } +} + +// MARK: - Drop Zone View + +struct FixDropZoneView: View { + @ObservedObject var viewModel: DropZoneViewModel + let localization: PluginLocalization + @State private var isTargeted = false + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder( + isTargeted ? Color.accentColor : Color.primary.opacity(0.1), + lineWidth: isTargeted ? 2 : 1 + ) + + content + .padding(20) + } + .frame(width: 280, height: 160) + .background(Color.clear) + .onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in + handleDrop(providers: providers) + } + .animation(.easeInOut(duration: 0.15), value: isTargeted) + } + + @ViewBuilder + private var content: some View { + switch viewModel.phase { + case .waiting: + VStack(spacing: 10) { + Image(systemName: "arrow.up.circle.dotted") + .font(.system(size: 34, weight: .light)) + .foregroundStyle(isTargeted ? Color.accentColor : .secondary) + .scaleEffect(isTargeted ? 1.15 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.6), value: isTargeted) + + Text(localization.string("dropZone.waiting", defaultValue: "将 .app 文件拖到此处以修复")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + case .running: + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + + Text(localization.string("dropZone.running", defaultValue: "修复中…")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } + + case .success(let name): + VStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(.green) + + Text(localization.format("dropZone.successFormat", defaultValue: "已修复:%@", name)) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + } + + case .failure(let message): + VStack(spacing: 10) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 34)) + .foregroundStyle(.red) + + Text(message) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(3) + + Button(localization.string("dropZone.close", defaultValue: "关闭")) { + viewModel.dismiss() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + // Mark pending before the async URL load finishes so the mouseUp monitor does not + // misclassify the panel as idle and close it. SwiftUI invokes this drop callback + // on the main thread, so `assumeIsolated` is safe here. + MainActor.assumeIsolated { viewModel.beginDrop() } + _ = provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in + guard + let data, + let url = URL(dataRepresentation: data, relativeTo: nil), + url.pathExtension.lowercased() == "app" + else { + Task { @MainActor in viewModel.cancelDropPending() } + return + } + Task { @MainActor in + viewModel.dropApp(url: url) + } + } + return true + } +} + +// MARK: - Panel + +@MainActor +final class FixDamagedAppDropZonePanel: NSPanel { + + private let viewModel: DropZoneViewModel + private let localization: PluginLocalization + + init( + viewModel: DropZoneViewModel, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.viewModel = viewModel + self.localization = localization + let size = NSSize(width: 280, height: 160) + super.init( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + level = .floating + backgroundColor = .clear + isOpaque = false + hasShadow = true + isMovableByWindowBackground = true + + // NSVisualEffectView is the correct host for the glass effect. `.behindWindow` plus a + // rounded `maskImage` is more reliable than `layer.cornerRadius` for this borderless panel. + let effectView = NSVisualEffectView(frame: NSRect(origin: .zero, size: size)) + effectView.material = .popover + effectView.blendingMode = .behindWindow + effectView.state = .active + effectView.maskImage = Self.makeRoundedMaskImage(size: size, cornerRadius: 20) + + // The SwiftUI layer draws only the border and content over the transparent effect view. + let hostingView = NSHostingView(rootView: FixDropZoneView( + viewModel: viewModel, + localization: localization + )) + hostingView.frame = effectView.bounds + hostingView.autoresizingMask = [.width, .height] + effectView.addSubview(hostingView) + + contentView = effectView + setContentSize(size) + } + + func dismissIfIdle() { + viewModel.dismissIfIdle() + } + + /// Builds an `NSVisualEffectView.maskImage`: a black rounded rectangle whose alpha controls visibility. + private static func makeRoundedMaskImage(size: NSSize, cornerRadius: CGFloat) -> NSImage { + let image = NSImage(size: size, flipped: false) { rect in + NSColor.black.setFill() + NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius).fill() + return true + } + image.capInsets = NSEdgeInsets( + top: cornerRadius, left: cornerRadius, + bottom: cornerRadius, right: cornerRadius + ) + image.resizingMode = .stretch + return image + } +} diff --git a/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift b/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift new file mode 100644 index 0000000..8e31fed --- /dev/null +++ b/Plugins/FixDamagedApp/Sources/FixDamagedAppPlugin.swift @@ -0,0 +1,444 @@ +import AppKit +import Foundation +import OSLog +import SwiftUI +import UniformTypeIdentifiers +import MacToolsPluginKit + +// MARK: - Factory + +public final class FixDamagedAppPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + FixDamagedAppPluginProvider(context: context) + } +} + +@MainActor +private struct FixDamagedAppPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [FixDamagedAppPlugin(context: context)] + } +} + +// MARK: - Plugin + +@MainActor +final class FixDamagedAppPlugin: MacToolsPlugin, PluginPrimaryPanel, DropZoneAnchorProviding { + + // MARK: Metadata + + let metadata: PluginMetadata + + // MARK: State + + private enum FixState: Equatable { + case idle + case running + case success(appName: String) + case failure(message: String) + } + + private var selectedApp: URL? + private var fixState: FixState = .idle + + // MARK: Drag Detection State + + private let storage: PluginStorage + private var mouseDownMonitor: Any? + private var dragMonitor: Any? + private var mouseUpMonitor: Any? + private var dropZonePanel: FixDamagedAppDropZonePanel? + private var isDragPanelShowing = false + private var isMouseButtonDown = false + /// Drag pasteboard change count captured at mouseDown to identify a new drag session. + private var dragSessionPasteboardChangeCount: Int = Int.min + private let localization: PluginLocalization + + // MARK: DropZoneAnchorProviding + + var anchorRectProvider: (() -> NSRect?)? + + var isDragDetectionEnabled: Bool { + storage.bool(forKey: StorageKey.isDragDetectionEnabled) + } + + // MARK: Storage Keys + + private enum StorageKey { + static let isDragDetectionEnabled = "drag-detection-enabled" + } + + // MARK: MacToolsPlugin + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", + category: "FixDamagedAppPlugin" + ) + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + // MARK: Init + + init(context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "fix-damaged-app")) { + let localization = PluginLocalization(bundle: context.resourceBundle) + self.localization = localization + self.storage = context.storage + self.metadata = PluginMetadata( + id: "fix-damaged-app", + title: localization.string("metadata.title", defaultValue: "修复损坏应用"), + iconName: "wrench.and.screwdriver.fill", + iconTint: Color(nsColor: .systemOrange), + order: 94, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "移除隔离属性,解决「已损坏」或「不受信任」提示" + ) + ) + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .dismissBeforeHandling, + buttonTitleProvider: { localization.string("panel.button.choose", defaultValue: "选择") } + ) + } + + // MARK: Lifecycle + + func activate(context: PluginRuntimeContext) { + updateDragMonitoring() + } + + func deactivate(reason: PluginDeactivationReason) { + stopDragMonitoring() + hideDropZonePanel() + } + + func refresh() {} + + // MARK: Configuration + + var configuration: PluginConfiguration? { + PluginConfiguration(description: metadata.defaultDescription) { [weak self] _ in + guard let self else { return AnyView(EmptyView()) } + return AnyView( + FixDamagedAppSettingsView( + isDragDetectionEnabled: self.isDragDetectionEnabled, + localization: self.localization, + onToggle: { [weak self] isOn in + self?.setDragDetectionEnabled(isOn) + } + ) + ) + } + } + + func setDragDetectionEnabled(_ enabled: Bool) { + storage.set(enabled, forKey: StorageKey.isDragDetectionEnabled) + updateDragMonitoring() + onStateChange?() + } + + // MARK: PluginPrimaryPanel + + let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: primarySubtitle, + isOn: false, + isExpanded: false, + isEnabled: fixState != .running, + isVisible: true, + detail: nil, + errorMessage: primaryError + ) + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case .invokeAction(let controlID): + handleControlAction(controlID: controlID) + default: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + // MARK: Private + + private func handleControlAction(controlID: String) { + switch controlID { + case "execute": + chooseApp() + default: + break + } + } + + private var primarySubtitle: String { + switch fixState { + case .idle: + return selectedApp.map { $0.deletingPathExtension().lastPathComponent } + ?? localization.string("panel.subtitle.chooseApp", defaultValue: "选择 .app 文件以修复") + case .running: + return localization.string("panel.subtitle.running", defaultValue: "修复中…") + case .success(let name): + return localization.format("panel.subtitle.successFormat", defaultValue: "已修复:%@", name) + case .failure: + return selectedApp.map { $0.deletingPathExtension().lastPathComponent } + ?? localization.string("panel.subtitle.chooseApp", defaultValue: "选择 .app 文件以修复") + } + } + + private var primaryError: String? { + guard case .failure(let message) = fixState else { return nil } + return message + } + + private func chooseApp() { + let panel = NSOpenPanel() + panel.title = localization.string("openPanel.title", defaultValue: "选择要修复的应用") + panel.message = localization.string("openPanel.message", defaultValue: "选择显示「已损坏」或「不受信任」的应用") + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + if let appBundleType = UTType("com.apple.application-bundle") { + panel.allowedContentTypes = [appBundleType] + } + panel.directoryURL = URL(fileURLWithPath: "/Applications") + let response = panel.runModal() + guard response == .OK, let url = panel.url else { return } + selectedApp = url + fixState = .idle + onStateChange?() + performFix() + } + + private func performFix() { + guard let appURL = selectedApp, fixState != .running else { return } + let appPath = appURL.path + let appName = appURL.deletingPathExtension().lastPathComponent + fixState = .running + onStateChange?() + Task { + do { + try await self.removeQuarantine(appPath: appPath) + await MainActor.run { + self.fixState = .success(appName: appName) + self.onStateChange?() + } + } catch { + let message = error.localizedDescription + self.logger.error("Quarantine removal failed for '\(appPath)': \(message)") + await MainActor.run { + self.fixState = .failure(message: message) + self.onStateChange?() + } + } + } + } + + private func removeQuarantine(appPath: String) async throws { + let localization = localization + try await Task.detached(priority: .userInitiated) { + try runQuarantineRemoval(appPath: appPath, localization: localization) + }.value + } + + // MARK: Private - Drag Monitoring + + private func updateDragMonitoring() { + stopDragMonitoring() + guard isDragDetectionEnabled else { return } + startDragMonitoring() + } + + private func startDragMonitoring() { + // Global NSEvent monitors fire on the main thread. Run synchronously with + // `MainActor.assumeIsolated` so multiple events cannot pass the `isDragPanelShowing` + // check while queued through async Tasks. + mouseDownMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] _ in + MainActor.assumeIsolated { + self?.isMouseButtonDown = true + // Capture the current drag pasteboard version; only a later changeCount indicates + // that a real drag started. + self?.dragSessionPasteboardChangeCount = NSPasteboard(name: .drag).changeCount + } + } + dragMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDragged]) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleGlobalDrag() + } + } + mouseUpMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] _ in + MainActor.assumeIsolated { + self?.isMouseButtonDown = false + self?.handleGlobalMouseUp() + } + } + } + + private func stopDragMonitoring() { + if let monitor = mouseDownMonitor { + NSEvent.removeMonitor(monitor) + mouseDownMonitor = nil + } + if let monitor = dragMonitor { + NSEvent.removeMonitor(monitor) + dragMonitor = nil + } + if let monitor = mouseUpMonitor { + NSEvent.removeMonitor(monitor) + mouseUpMonitor = nil + } + isMouseButtonDown = false + } + + private func handleGlobalDrag() { + guard !isDragPanelShowing, isMouseButtonDown else { return } + let pb = NSPasteboard(name: .drag) + // React only after the drag pasteboard changes during this mouse-down cycle. This prevents + // stale drag pasteboard data from showing the panel on normal clicks, such as opening Finder. + guard pb.changeCount != dragSessionPasteboardChangeCount else { return } + guard + let urls = pb.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL], + urls.contains(where: { $0.pathExtension.lowercased() == "app" }) + else { return } + showDropZonePanel() + } + + private func handleGlobalMouseUp() { + guard isDragPanelShowing else { return } + // If the pointer is inside the panel, the file was dropped into the window and the drop + // pipeline owns dismissal. The global mouseUp arrives before SwiftUI `onDrop`, so it cannot + // rely on `isDropPending` having been set yet. + if let panel = dropZonePanel, panel.frame.contains(NSEvent.mouseLocation) { return } + dropZonePanel?.dismissIfIdle() + } + + private func showDropZonePanel() { + isDragPanelShowing = true + let vm = DropZoneViewModel( + localization: localization, + onComplete: { [weak self] appName, succeeded, errorMessage in + guard let self else { return } + if succeeded { + self.fixState = .success(appName: appName) + } else { + self.fixState = .failure( + message: errorMessage + ?? self.localization.string("error.fixFailed", defaultValue: "修复失败") + ) + } + self.onStateChange?() + }, + onDismiss: { [weak self] in + self?.hideDropZonePanel() + } + ) + let panel = FixDamagedAppDropZonePanel(viewModel: vm, localization: localization) + positionDropZonePanel(panel) + panel.makeKeyAndOrderFront(nil) + dropZonePanel = panel + } + + private func hideDropZonePanel() { + dropZonePanel?.orderOut(nil) + dropZonePanel = nil + isDragPanelShowing = false + } + + private func positionDropZonePanel(_ panel: NSPanel) { + let panelSize = panel.frame.size + + if let anchorRect = anchorRectProvider?() { + let screenMaxX = NSScreen.main?.frame.maxX ?? 1440 + let rawX = anchorRect.midX - panelSize.width / 2 + let x = max(8, min(rawX, screenMaxX - panelSize.width - 8)) + let y = anchorRect.minY - panelSize.height - 4 + panel.setFrameOrigin(NSPoint(x: x, y: y)) + return + } + + guard let screen = NSScreen.main else { return } + let menuBarThickness = NSStatusBar.system.thickness + let x = screen.frame.midX - panelSize.width / 2 + let y = screen.frame.maxY - menuBarThickness - panelSize.height - 12 + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} + +// MARK: - Quarantine Removal (nonisolated helper) + +func runQuarantineRemoval(appPath: String) throws { + try runQuarantineRemoval(appPath: appPath, localization: PluginLocalization(bundle: .main)) +} + +func runQuarantineRemoval(appPath: String, localization: PluginLocalization) throws { + // Reject paths containing double-quote to prevent AppleScript string literal injection + guard !appPath.contains("\"") else { + throw NSError( + domain: "FixDamagedAppPlugin", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: localization.string( + "error.unsupportedPathCharacters", + defaultValue: "应用路径包含不支持的字符(双引号)" + ) + ] + ) + } + // Use AppleScript's `quoted form of` to safely quote the path in the shell command + let script = """ + set appPath to "\(appPath)" + do shell script "xattr -r -d com.apple.quarantine " & quoted form of appPath with administrator privileges + """ + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + let errMsg = String(data: errData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if errMsg.contains("-128") || errMsg.contains("User canceled") { + throw NSError( + domain: "FixDamagedAppPlugin", + code: -128, + userInfo: [ + NSLocalizedDescriptionKey: localization.string( + "error.userCancelledAuthorization", + defaultValue: "用户取消了授权" + ) + ] + ) + } + throw NSError( + domain: "FixDamagedAppPlugin", + code: Int(process.terminationStatus), + userInfo: [ + NSLocalizedDescriptionKey: errMsg.isEmpty + ? localization.string("error.fixFailedUnknown", defaultValue: "修复失败(未知错误)") + : errMsg + ] + ) + } +} diff --git a/Plugins/FixDamagedApp/Sources/FixDamagedAppSettingsView.swift b/Plugins/FixDamagedApp/Sources/FixDamagedAppSettingsView.swift new file mode 100644 index 0000000..76b500c --- /dev/null +++ b/Plugins/FixDamagedApp/Sources/FixDamagedAppSettingsView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import MacToolsPluginKit + +struct FixDamagedAppSettingsView: View { + let onToggle: (Bool) -> Void + let localization: PluginLocalization + + @State private var isEnabled: Bool + + init( + isDragDetectionEnabled: Bool, + localization: PluginLocalization = PluginLocalization(bundle: .main), + onToggle: @escaping (Bool) -> Void + ) { + self.onToggle = onToggle + self.localization = localization + self._isEnabled = State(initialValue: isDragDetectionEnabled) + } + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + Label(localization.string("settings.section.behavior", defaultValue: "行为"), systemImage: "hand.rays") + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + + HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "hand.draw") + .pluginSettingsRowIconStyle(.secondary) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("settings.dragDetection.title", defaultValue: "拖动检测")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + + Text(localization.string( + "settings.dragDetection.description", + defaultValue: "拖入 .app 文件后松手,自动弹出修复窗口并开始修复。" + )) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + Toggle("", isOn: $isEnabled) + .toggleStyle(.switch) + .labelsHidden() + .onChange(of: isEnabled) { _, newValue in + onToggle(newValue) + } + } + .padding(PluginSettingsTheme.Spacing.cardContent) + .frame(maxWidth: .infinity, alignment: .leading) + .pluginSettingsCardBackground(.host) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } +} diff --git a/Plugins/FixDamagedApp/Tests/FixDamagedAppPluginTests.swift b/Plugins/FixDamagedApp/Tests/FixDamagedAppPluginTests.swift new file mode 100644 index 0000000..bddc681 --- /dev/null +++ b/Plugins/FixDamagedApp/Tests/FixDamagedAppPluginTests.swift @@ -0,0 +1,93 @@ +import XCTest +@testable import MacTools +@testable import FixDamagedAppPlugin + +@MainActor +final class FixDamagedAppPluginTests: XCTestCase { + + // MARK: - Metadata + + func testMetadataID() { + let plugin = FixDamagedAppPlugin() + + XCTAssertEqual(plugin.metadata.id, "fix-damaged-app") + } + + func testMetadataTitle() { + let plugin = FixDamagedAppPlugin() + + XCTAssertEqual(plugin.metadata.title, "修复损坏应用") + } + + func testMetadataDefaultDescription() { + let plugin = FixDamagedAppPlugin() + + XCTAssertEqual(plugin.metadata.defaultDescription, "移除隔离属性,解决「已损坏」或「不受信任」提示") + } + + // MARK: - Primary Panel Descriptor + + func testControlStyleIsButton() { + let plugin = FixDamagedAppPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button) + XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "选择") + } + + func testMenuActionBehaviorIsDismissBeforeHandling() { + let plugin = FixDamagedAppPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .dismissBeforeHandling) + } + + // MARK: - Initial Primary Panel State + + func testInitialPrimaryStateIsEnabledAndNotOn() { + let plugin = FixDamagedAppPlugin() + + let state = plugin.primaryPanelState + XCTAssertTrue(state.isEnabled) + XCTAssertFalse(state.isOn) + } + + func testInitialPrimarySubtitlePromptsSelection() { + let plugin = FixDamagedAppPlugin() + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "选择 .app 文件以修复") + } + + func testInitialPrimaryStateHasNoError() { + let plugin = FixDamagedAppPlugin() + + XCTAssertNil(plugin.primaryPanelState.errorMessage) + } + + // MARK: - Permission & Settings + + func testPermissionRequirementsIsEmpty() { + let plugin = FixDamagedAppPlugin() + + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } + + func testSettingsSectionsIsEmpty() { + let plugin = FixDamagedAppPlugin() + + XCTAssertTrue(plugin.settingsSections.isEmpty) + } + + func testPermissionStateAlwaysGranted() { + let plugin = FixDamagedAppPlugin() + + let state = plugin.permissionState(for: "any-permission-id") + XCTAssertTrue(state.isGranted) + } + + // MARK: - Plugin Host Integration + + func testPluginHostIncludesFixDamagedApp() { + let host = makePluginHostForTests(plugins: [FixDamagedAppPlugin()]) + + XCTAssertTrue(host.featureManagementItems.contains { $0.id == "fix-damaged-app" }) + } +} diff --git a/Plugins/FixDamagedApp/plugin.json b/Plugins/FixDamagedApp/plugin.json new file mode 100644 index 0000000..e8decf8 --- /dev/null +++ b/Plugins/FixDamagedApp/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "fix-damaged-app", + "displayName": "修复损坏应用", + "summary": "移除隔离属性,解决「已损坏」或「不受信任」提示", + "localizedMetadata": { + "ar": { + "displayName": "إصلاح التطبيقات التالفة", + "summary": "قم بإزالة سمات العزل لحل تحذيرات التطبيقات التالفة أو غير الموثوقة." + }, + "de": { + "displayName": "Beschädigte Apps reparieren", + "summary": "Entfernen Sie Quarantäneattribute, um Warnungen zu beschädigten oder nicht vertrauenswürdigen Apps zu beheben." + }, + "en": { + "displayName": "Fix Damaged Apps", + "summary": "Remove quarantine attributes to resolve damaged or untrusted app warnings." + }, + "es": { + "displayName": "Reparar apps dañadas", + "summary": "Elimine los atributos de cuarentena para resolver advertencias de aplicaciones dañadas o que no son de confianza." + }, + "fr": { + "displayName": "Réparer les apps endommagées", + "summary": "Supprimez les attributs de quarantaine pour résoudre les avertissements d'applications endommagées ou non fiables." + }, + "ja": { + "displayName": "破損したアプリを修復", + "summary": "破損したアプリまたは信頼できないアプリの警告を解決するには、隔離属性を削除します。" + }, + "ko": { + "displayName": "손상된 앱 복구", + "summary": "손상되었거나 신뢰할 수 없는 앱 경고를 해결하려면 격리 속성을 제거하세요." + }, + "pt": { + "displayName": "Corrigir apps danificados", + "summary": "Remova atributos de quarentena para resolver avisos de aplicativos danificados ou não confiáveis." + }, + "ru": { + "displayName": "Исправить повреждённые приложения", + "summary": "Удалите атрибуты карантина, чтобы устранить предупреждения поврежденных или ненадежных приложений." + }, + "zh-Hans": { + "displayName": "修复损坏应用", + "summary": "移除隔离属性,解决「已损坏」或「不受信任」提示" + }, + "zh-Hant": { + "displayName": "修復損壞的 App", + "summary": "移除隔離屬性,解決「已損壞」或「不受信任」提示" + } + }, + "version": "1.0.8", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "FixDamagedApp.bundle", + "factoryClass": "FixDamagedAppPlugin.FixDamagedAppPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "FixDamagedAppPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": true, + "configuration": true + }, + "permissions": [], + "category": "productivity" +} diff --git a/Plugins/HideNotch/Bundle/HideNotchPluginBundleEntrypoint.swift b/Plugins/HideNotch/Bundle/HideNotchPluginBundleEntrypoint.swift new file mode 100644 index 0000000..9dd9616 --- /dev/null +++ b/Plugins/HideNotch/Bundle/HideNotchPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import HideNotchPlugin + +private let hideNotchPluginFactoryAnchor: Any.Type = HideNotchPluginFactory.self diff --git a/Plugins/HideNotch/Resources/Localizable.xcstrings b/Plugins/HideNotch/Resources/Localizable.xcstrings new file mode 100644 index 0000000..52468f1 --- /dev/null +++ b/Plugins/HideNotch/Resources/Localizable.xcstrings @@ -0,0 +1,503 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء النوتش" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kerbe ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Notch" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar muesca" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer l'encoche" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノッチを隠す" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노치 숨기기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ocultar entalhe" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть вырез" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏刘海" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏劉海" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء المنطقة العلوية المحيطة بحز الشاشة تلقائيًا." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maskieren Sie automatisch den oberen Bereich um eine Anzeigekerbe." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically mask the top area around a display notch." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enmascare automáticamente el área superior alrededor de una muesca de la pantalla." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer automatiquement la zone supérieure autour d'une encoche d'affichage." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ ノッチの周囲の上部領域を自動的にマスクします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 노치 주변의 상단 영역을 자동으로 마스킹합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mascarar automaticamente a área superior ao redor de um entalhe da tela." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически маскировать верхнюю область вокруг выреза дисплея." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动遮挡刘海屏顶部区域" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動遮擋劉海螢幕頂部區域" + } + } + } + }, + "panel.subtitle.enabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "On" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "켜기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已开启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已開啟" + } + } + } + }, + "panel.subtitle.noSupportedDisplay": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف شاشة مسننة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine gekerbte Anzeige erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No notched display detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectó ninguna pantalla con muescas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun affichage cranté détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノッチ付きディスプレイが検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노치 디스플레이가 감지되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma tela com entalhe detectada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дисплей с вырезом не обнаружен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到刘海屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到劉海螢幕" + } + } + } + }, + "panel.subtitle.closing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausschalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Turning off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Apagar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフにします" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄는 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desligando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выключение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在關閉" + } + } + } + }, + "error.maskWindowCreationFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على إنشاء طبقة قناع الشق." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Kerbmaskenebene konnte nicht erstellt werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to create the notch mask layer." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede crear la capa de máscara de muesca." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de créer le calque de masque d'entaille." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ノッチ マスク レイヤを作成できません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "노치 마스크 레이어를 생성할 수 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível criar a camada de máscara de entalhe." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно создать слой маски выреза." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法创建隐藏刘海遮挡层。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法創建隱藏劉海遮擋層。" + } + } + } + }, + "error.maskWindowCreationFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على إنشاء طبقة قناع الشق لـ %@: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kerbmaskenebene für %@: %@ konnte nicht erstellt werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to create the notch mask layer for %@: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede crear la capa de máscara de muesca para %@: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de créer le calque de masque d'encoche pour %@ : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のノッチ マスク レイヤを作成できません: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 대한 노치 마스크 레이어를 생성할 수 없습니다: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível criar a camada de máscara de entalhe para %@: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно создать слой маски выреза для %@: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法为 %@ 创建刘海遮挡层:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法為 %@ 創建劉海遮擋層:%@" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/HideNotch/Sources/HideNotchController.swift b/Plugins/HideNotch/Sources/HideNotchController.swift new file mode 100644 index 0000000..2976429 --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchController.swift @@ -0,0 +1,203 @@ +import AppKit +import Foundation +import MacToolsPluginKit + +@MainActor +final class HideNotchController: HideNotchWallpaperControlling { + private struct NotificationObservation: @unchecked Sendable { + let center: NotificationCenter + let token: NSObjectProtocol + } + + var onStateChange: (() -> Void)? + + private let displayCatalog: HideNotchDisplayCatalogProviding + private let maskManager: HideNotchDesktopMaskManaging + private let stateStore: HideNotchStateStoring + private let notificationCenter: NotificationCenter + private let workspaceNotificationCenter: NotificationCenter + private let logger = HideNotchLog.controller + + private var isProcessing = false + private var pendingRefresh = false + private var errorMessage: String? + private var observationTokens: [NotificationObservation] = [] + private var debouncedRefreshTask: Task? + + init( + displayCatalog: HideNotchDisplayCatalogProviding = SystemHideNotchDisplayCatalog(), + maskManager: HideNotchDesktopMaskManaging = HideNotchDesktopMaskManager(), + stateStore: HideNotchStateStoring? = nil, + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "hide-notch"), + notificationCenter: NotificationCenter = .default, + workspaceNotificationCenter: NotificationCenter = NSWorkspace.shared.notificationCenter + ) { + self.displayCatalog = displayCatalog + self.maskManager = maskManager + self.stateStore = stateStore ?? HideNotchStateStore(context: context) + self.notificationCenter = notificationCenter + self.workspaceNotificationCenter = workspaceNotificationCenter + + installObservers() + } + + deinit { + debouncedRefreshTask?.cancel() + + for observation in observationTokens { + observation.center.removeObserver(observation.token) + } + } + + func snapshot() -> HideNotchSnapshot { + let supportedRecords = displayCatalog.listDisplayRecords().filter { $0.context.isSupported } + let managedDisplayCount: Int + + if stateStore.desiredEnabled { + managedDisplayCount = supportedRecords.reduce(into: 0) { count, record in + if maskManager.managedDisplayIdentifiers.contains(record.context.displayIdentifier) { + count += 1 + } + } + } else { + managedDisplayCount = 0 + } + + return HideNotchSnapshot( + hasSupportedDisplay: !supportedRecords.isEmpty, + supportedDisplayCount: supportedRecords.count, + managedDisplayCount: managedDisplayCount, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: stateStore.desiredEnabled, + isProcessing: isProcessing, + isAwaitingDisplay: stateStore.desiredEnabled && supportedRecords.isEmpty, + errorMessage: errorMessage + ) + } + + func refresh() { + scheduleSync() + } + + func setEnabled(_ isEnabled: Bool) { + stateStore.desiredEnabled = isEnabled + errorMessage = nil + scheduleSync(forceNotify: true) + } + + private func scheduleSync(forceNotify: Bool = false) { + if isProcessing { + pendingRefresh = true + if forceNotify { + onStateChange?() + } + return + } + + if !forceNotify, + !stateStore.desiredEnabled, + maskManager.managedDisplayIdentifiers.isEmpty { + onStateChange?() + return + } + + isProcessing = true + onStateChange?() + + Task { @MainActor [weak self] in + self?.performSync() + } + } + + private func performSync() { + let supportedRecords = displayCatalog.listDisplayRecords().filter { $0.context.isSupported } + + do { + if stateStore.desiredEnabled { + if supportedRecords.isEmpty { + maskManager.hideAllMasks() + logger.notice( + "hide-notch refresh skipped because no supported builtin notched display was found" + ) + } else { + try maskManager.synchronizeMasks(for: supportedRecords.map(\.context)) + logger.info( + "hide-notch synchronized desktop masks count=\(supportedRecords.count, privacy: .public)" + ) + } + } else { + maskManager.hideAllMasks() + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + logger.error( + "hide-notch sync failed error=\(error.localizedDescription, privacy: .public)" + ) + } + + isProcessing = false + onStateChange?() + + if pendingRefresh { + pendingRefresh = false + scheduleSync() + } + } + + private func installObservers() { + let activeSpaceToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.activeSpaceDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.scheduleDebouncedRefresh() + } + } + observationTokens.append( + NotificationObservation(center: workspaceNotificationCenter, token: activeSpaceToken) + ) + + let screensDidWakeToken = workspaceNotificationCenter.addObserver( + forName: NSWorkspace.screensDidWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.scheduleDebouncedRefresh() + } + } + observationTokens.append( + NotificationObservation(center: workspaceNotificationCenter, token: screensDidWakeToken) + ) + + let screenParametersToken = notificationCenter.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.scheduleDebouncedRefresh() + } + } + observationTokens.append( + NotificationObservation(center: notificationCenter, token: screenParametersToken) + ) + } + + private func scheduleDebouncedRefresh() { + debouncedRefreshTask?.cancel() + debouncedRefreshTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { + return + } + + await MainActor.run { [weak self] in + self?.refresh() + } + } + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchDisplayCatalog.swift b/Plugins/HideNotch/Sources/HideNotchDisplayCatalog.swift new file mode 100644 index 0000000..e01c9d4 --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchDisplayCatalog.swift @@ -0,0 +1,164 @@ +import AppKit +import CoreGraphics +import Darwin +import Foundation + +private enum ManagedDisplaySpacesBridge { + typealias DefaultConnectionForThreadFn = @convention(c) () -> Int32 + typealias CopyManagedDisplaySpacesFn = @convention(c) (Int32) -> Unmanaged? + + nonisolated(unsafe) static let handle = dlopen( + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", + RTLD_LAZY + ) + + static let defaultConnectionForThread: DefaultConnectionForThreadFn? = load( + name: "SLSDefaultConnectionForThread", + as: DefaultConnectionForThreadFn.self + ) + + static let copyManagedDisplaySpaces: CopyManagedDisplaySpacesFn? = load( + name: "SLSCopyManagedDisplaySpaces", + as: CopyManagedDisplaySpacesFn.self + ) + + private static func load(name: String, as _: T.Type) -> T? { + guard let handle, let symbol = dlsym(handle, name) else { + return nil + } + + return unsafeBitCast(symbol, to: T.self) + } +} + +enum HideNotchManagedDisplaySpaceResolver { + static func spaces(from item: [String: Any]) -> [HideNotchDisplaySpace] { + let currentSpace = item["Current Space"] as? [String: Any] + let currentIdentifier = resolvedIdentifier( + from: currentSpace, + isCurrentSpace: true + ) + + var rawSpaces: [HideNotchDisplaySpace] = [] + + if let currentIdentifier { + rawSpaces.append( + HideNotchDisplaySpace( + identifier: currentIdentifier, + isCurrent: true + ) + ) + } + + if let spaces = item["Spaces"] as? [[String: Any]] { + for space in spaces { + guard let identifier = resolvedIdentifier(from: space, isCurrentSpace: false) else { + continue + } + + rawSpaces.append( + HideNotchDisplaySpace( + identifier: identifier, + isCurrent: identifier == currentIdentifier + ) + ) + } + } + + var seenIdentifiers: Set = [] + let spaces = rawSpaces.filter { seenIdentifiers.insert($0.identifier).inserted } + return spaces.isEmpty ? [.currentPlaceholder] : spaces + } + + private static func resolvedIdentifier( + from space: [String: Any]?, + isCurrentSpace: Bool + ) -> String? { + guard let space else { + return isCurrentSpace ? HideNotchDisplaySpace.currentPlaceholderIdentifier : nil + } + + let spaceType = (space["type"] as? NSNumber)?.intValue ?? 0 + guard spaceType == 0 else { + return nil + } + + if let identifier = (space["uuid"] as? String), !identifier.isEmpty { + return identifier + } + + return isCurrentSpace ? HideNotchDisplaySpace.currentPlaceholderIdentifier : nil + } +} + +@MainActor +struct SystemHideNotchDisplayCatalog: HideNotchDisplayCatalogProviding { + func listDisplayRecords() -> [HideNotchDisplayRecord] { + let spacesByDisplay = displaySpacesByDisplayIdentifier() + + return NSScreen.screens.compactMap { screen in + guard let context = Self.displayContext(for: screen) else { + return nil + } + + let spaces = spacesByDisplay[context.displayIdentifier] ?? [.currentPlaceholder] + return HideNotchDisplayRecord( + context: context, + spaces: spaces + ) + } + } + + private func displaySpacesByDisplayIdentifier() -> [String: [HideNotchDisplaySpace]] { + guard + let defaultConnectionForThread = ManagedDisplaySpacesBridge.defaultConnectionForThread, + let copyManagedDisplaySpaces = ManagedDisplaySpacesBridge.copyManagedDisplaySpaces, + let managedDisplaySpaces = copyManagedDisplaySpaces(defaultConnectionForThread())?.takeRetainedValue() + as? [[String: Any]] + else { + return [:] + } + + return managedDisplaySpaces.reduce(into: [String: [HideNotchDisplaySpace]]()) { result, item in + guard let displayIdentifier = (item["Display Identifier"] as? String)?.uppercased() else { + return + } + + result[displayIdentifier] = HideNotchManagedDisplaySpaceResolver.spaces(from: item) + } + } + + private static func displayContext(for screen: NSScreen) -> HideNotchDisplayContext? { + guard + let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber + else { + return nil + } + + let displayID = screenNumber.uint32Value + + if CGDisplayIsInMirrorSet(displayID) != 0, CGDisplayIsMain(displayID) == 0 { + return nil + } + + let topLeftArea = screen.auxiliaryTopLeftArea ?? .zero + let topRightArea = screen.auxiliaryTopRightArea ?? .zero + let isBuiltin = CGDisplayIsBuiltin(displayID) != 0 + let hasUnobscuredTopArea = !topLeftArea.isEmpty || !topRightArea.isEmpty + let fallbackHeight = max(topLeftArea.height, topRightArea.height) + let menuBarHeight = NSApplication.shared.mainMenu?.menuBarHeight ?? 0 + let notchHeight = max(menuBarHeight, fallbackHeight) + let isSupported = isBuiltin && hasUnobscuredTopArea && notchHeight > 0 + + return HideNotchDisplayContext( + displayID: displayID, + displayIdentifier: HideNotchDisplayIdentity.stableIdentifier(for: displayID) ?? String(displayID), + name: screen.localizedName, + frame: screen.frame, + backingScaleFactor: screen.backingScaleFactor, + notchHeightPoints: notchHeight, + isBuiltin: isBuiltin, + isSupported: isSupported + ) + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchLog.swift b/Plugins/HideNotch/Sources/HideNotchLog.swift new file mode 100644 index 0000000..18e885e --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchLog.swift @@ -0,0 +1,17 @@ +import Foundation +import OSLog + +enum HideNotchLog { + private static let subsystem = Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools" + + static let controller = Logger(subsystem: subsystem, category: "HideNotchController") + static let overlayManager = Logger(subsystem: subsystem, category: "HideNotchOverlayManager") + + static var isVerboseLoggingEnabled: Bool { + #if DEBUG + ProcessInfo.processInfo.environment["MACTOOLS_VERBOSE_LOGS"] == "1" + #else + false + #endif + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchModels.swift b/Plugins/HideNotch/Sources/HideNotchModels.swift new file mode 100644 index 0000000..8384a1b --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchModels.swift @@ -0,0 +1,101 @@ +import AppKit +import CoreGraphics +import Foundation + +struct HideNotchSnapshot: Equatable, Sendable { + let hasSupportedDisplay: Bool + let supportedDisplayCount: Int + let managedDisplayCount: Int + let unsupportedVisibleDisplayCount: Int + let pendingRestoreCount: Int + let isEnabled: Bool + let isProcessing: Bool + let isAwaitingDisplay: Bool + let errorMessage: String? +} + +struct HideNotchDisplayContext: Equatable, Sendable { + let displayID: CGDirectDisplayID + let displayIdentifier: String + let name: String + let frame: CGRect + let backingScaleFactor: CGFloat + let notchHeightPoints: CGFloat + let isBuiltin: Bool + let isSupported: Bool +} + +struct HideNotchDisplaySpace: Equatable, Sendable { + static let currentPlaceholderIdentifier = "__current__" + static let currentPlaceholder = HideNotchDisplaySpace( + identifier: currentPlaceholderIdentifier, + isCurrent: true + ) + + let identifier: String + let isCurrent: Bool +} + +struct HideNotchDisplayRecord: Equatable, Sendable { + let context: HideNotchDisplayContext + let spaces: [HideNotchDisplaySpace] + + var currentSpaceIdentifier: String { + spaces.first(where: \.isCurrent)?.identifier + ?? spaces.first?.identifier + ?? HideNotchDisplaySpace.currentPlaceholderIdentifier + } + + var allSpaces: [HideNotchDisplaySpace] { + spaces.isEmpty ? [.currentPlaceholder] : spaces + } +} + +enum HideNotchDisplayIdentity { + static func stableIdentifier(for displayID: CGDirectDisplayID) -> String? { + guard let uuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { + return nil + } + + return (CFUUIDCreateString(nil, uuid) as String).uppercased() + } +} + +@MainActor +protocol HideNotchDesktopMaskWindowing: AnyObject { + func setFrame(_ frame: CGRect) + func show() + func close() +} + +@MainActor +protocol HideNotchDesktopMaskWindowBuilding { + func makeWindow(frame: CGRect) throws -> HideNotchDesktopMaskWindowing +} + +@MainActor +protocol HideNotchDisplayCatalogProviding { + func listDisplayRecords() -> [HideNotchDisplayRecord] +} + +@MainActor +protocol HideNotchDesktopMaskManaging: AnyObject { + var managedDisplayIdentifiers: Set { get } + + func synchronizeMasks(for displays: [HideNotchDisplayContext]) throws + func hideAllMasks() +} + +@MainActor +protocol HideNotchStateStoring: AnyObject { + var desiredEnabled: Bool { get set } +} + +@MainActor +protocol HideNotchWallpaperControlling: AnyObject { + var onStateChange: (() -> Void)? { get set } + + func snapshot() -> HideNotchSnapshot + func refresh() + func setEnabled(_ isEnabled: Bool) +} diff --git a/Plugins/HideNotch/Sources/HideNotchPlugin.swift b/Plugins/HideNotch/Sources/HideNotchPlugin.swift new file mode 100644 index 0000000..d209bb3 --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchPlugin.swift @@ -0,0 +1,123 @@ +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class HideNotchPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + HideNotchPluginProvider(context: context) + } +} + +@MainActor +private struct HideNotchPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [HideNotchPlugin(context: context)] + } +} + +@MainActor +final class HideNotchPlugin: MacToolsPlugin, PluginPrimaryPanel { + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let controller: HideNotchWallpaperControlling + private let localization: PluginLocalization + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "hide-notch"), + controller: HideNotchWallpaperControlling? = nil, + localization: PluginLocalization? = nil + ) { + let localization = localization ?? PluginLocalization(bundle: context.resourceBundle) + self.localization = localization + self.metadata = PluginMetadata( + id: "hide-notch", + title: localization.string("metadata.title", defaultValue: "隐藏刘海"), + iconName: "rectangle.topthird.inset.filled", + iconTint: Color(nsColor: .labelColor), + order: 40, + defaultDescription: localization.string("metadata.description", defaultValue: "自动遮挡刘海屏顶部区域") + ) + self.controller = controller ?? HideNotchController( + maskManager: HideNotchDesktopMaskManager(localization: localization), + context: context + ) + self.controller.onStateChange = { [weak self] in + self?.onStateChange?() + } + } + + var primaryPanelState: PluginPanelState { + let snapshot = controller.snapshot() + + if !snapshot.hasSupportedDisplay { + let subtitle = snapshot.isEnabled + ? localization.string("panel.subtitle.enabled", defaultValue: "已开启") + : localization.string("panel.subtitle.noSupportedDisplay", defaultValue: "未检测到刘海屏") + + return PluginPanelState( + subtitle: subtitle, + isOn: snapshot.isEnabled, + isExpanded: false, + isEnabled: false, + isVisible: true, + detail: nil, + errorMessage: snapshot.errorMessage + ) + } + + let subtitle: String + if snapshot.isEnabled { + subtitle = localization.string("panel.subtitle.enabled", defaultValue: "已开启") + } else if snapshot.isProcessing { + subtitle = localization.string("panel.subtitle.closing", defaultValue: "正在关闭") + } else { + subtitle = metadata.defaultDescription + } + + return PluginPanelState( + subtitle: subtitle, + isOn: snapshot.isEnabled, + isExpanded: false, + isEnabled: !snapshot.isProcessing, + isVisible: true, + detail: nil, + errorMessage: snapshot.errorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func refresh() { + controller.refresh() + } + + func handleAction(_ action: PluginPanelAction) { + guard case let .setSwitch(isEnabled) = action else { + return + } + + controller.setEnabled(isEnabled) + onStateChange?() + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} +} diff --git a/Plugins/HideNotch/Sources/HideNotchStateStore.swift b/Plugins/HideNotch/Sources/HideNotchStateStore.swift new file mode 100644 index 0000000..1874dc2 --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchStateStore.swift @@ -0,0 +1,42 @@ +import Foundation +import MacToolsPluginKit + +@MainActor +final class HideNotchStateStore: HideNotchStateStoring { + private enum StorageKey { + static let desiredEnabled = "hide-notch.enabled" + } + + private let storage: PluginStorage + private let legacyUserDefaults: UserDefaults + private let obsoleteKeys = [ + "feature.hideNotchManagedWallpapers", + "feature.hideNotchEnabled", + "hide-notch.original-wallpaper-states", + "hide-notch.managed-space-states" + ] + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "hide-notch"), + userDefaults: UserDefaults? = nil + ) { + self.legacyUserDefaults = userDefaults ?? .standard + self.storage = userDefaults.map { + UserDefaultsPluginStorage(pluginID: context.pluginID, userDefaults: $0) + } ?? context.storage + storage.migrateValueIfNeeded( + fromLegacyKey: StorageKey.desiredEnabled, + to: StorageKey.desiredEnabled + ) + removeObsoleteStateIfNeeded() + } + + var desiredEnabled: Bool { + get { storage.bool(forKey: StorageKey.desiredEnabled) } + set { storage.set(newValue, forKey: StorageKey.desiredEnabled) } + } + + private func removeObsoleteStateIfNeeded() { + obsoleteKeys.forEach { legacyUserDefaults.removeObject(forKey: $0) } + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchWallpaperManager.swift b/Plugins/HideNotch/Sources/HideNotchWallpaperManager.swift new file mode 100644 index 0000000..f9c87a8 --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchWallpaperManager.swift @@ -0,0 +1,132 @@ +import AppKit +import CoreGraphics +import Foundation +import MacToolsPluginKit + +enum HideNotchDesktopMaskManagerError: LocalizedError { + case maskWindowCreationFailed(displayName: String, underlyingMessage: String, PluginLocalization) + + var errorDescription: String? { + switch self { + case let .maskWindowCreationFailed(displayName, underlyingMessage, localization): + return localization.format( + "error.maskWindowCreationFailedFormat", + defaultValue: "无法为 %@ 创建刘海遮挡层:%@", + displayName, + underlyingMessage + ) + } + } +} + +@MainActor +final class HideNotchDesktopMaskManager: HideNotchDesktopMaskManaging { + private struct ManagedWindow { + let display: HideNotchDisplayContext + let window: HideNotchDesktopMaskWindowing + } + + private let windowBuilder: HideNotchDesktopMaskWindowBuilding + private let localization: PluginLocalization + private let logger = HideNotchLog.overlayManager + + private var windowsByDisplayIdentifier: [String: ManagedWindow] = [:] + + init( + windowBuilder: HideNotchDesktopMaskWindowBuilding? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.windowBuilder = windowBuilder ?? HideNotchDesktopMaskWindowBuilder(localization: localization) + } + + var managedDisplayIdentifiers: Set { + Set(windowsByDisplayIdentifier.keys) + } + + func synchronizeMasks(for displays: [HideNotchDisplayContext]) throws { + let supportedDisplays = displays.filter(\.isSupported) + let desiredDisplayIdentifiers = Set(supportedDisplays.map(\.displayIdentifier)) + + for displayIdentifier in windowsByDisplayIdentifier.keys.sorted() + where !desiredDisplayIdentifiers.contains(displayIdentifier) { + if let managedWindow = windowsByDisplayIdentifier.removeValue(forKey: displayIdentifier) { + managedWindow.window.close() + logger.info( + "hide-notch removed desktop mask display=\(displayIdentifier, privacy: .public)" + ) + } + } + + for display in supportedDisplays { + let frame = Self.maskFrame(for: display) + guard !frame.isEmpty else { + if let managedWindow = windowsByDisplayIdentifier.removeValue(forKey: display.displayIdentifier) { + managedWindow.window.close() + } + continue + } + + if let managedWindow = windowsByDisplayIdentifier[display.displayIdentifier] { + managedWindow.window.setFrame(frame) + managedWindow.window.show() + windowsByDisplayIdentifier[display.displayIdentifier] = ManagedWindow( + display: display, + window: managedWindow.window + ) + continue + } + + do { + let window = try windowBuilder.makeWindow(frame: frame) + window.show() + windowsByDisplayIdentifier[display.displayIdentifier] = ManagedWindow( + display: display, + window: window + ) + logger.info( + "hide-notch created desktop mask display=\(display.displayIdentifier, privacy: .public) frame=\(NSStringFromRect(frame), privacy: .public)" + ) + } catch { + throw HideNotchDesktopMaskManagerError.maskWindowCreationFailed( + displayName: display.name, + underlyingMessage: error.localizedDescription, + localization + ) + } + } + + if HideNotchLog.isVerboseLoggingEnabled { + let activeDisplays = managedDisplayIdentifiers.sorted().joined(separator: ",") + logger.debug( + "hide-notch active desktop masks=\(activeDisplays, privacy: .public)" + ) + } + } + + func hideAllMasks() { + guard !windowsByDisplayIdentifier.isEmpty else { + return + } + + for managedWindow in windowsByDisplayIdentifier.values { + managedWindow.window.close() + } + windowsByDisplayIdentifier.removeAll() + logger.info("hide-notch cleared all desktop masks") + } + + static func maskFrame(for display: HideNotchDisplayContext) -> CGRect { + let height = min(display.frame.height, max(0, display.notchHeightPoints)) + guard height > 0 else { + return .zero + } + + return CGRect( + x: display.frame.minX, + y: display.frame.maxY - height, + width: display.frame.width, + height: height + ) + } +} diff --git a/Plugins/HideNotch/Sources/HideNotchWallpaperRenderer.swift b/Plugins/HideNotch/Sources/HideNotchWallpaperRenderer.swift new file mode 100644 index 0000000..afeb272 --- /dev/null +++ b/Plugins/HideNotch/Sources/HideNotchWallpaperRenderer.swift @@ -0,0 +1,96 @@ +import AppKit +import CoreGraphics +import Foundation +import MacToolsPluginKit + +enum HideNotchDesktopMaskWindowError: LocalizedError { + case creationFailed(PluginLocalization) + + var errorDescription: String? { + switch self { + case let .creationFailed(localization): + return localization.string("error.maskWindowCreationFailed", defaultValue: "无法创建隐藏刘海遮挡层。") + } + } +} + +private final class HideNotchDesktopMaskWindow: NSWindow { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +private final class HideNotchDesktopMaskView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + nil + } +} + +private final class SystemHideNotchDesktopMaskWindow: HideNotchDesktopMaskWindowing { + private let window: HideNotchDesktopMaskWindow + + init(frame: CGRect) { + let window = HideNotchDesktopMaskWindow( + contentRect: frame, + styleMask: .borderless, + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.level = Self.windowLevel + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + window.ignoresMouseEvents = true + window.hasShadow = false + window.backgroundColor = .black + window.isOpaque = true + window.animationBehavior = .none + window.contentView = HideNotchDesktopMaskView( + frame: CGRect(origin: .zero, size: frame.size) + ) + self.window = window + } + + func setFrame(_ frame: CGRect) { + window.setFrame(frame, display: true) + window.contentView?.frame = CGRect(origin: .zero, size: frame.size) + } + + func show() { + window.orderFrontRegardless() + } + + func close() { + window.orderOut(nil) + window.close() + } + + private static let windowLevel = NSWindow.Level( + rawValue: max( + Int(CGWindowLevelForKey(.desktopWindow)) + 1, + Int(CGWindowLevelForKey(.desktopIconWindow)) - 1 + ) + ) +} + +@MainActor +final class HideNotchDesktopMaskWindowBuilder: HideNotchDesktopMaskWindowBuilding { + private let localization: PluginLocalization + + init(localization: PluginLocalization = PluginLocalization(bundle: .main)) { + self.localization = localization + } + + func makeWindow(frame: CGRect) throws -> HideNotchDesktopMaskWindowing { + guard !frame.isEmpty else { + throw HideNotchDesktopMaskWindowError.creationFailed(localization) + } + + return SystemHideNotchDesktopMaskWindow(frame: frame) + } +} diff --git a/Plugins/HideNotch/Tests/HideNotchDisplayCatalogTests.swift b/Plugins/HideNotch/Tests/HideNotchDisplayCatalogTests.swift new file mode 100644 index 0000000..1f2e058 --- /dev/null +++ b/Plugins/HideNotch/Tests/HideNotchDisplayCatalogTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import MacTools +@testable import HideNotchPlugin + +final class HideNotchDisplayCatalogTests: XCTestCase { + func testResolverUsesCurrentPlaceholderWhenCurrentDesktopUUIDIsEmpty() { + let spaces = HideNotchManagedDisplaySpaceResolver.spaces(from: [ + "Current Space": [ + "uuid": "", + "type": 0 + ], + "Spaces": [ + [ + "uuid": "", + "type": 0 + ] + ] + ]) + + XCTAssertEqual( + spaces, + [ + HideNotchDisplaySpace( + identifier: HideNotchDisplaySpace.currentPlaceholderIdentifier, + isCurrent: true + ) + ] + ) + } + + func testResolverFiltersOutNonDesktopSpaces() { + let spaces = HideNotchManagedDisplaySpaceResolver.spaces(from: [ + "Current Space": [ + "uuid": "", + "type": 0 + ], + "Spaces": [ + [ + "uuid": "", + "type": 0 + ], + [ + "uuid": "E511762E-A085-4DFB-AF2E-B8F5E83A7952", + "type": 4, + "WallSpace": [ + "uuid": "48CC1451-CDC2-4890-91F0-A03908F06252", + "type": 6 + ] + ], + [ + "uuid": "DESKTOP-2", + "type": 0 + ] + ] + ]) + + XCTAssertEqual( + spaces, + [ + HideNotchDisplaySpace( + identifier: HideNotchDisplaySpace.currentPlaceholderIdentifier, + isCurrent: true + ), + HideNotchDisplaySpace( + identifier: "DESKTOP-2", + isCurrent: false + ) + ] + ) + } +} diff --git a/Plugins/HideNotch/Tests/HideNotchPluginHostIntegrationTests.swift b/Plugins/HideNotch/Tests/HideNotchPluginHostIntegrationTests.swift new file mode 100644 index 0000000..4f3b60f --- /dev/null +++ b/Plugins/HideNotch/Tests/HideNotchPluginHostIntegrationTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import MacTools +@testable import HideNotchPlugin + +@MainActor +final class HideNotchPluginHostIntegrationTests: XCTestCase { + func testPluginHostExposesHideNotchPanelItem() { + let controller = MockHideNotchWallpaperController() + controller.snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: true, + supportedDisplayCount: 1, + managedDisplayCount: 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: false, + isProcessing: false, + isAwaitingDisplay: false, + errorMessage: nil + ) + let userDefaults = makeIsolatedUserDefaults() + let plugin = HideNotchPlugin(controller: controller) + + let host = PluginHost( + plugins: [plugin], + shortcutStore: ShortcutStore(userDefaults: userDefaults), + pluginDisplayPreferencesStore: PluginDisplayPreferencesStore(userDefaults: userDefaults), + globalShortcutManager: GlobalShortcutManager() + ) + + XCTAssertEqual(host.panelItems.map(\.id), ["hide-notch"]) + XCTAssertEqual(host.panelItems.first?.title, "隐藏刘海") + XCTAssertEqual(host.panelItems.first?.description, "自动遮挡刘海屏顶部区域") + } +} diff --git a/Plugins/HideNotch/Tests/HideNotchPluginTests.swift b/Plugins/HideNotch/Tests/HideNotchPluginTests.swift new file mode 100644 index 0000000..192eaaa --- /dev/null +++ b/Plugins/HideNotch/Tests/HideNotchPluginTests.swift @@ -0,0 +1,110 @@ +import XCTest +@testable import MacTools +@testable import HideNotchPlugin + +@MainActor +final class HideNotchPluginTests: XCTestCase { + func testPanelStateDisablesSwitchWhenNoSupportedDisplayExists() { + let controller = MockHideNotchWallpaperController() + controller.snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: false, + supportedDisplayCount: 0, + managedDisplayCount: 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: false, + isProcessing: false, + isAwaitingDisplay: false, + errorMessage: nil + ) + + let plugin = HideNotchPlugin(controller: controller) + let state = plugin.primaryPanelState + + XCTAssertEqual(state.subtitle, "未检测到刘海屏") + XCTAssertFalse(state.isEnabled) + XCTAssertFalse(state.isOn) + } + + func testPanelStateShowsWaitingSubtitleWhenEnabledWithoutSupportedDisplay() { + let controller = MockHideNotchWallpaperController() + controller.snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: false, + supportedDisplayCount: 0, + managedDisplayCount: 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: true, + isProcessing: false, + isAwaitingDisplay: true, + errorMessage: nil + ) + + let plugin = HideNotchPlugin(controller: controller) + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") + XCTAssertTrue(plugin.primaryPanelState.isOn) + XCTAssertFalse(plugin.primaryPanelState.isEnabled) + } + + func testPanelStateShowsManagedDisplayCountWhenEnabled() { + let controller = MockHideNotchWallpaperController() + controller.snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: true, + supportedDisplayCount: 2, + managedDisplayCount: 2, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: true, + isProcessing: false, + isAwaitingDisplay: false, + errorMessage: nil + ) + + let plugin = HideNotchPlugin(controller: controller) + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") + XCTAssertTrue(plugin.primaryPanelState.isOn) + } + + func testToggleOnForwardsToController() { + let controller = MockHideNotchWallpaperController() + controller.snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: true, + supportedDisplayCount: 1, + managedDisplayCount: 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: false, + isProcessing: false, + isAwaitingDisplay: false, + errorMessage: nil + ) + + let plugin = HideNotchPlugin(controller: controller) + plugin.handleAction(.setSwitch(true)) + + XCTAssertEqual(controller.setEnabledCalls, [true]) + XCTAssertTrue(plugin.primaryPanelState.isOn) + } + + func testPanelStateShowsEnabledSubtitleWhileProcessing() { + let controller = MockHideNotchWallpaperController() + controller.snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: true, + supportedDisplayCount: 1, + managedDisplayCount: 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: true, + isProcessing: true, + isAwaitingDisplay: false, + errorMessage: nil + ) + + let plugin = HideNotchPlugin(controller: controller) + + XCTAssertEqual(plugin.primaryPanelState.subtitle, "已开启") + XCTAssertFalse(plugin.primaryPanelState.isEnabled) + } +} diff --git a/Plugins/HideNotch/Tests/HideNotchTestSupport.swift b/Plugins/HideNotch/Tests/HideNotchTestSupport.swift new file mode 100644 index 0000000..b373a14 --- /dev/null +++ b/Plugins/HideNotch/Tests/HideNotchTestSupport.swift @@ -0,0 +1,141 @@ +import AppKit +import XCTest +@testable import MacTools +@testable import HideNotchPlugin + +@MainActor +final class MockHideNotchWallpaperController: HideNotchWallpaperControlling { + var onStateChange: (() -> Void)? + var snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: false, + supportedDisplayCount: 0, + managedDisplayCount: 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: false, + isProcessing: false, + isAwaitingDisplay: false, + errorMessage: nil + ) + private(set) var refreshCallCount = 0 + private(set) var setEnabledCalls: [Bool] = [] + + func snapshot() -> HideNotchSnapshot { + snapshotValue + } + + func refresh() { + refreshCallCount += 1 + } + + func setEnabled(_ isEnabled: Bool) { + setEnabledCalls.append(isEnabled) + snapshotValue = HideNotchSnapshot( + hasSupportedDisplay: snapshotValue.hasSupportedDisplay, + supportedDisplayCount: snapshotValue.supportedDisplayCount, + managedDisplayCount: isEnabled ? max(snapshotValue.managedDisplayCount, 1) : 0, + unsupportedVisibleDisplayCount: 0, + pendingRestoreCount: 0, + isEnabled: isEnabled, + isProcessing: false, + isAwaitingDisplay: isEnabled && !snapshotValue.hasSupportedDisplay, + errorMessage: snapshotValue.errorMessage + ) + } +} + +@MainActor +final class InMemoryHideNotchStateStore: HideNotchStateStoring { + var desiredEnabled = false +} + +struct StubHideNotchDisplayCatalog: HideNotchDisplayCatalogProviding { + let records: [HideNotchDisplayRecord] + + func listDisplayRecords() -> [HideNotchDisplayRecord] { + records + } +} + +@MainActor +final class RecordingHideNotchDesktopMaskWindow: HideNotchDesktopMaskWindowing { + private(set) var frames: [CGRect] + private(set) var showCallCount = 0 + private(set) var closeCallCount = 0 + + init(frame: CGRect) { + frames = [frame] + } + + func setFrame(_ frame: CGRect) { + frames.append(frame) + } + + func show() { + showCallCount += 1 + } + + func close() { + closeCallCount += 1 + } +} + +enum StubHideNotchDesktopMaskWindowBuilderError: LocalizedError { + case forcedFailure + + var errorDescription: String? { + "forced failure" + } +} + +@MainActor +final class RecordingHideNotchDesktopMaskWindowBuilder: HideNotchDesktopMaskWindowBuilding { + var makeWindowError: Error? + + private(set) var windowsByOriginX: [CGFloat: RecordingHideNotchDesktopMaskWindow] = [:] + private(set) var createdFrames: [CGRect] = [] + + func makeWindow(frame: CGRect) throws -> HideNotchDesktopMaskWindowing { + if let makeWindowError { + throw makeWindowError + } + + let window = RecordingHideNotchDesktopMaskWindow(frame: frame) + createdFrames.append(frame) + windowsByOriginX[frame.minX] = window + return window + } +} + +func makeHideNotchDisplayRecord( + id displayID: CGDirectDisplayID, + displayIdentifier: String, + spaces: [HideNotchDisplaySpace] = [HideNotchDisplaySpace(identifier: "space-1", isCurrent: true)], + name: String = "Built-in Retina Display", + frame: CGRect = CGRect(x: 0, y: 0, width: 1512, height: 982), + backingScaleFactor: CGFloat = 2, + notchHeightPoints: CGFloat = 36, + isBuiltin: Bool = true, + isSupported: Bool = true +) -> HideNotchDisplayRecord { + HideNotchDisplayRecord( + context: HideNotchDisplayContext( + displayID: displayID, + displayIdentifier: displayIdentifier, + name: name, + frame: frame, + backingScaleFactor: backingScaleFactor, + notchHeightPoints: notchHeightPoints, + isBuiltin: isBuiltin, + isSupported: isSupported + ), + spaces: spaces + ) +} + +func makeIsolatedUserDefaults() -> UserDefaults { + let suiteName = "HideNotchTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults +} diff --git a/Plugins/HideNotch/Tests/HideNotchWallpaperManagerTests.swift b/Plugins/HideNotch/Tests/HideNotchWallpaperManagerTests.swift new file mode 100644 index 0000000..fe6e5b7 --- /dev/null +++ b/Plugins/HideNotch/Tests/HideNotchWallpaperManagerTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import MacTools +@testable import HideNotchPlugin + +@MainActor +final class HideNotchDesktopMaskManagerTests: XCTestCase { + func testSynchronizeCreatesOneWindowPerSupportedDisplay() throws { + let primary = makeHideNotchDisplayRecord( + id: 1, + displayIdentifier: "BUILTIN-1" + ).context + let secondary = makeHideNotchDisplayRecord( + id: 2, + displayIdentifier: "BUILTIN-2", + frame: CGRect(x: 1512, y: 0, width: 1512, height: 982) + ).context + + let builder = RecordingHideNotchDesktopMaskWindowBuilder() + let manager = HideNotchDesktopMaskManager(windowBuilder: builder) + + try manager.synchronizeMasks(for: [primary, secondary]) + + XCTAssertEqual(builder.createdFrames.count, 2) + XCTAssertEqual(manager.managedDisplayIdentifiers, Set(["BUILTIN-1", "BUILTIN-2"])) + XCTAssertEqual(builder.windowsByOriginX[0]?.showCallCount, 1) + XCTAssertEqual(builder.windowsByOriginX[1512]?.showCallCount, 1) + } + + func testSynchronizeUpdatesExistingWindowsAndClosesRemovedDisplays() throws { + let firstPass = makeHideNotchDisplayRecord( + id: 1, + displayIdentifier: "BUILTIN-1", + frame: CGRect(x: 0, y: 0, width: 1512, height: 982), + notchHeightPoints: 36 + ).context + let secondPass = makeHideNotchDisplayRecord( + id: 1, + displayIdentifier: "BUILTIN-1", + frame: CGRect(x: 0, y: 0, width: 1512, height: 982), + notchHeightPoints: 48 + ).context + let removedDisplay = makeHideNotchDisplayRecord( + id: 2, + displayIdentifier: "BUILTIN-2", + frame: CGRect(x: 1512, y: 0, width: 1512, height: 982) + ).context + + let builder = RecordingHideNotchDesktopMaskWindowBuilder() + let manager = HideNotchDesktopMaskManager(windowBuilder: builder) + + try manager.synchronizeMasks(for: [firstPass, removedDisplay]) + try manager.synchronizeMasks(for: [secondPass]) + + let updatedWindow = builder.windowsByOriginX[0] + XCTAssertEqual(updatedWindow?.frames.last?.height, 48) + XCTAssertEqual(updatedWindow?.showCallCount, 2) + XCTAssertEqual(builder.windowsByOriginX[1512]?.closeCallCount, 1) + XCTAssertEqual(manager.managedDisplayIdentifiers, Set(["BUILTIN-1"])) + } + + func testSynchronizeIgnoresUnsupportedDisplays() throws { + let unsupported = makeHideNotchDisplayRecord( + id: 3, + displayIdentifier: "EXTERNAL-1", + name: "Studio Display", + isBuiltin: false, + isSupported: false + ).context + + let builder = RecordingHideNotchDesktopMaskWindowBuilder() + let manager = HideNotchDesktopMaskManager(windowBuilder: builder) + + try manager.synchronizeMasks(for: [unsupported]) + + XCTAssertTrue(builder.createdFrames.isEmpty) + XCTAssertTrue(manager.managedDisplayIdentifiers.isEmpty) + } + + func testSynchronizeSurfacesWindowCreationFailures() { + let supported = makeHideNotchDisplayRecord( + id: 4, + displayIdentifier: "BUILTIN-ERR" + ).context + + let builder = RecordingHideNotchDesktopMaskWindowBuilder() + builder.makeWindowError = StubHideNotchDesktopMaskWindowBuilderError.forcedFailure + let manager = HideNotchDesktopMaskManager(windowBuilder: builder) + + XCTAssertThrowsError(try manager.synchronizeMasks(for: [supported])) { error in + XCTAssertEqual( + error.localizedDescription, + "无法为 Built-in Retina Display 创建刘海遮挡层:forced failure" + ) + } + } +} diff --git a/Plugins/HideNotch/Tests/HideNotchWallpaperRendererTests.swift b/Plugins/HideNotch/Tests/HideNotchWallpaperRendererTests.swift new file mode 100644 index 0000000..302d960 --- /dev/null +++ b/Plugins/HideNotch/Tests/HideNotchWallpaperRendererTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import MacTools +@testable import HideNotchPlugin + +@MainActor +final class HideNotchDesktopMaskGeometryTests: XCTestCase { + func testMaskFramePinsBlackBarToTopEdgeUsingNotchHeight() { + let display = makeHideNotchDisplayRecord( + id: 6, + displayIdentifier: "BUILTIN-GEOMETRY", + frame: CGRect(x: 40, y: 20, width: 1512, height: 982), + notchHeightPoints: 42 + ).context + + let frame = HideNotchDesktopMaskManager.maskFrame(for: display) + + XCTAssertEqual(frame.origin.x, 40) + XCTAssertEqual(frame.origin.y, 960) + XCTAssertEqual(frame.width, 1512) + XCTAssertEqual(frame.height, 42) + } + + func testMaskFrameIsEmptyWhenNotchHeightIsZero() { + let display = makeHideNotchDisplayRecord( + id: 7, + displayIdentifier: "BUILTIN-ZERO", + notchHeightPoints: 0 + ).context + + XCTAssertEqual(HideNotchDesktopMaskManager.maskFrame(for: display), .zero) + } +} diff --git a/Plugins/HideNotch/plugin.json b/Plugins/HideNotch/plugin.json new file mode 100644 index 0000000..beaf849 --- /dev/null +++ b/Plugins/HideNotch/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "hide-notch", + "displayName": "隐藏刘海", + "summary": "自动遮挡刘海屏顶部区域", + "localizedMetadata": { + "ar": { + "displayName": "إخفاء النتوء", + "summary": "قم بتغطية المنطقة العلوية تلقائيًا على الشاشات المسننة." + }, + "de": { + "displayName": "Notch ausblenden", + "summary": "Decken Sie automatisch den oberen Kerbbereich auf gekerbten Displays ab." + }, + "en": { + "displayName": "Hide Notch", + "summary": "Automatically cover the top notch area on notched displays." + }, + "es": { + "displayName": "Ocultar muesca", + "summary": "Cubra automáticamente el área de muesca superior en pantallas con muescas." + }, + "fr": { + "displayName": "Masquer l’encoche", + "summary": "Couvrez automatiquement la zone supérieure sur les écrans à encoches." + }, + "ja": { + "displayName": "ノッチを隠す", + "summary": "ノッチ付きディスプレイの最上部のノッチ領域を自動的にカバーします。" + }, + "ko": { + "displayName": "노치 숨기기", + "summary": "노치 디스플레이의 상단 노치 영역을 자동으로 덮습니다." + }, + "pt": { + "displayName": "Ocultar entalhe", + "summary": "Cobre automaticamente a área superior em telas entalhadas." + }, + "ru": { + "displayName": "Скрыть вырез", + "summary": "Автоматически закрывать верхнюю область на дисплеях с вырезом." + }, + "zh-Hans": { + "displayName": "隐藏刘海", + "summary": "自动遮挡刘海屏顶部区域" + }, + "zh-Hant": { + "displayName": "隱藏瀏海", + "summary": "自動遮擋劉海螢幕頂部區域" + } + }, + "version": "1.0.7", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "HideNotch.bundle", + "factoryClass": "HideNotchPlugin.HideNotchPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "HideNotchPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [], + "category": "display" +} diff --git a/Plugins/Homebrew/Bundle/HomebrewPluginBundleEntrypoint.swift b/Plugins/Homebrew/Bundle/HomebrewPluginBundleEntrypoint.swift new file mode 100644 index 0000000..ab404cc --- /dev/null +++ b/Plugins/Homebrew/Bundle/HomebrewPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import HomebrewPlugin + +private let homebrewPluginFactoryAnchor: Any.Type = HomebrewPluginFactory.self diff --git a/Plugins/Homebrew/Resources/Localizable.xcstrings b/Plugins/Homebrew/Resources/Localizable.xcstrings new file mode 100644 index 0000000..cd04332 --- /dev/null +++ b/Plugins/Homebrew/Resources/Localizable.xcstrings @@ -0,0 +1,7002 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "detail.confirm.cancel": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "detail.confirm.cleanup.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理" + } + } + } + }, + "detail.confirm.cleanup.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يشغّل brew cleanup لإزالة إصدارات Homebrew القديمة وذاكرات التنزيل المؤقتة." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs brew cleanup to remove old Homebrew versions and download caches." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecuta brew cleanup para eliminar versiones antiguas y cachés de descarga de Homebrew." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécute brew cleanup pour supprimer les anciennes versions Homebrew et les caches de téléchargement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew cleanup を実行して古い Homebrew バージョンとダウンロードキャッシュを削除します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executa brew cleanup para remover versões antigas e caches de download do Homebrew." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将执行 brew cleanup,删除 Homebrew 旧版本和下载缓存。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將執行 brew cleanup,刪除 Homebrew 舊版本與下載快取。" + } + } + } + }, + "detail.confirm.cleanup.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف ذاكرة Homebrew المؤقتة؟" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean Homebrew cache?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Limpiar caché de Homebrew?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer le cache Homebrew ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew キャッシュを削除しますか?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar cache do Homebrew?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认清理 Homebrew 缓存?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認清理 Homebrew 快取?" + } + } + } + }, + "detail.confirm.defaultTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد الإجراء" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Action" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar acción" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer l’action" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作を確認" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ação" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認操作" + } + } + } + }, + "detail.confirm.tap.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入" + } + } + } + }, + "detail.confirm.tap.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يشغّل brew tap %@ ليجلب Homebrew بيانات formula و cask من هذا المصدر." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs brew tap %@ so Homebrew can fetch formula and cask data from this source." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecuta brew tap %@ para que Homebrew obtenga datos de formula y cask desde esta fuente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécute brew tap %@ afin que Homebrew récupère les données formula et cask depuis cette source." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew tap %@ を実行し、このソースから formula と cask 情報を取得できるようにします。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executa brew tap %@ para que o Homebrew obtenha dados de formula e cask dessa fonte." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将执行 brew tap %@,Homebrew 会从该软件源获取配方和 cask 信息。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將執行 brew tap %@,Homebrew 會從該軟體源取得 formula 與 cask 資訊。" + } + } + } + }, + "detail.confirm.tap.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة مصدر؟" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add tap?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Agregar tap?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter le tap ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap を追加しますか?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar tap?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认添加软件源?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認加入軟體源?" + } + } + } + }, + "detail.confirm.uninstall.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desinstalar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désinstaller" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンインストール" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desinstalar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解除安裝" + } + } + } + }, + "detail.confirm.uninstall.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يشغّل brew uninstall %@. قد تتأثر الميزات التي تعتمد عليه." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs brew uninstall %@. Features that depend on it may be affected." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecuta brew uninstall %@. Las funciones que dependen de este paquete pueden verse afectadas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécute brew uninstall %@. Les fonctionnalités qui en dépendent peuvent être affectées." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew uninstall %@ を実行します。他のパッケージが依存している場合、関連機能に影響することがあります。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executa brew uninstall %@. Recursos que dependem dele podem ser afetados." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将执行 brew uninstall %@。如果其他包依赖它,相关功能可能受到影响。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將執行 brew uninstall %@。若其他套件相依於它,相關功能可能受影響。" + } + } + } + }, + "detail.confirm.uninstall.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة %@؟" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall %@?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Desinstalar %@?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désinstaller %@ ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ をアンインストールしますか?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desinstalar %@?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认卸载 %@?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認解除安裝 %@?" + } + } + } + }, + "detail.confirm.untap.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "detail.confirm.untap.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يشغّل brew untap %@. قد تتوقف الحزم من هذا المصدر عن تلقي التحديثات." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs brew untap %@. Packages from this source may stop receiving updates." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecuta brew untap %@. Los paquetes de esta fuente podrían dejar de actualizarse." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécute brew untap %@. Les paquets de cette source peuvent ne plus recevoir de mises à jour." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew untap %@ を実行します。このソースに依存するパッケージは更新できなくなる場合があります。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executa brew untap %@. Pacotes dessa fonte podem deixar de receber atualizações." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将执行 brew untap %@。依赖该软件源的包可能无法继续更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將執行 brew untap %@。依賴該軟體源的套件可能無法繼續更新。" + } + } + } + }, + "detail.confirm.untap.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة %@؟" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove %@?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Eliminar %@?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer %@ ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を削除しますか?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover %@?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认移除 %@?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認移除 %@?" + } + } + } + }, + "detail.confirm.upgradeAll.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية الكل" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade All" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout mettre à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて更新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar tudo" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部更新" + } + } + } + }, + "detail.confirm.upgradeAll.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يشغّل brew upgrade لتحديث كل حزم Homebrew القابلة للترقية. قد تتغير البرامج المثبتة." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runs brew upgrade to update all upgradeable Homebrew packages. Installed software may change." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecuta brew upgrade para actualizar todos los paquetes Homebrew actualizables. El software instalado puede cambiar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécute brew upgrade pour mettre à jour tous les paquets Homebrew disponibles. Les logiciels installés peuvent changer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew upgrade を実行して更新可能な Homebrew パッケージをすべて更新します。インストール済みソフトウェアが変更される場合があります。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executa brew upgrade para atualizar todos os pacotes Homebrew atualizáveis. O software instalado pode mudar." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将执行 brew upgrade,更新所有可升级的 Homebrew 包。此操作可能修改已安装软件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將執行 brew upgrade,更新所有可升級的 Homebrew 套件。此操作可能修改已安裝軟體。" + } + } + } + }, + "detail.confirm.upgradeAll.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية كل الحزم؟" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade all packages?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Actualizar todos los paquetes?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à jour tous les paquets ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのパッケージを更新しますか?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar todos os pacotes?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认更新所有包?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認更新所有套件?" + } + } + } + }, + "detail.console.buttonCancel": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء العملية" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel Operation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar operación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler l’opération" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作をキャンセル" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cancelar operação" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消操作" + } + } + } + }, + "detail.console.buttonClear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "消去" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除" + } + } + } + }, + "detail.console.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجلات وحدة التحكم" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Console Logs" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Registros de consola" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Journaux de console" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンソールログ" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Logs do console" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "控制台日志" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "主控台記錄" + } + } + } + }, + "detail.detail.actionPin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت الإصدار" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin Version" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijar versión" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler la version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バージョンを固定" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fixar versão" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "锁定版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "鎖定版本" + } + } + } + }, + "detail.detail.actionUninstall": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstall" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desinstalar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désinstaller" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンインストール" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desinstalar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解除安裝" + } + } + } + }, + "detail.detail.actionUnpin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء التثبيت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dejar de fijar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désépingler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "固定解除" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desfixar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "解除锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解除鎖定" + } + } + } + }, + "detail.detail.actionUpgrade": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップグレード" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "detail.detail.dependencies": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التبعيات (%d)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dependencies (%d)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dependencias (%d)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dépendances (%d)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "依存関係(%d)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Dependências (%d)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "依赖(%d)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "相依套件(%d)" + } + } + } + }, + "detail.detail.installedVer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المثبت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済み" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalado" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已安裝版本" + } + } + } + }, + "detail.detail.latestAvailableVer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأحدث المتاح" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Latest Available" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Última disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dernière disponible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "利用可能な最新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mais recente disponível" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可用最新版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可用最新版本" + } + } + } + }, + "detail.detail.latestVer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأحدث" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Latest" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Última" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dernière" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mais recente" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "最新版本" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最新版本" + } + } + } + }, + "detail.detail.onlineHint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدد نتيجة بحث للتثبيت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select a search result to install" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Selecciona un resultado para instalar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnez un résultat à installer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索結果を選択してインストール" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Selecione um resultado para instalar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择搜索结果后安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇搜尋結果後安裝" + } + } + } + }, + "detail.detail.pinned": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مثبت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinned" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épinglé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "固定済み" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fixado" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已锁定" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已鎖定" + } + } + } + }, + "detail.detail.selectionHint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدد حزمة لعرض التفاصيل" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select a package to view details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Selecciona un paquete para ver detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnez un paquet pour voir les détails" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージを選択して詳細を表示" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Selecione um pacote para ver detalhes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择一个包查看详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇套件以查看詳情" + } + } + } + }, + "detail.detail.type": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النوع" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "種類" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "类型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "類型" + } + } + } + }, + "detail.detail.typeCask": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطبيق رسومي (Cask)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "GUI App (Cask)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "App gráfica (Cask)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "App graphique (Cask)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "GUI アプリ(Cask)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "App gráfica (Cask)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "图形应用(Cask)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "圖形 App(Cask)" + } + } + } + }, + "detail.detail.typeFormula": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حزمة سطر أوامر (Formula)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "CLI Package (Formula)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Paquete CLI (Formula)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Paquet CLI (Formula)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CLI パッケージ(Formula)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pacote CLI (Formula)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令行包(Formula)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "命令列套件(Formula)" + } + } + } + }, + "detail.detail.usedBy": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مطلوب بواسطة (%d)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Required By (%d)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Requerido por (%d)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Requis par (%d)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "必要とするパッケージ(%d)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exigido por (%d)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "被依赖(%d)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "被相依(%d)" + } + } + } + }, + "detail.detail.website": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الصفحة الرئيسية" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Homepage" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Página web" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Page d’accueil" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホームページ" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Página inicial" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "主页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "首頁" + } + } + } + }, + "detail.diagnostics.buttonExecute": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Run" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécuter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "执行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行" + } + } + } + }, + "detail.diagnostics.cleanup.desc": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة التنزيلات القديمة والذاكرات المؤقتة." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove old downloads and caches." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elimina descargas antiguas y cachés." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprime les anciens téléchargements et caches." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "古いダウンロードとキャッシュを削除します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remove downloads antigos e caches." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除旧版本下载和缓存。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除舊版本下載與快取。" + } + } + } + }, + "detail.diagnostics.cleanup.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف الذاكرة المؤقتة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean Cache" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar caché" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer le cache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャッシュをクリーンアップ" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar cache" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理缓存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理快取" + } + } + } + }, + "detail.diagnostics.doctor.desc": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فحص متغيرات البيئة والأذونات ومسارات البناء." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check environment variables, permissions, and build paths." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprueba variables de entorno, permisos y rutas de compilación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifie les variables d’environnement, les autorisations et les chemins de build." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "環境変数、権限、ビルドパスを確認します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verifica variáveis de ambiente, permissões e caminhos de build." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查环境变量、权限和构建路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查環境變數、權限與建置路徑。" + } + } + } + }, + "detail.diagnostics.doctor.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل التشخيص" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Run Diagnostics" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutar diagnóstico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lancer le diagnostic" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "診断を実行" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executar diagnóstico" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行诊断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行診斷" + } + } + } + }, + "detail.diagnostics.maintenance.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الصيانة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Maintenance" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantenimiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Maintenance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メンテナンス" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manutenção" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "维护操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "維護操作" + } + } + } + }, + "detail.diagnostics.pathApply": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطبيق" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appliquer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "適用" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aplicar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "套用" + } + } + } + }, + "detail.diagnostics.pathHelpTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مواقع التثبيت الشائعة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Common install locations" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ubicaciones comunes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Emplacements courants" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一般的なインストール場所" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Locais comuns de instalação" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "常见安装位置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "常見安裝位置" + } + } + } + }, + "detail.diagnostics.pathNotFoundDesc": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ثبّت Homebrew أو اختر مسار ملف brew التنفيذي يدويًا." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Homebrew or choose the brew executable path manually." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instala Homebrew o elige manualmente la ruta del ejecutable brew." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installez Homebrew ou choisissez manuellement le chemin de l’exécutable brew." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew をインストールするか、brew 実行ファイルのパスを手動で指定してください。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instale o Homebrew ou escolha manualmente o caminho do executável brew." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请确认已安装 Homebrew,或手动指定 brew 可执行文件路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請確認已安裝 Homebrew,或手動指定 brew 可執行檔路徑。" + } + } + } + }, + "detail.diagnostics.pathNotFoundTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على Homebrew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Homebrew Not Found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró Homebrew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Homebrew introuvable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew が見つかりません" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Homebrew não encontrado" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到 Homebrew" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未偵測到 Homebrew" + } + } + } + }, + "detail.diagnostics.pathPlaceholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مثال /opt/homebrew/bin/brew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "For example /opt/homebrew/bin/brew" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Por ejemplo /opt/homebrew/bin/brew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Par exemple /opt/homebrew/bin/brew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "例: /opt/homebrew/bin/brew" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Por exemplo /opt/homebrew/bin/brew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "例如 /opt/homebrew/bin/brew" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "例如 /opt/homebrew/bin/brew" + } + } + } + }, + "detail.diagnostics.pathTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسار brew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "brew Path" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ruta de brew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chemin brew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew パス" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Caminho do brew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "brew 路径" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "brew 路徑" + } + } + } + }, + "detail.diagnostics.update.desc": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مزامنة قوائم formula و cask في Homebrew." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sync Homebrew formula and cask lists." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sincroniza las listas de formula y cask de Homebrew." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Synchronise les listes formula et cask de Homebrew." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew の formula と cask 一覧を同期します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sincroniza as listas de formula e cask do Homebrew." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "同步 Homebrew formula 和 cask 列表。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "同步 Homebrew formula 與 cask 清單。" + } + } + } + }, + "detail.diagnostics.update.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث المصادر" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Update Taps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar taps" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à jour les taps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap を更新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar taps" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新软件源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新軟體源" + } + } + } + }, + "detail.diagnostics.upgrade.desc": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية الحزم القديمة المكتشفة." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade currently detected outdated packages." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualiza los paquetes obsoletos detectados." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Met à jour les paquets obsolètes détectés." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出された古いパッケージを更新します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualiza os pacotes desatualizados detectados." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新当前检测到的过期包。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新目前偵測到的過期套件。" + } + } + } + }, + "detail.diagnostics.upgrade.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية الحزم" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade Packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar paquetes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à jour les paquets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージを更新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar pacotes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新所有包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新所有套件" + } + } + } + }, + "detail.filter.all": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الكل" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tudo" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部" + } + } + } + }, + "detail.filter.cask": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cask" + } + } + } + }, + "detail.filter.formula": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Formula" + } + } + } + }, + "detail.refresh.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理" + } + } + } + }, + "detail.refresh.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث حالة Homebrew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh Homebrew status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar estado de Homebrew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser l’état Homebrew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew の状態を更新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar status do Homebrew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新 Homebrew 状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新整理 Homebrew 狀態" + } + } + } + }, + "detail.search.button": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + } + } + }, + "detail.search.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل كلمة مفتاحية للبحث عن الحزم" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a keyword to search packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce una palabra clave para buscar paquetes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un mot-clé pour rechercher des paquets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーワードを入力してパッケージを検索" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Digite uma palavra-chave para pesquisar pacotes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "输入关键词搜索软件包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "輸入關鍵字搜尋套件" + } + } + } + }, + "detail.search.fetchingDetail": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل تفاصيل الحزمة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading package details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando detalles del paquete" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement des détails du paquet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージ詳細を読み込み中" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregando detalhes do pacote" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载包详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入套件詳情" + } + } + } + }, + "detail.search.hasUpdate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Update" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新可" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualização" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可更新" + } + } + } + }, + "detail.search.installAction": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝" + } + } + } + }, + "detail.search.installAgainAction": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت مجددًا" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Again" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalar de nuevo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinstaller" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再インストール" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalar novamente" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重新安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新安裝" + } + } + } + }, + "detail.search.installedHint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذه الحزمة مثبتة بالفعل." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This package is already installed." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este paquete ya está instalado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce paquet est déjà installé." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このパッケージはすでにインストールされています。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Este pacote já está instalado." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此包已安装。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此套件已安裝。" + } + } + } + }, + "detail.search.loadingInstalled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل حالة Homebrew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading Homebrew status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando estado de Homebrew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement de l’état Homebrew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew の状態を読み込み中" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregando status do Homebrew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载 Homebrew 状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入 Homebrew 狀態" + } + } + } + }, + "detail.search.noMatch": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد حزم مثبتة مطابقة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching installed packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay paquetes instalados coincidentes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun paquet installé correspondant" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するインストール済みパッケージはありません" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum pacote instalado correspondente" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配的已安装包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有符合的已安裝套件" + } + } + } + }, + "detail.search.notLoaded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تحميل الحزم المثبتة بعد" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed packages not loaded yet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los paquetes instalados aún no se han cargado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les paquets installés ne sont pas encore chargés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済みパッケージは未読み込みです" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pacotes instalados ainda não carregados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未加载已安装包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未載入已安裝套件" + } + } + } + }, + "detail.search.onlinePlaceholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث عن حزم" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Search packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar paquetes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des paquets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージを検索" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar pacotes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索软件包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋套件" + } + } + } + }, + "detail.search.placeholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث في الحزم المثبتة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Search installed packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar paquetes instalados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher les paquets installés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済みパッケージを検索" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar pacotes instalados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索已安装包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋已安裝套件" + } + } + } + }, + "detail.search.searching": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ البحث في Homebrew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Searching Homebrew" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscando en Homebrew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Recherche dans Homebrew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew を検索中" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisando no Homebrew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在搜索 Homebrew" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在搜尋 Homebrew" + } + } + } + }, + "detail.status.brewPathMissing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "brew غير مهيأ" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "brew not configured" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "brew sin configurar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "brew non configuré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew 未設定" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "brew não configurado" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未配置 brew" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未設定 brew" + } + } + } + }, + "detail.status.installed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مثبت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済み" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已安裝" + } + } + } + }, + "detail.status.outdated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحاجة لتحديث" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Outdated" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactualizados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À mettre à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新あり" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desatualizados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可更新" + } + } + } + }, + "detail.status.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جاري فحص الحزم والتحديثات..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning packages and updates..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscando paquetes y actualizaciones..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse des paquets et des mises à jour..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージとアップデートをスキャン中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Buscando pacotes e atualizações..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描包与更新..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描包與更新..." + } + } + } + }, + "detail.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة الحزم والتطبيقات والمستودعات بصريًا." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage packages, apps, and repositories visually." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestiona paquetes, apps y repositorios visualmente." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérez visuellement les paquets, apps et dépôts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージ、アプリ、リポジトリを視覚的に管理します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerencie pacotes, apps e repositórios visualmente." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可视化管理包、应用与软件源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "視覺化管理套件、App 與軟體源" + } + } + } + }, + "detail.tabs.diagnostics": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التشخيص" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Diagnóstico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "診断" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Diagnóstico" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "诊断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "診斷" + } + } + } + }, + "detail.tabs.installed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مثبت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済み" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已安裝" + } + } + } + }, + "detail.tabs.picker": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "العرض" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Vista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vue" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Visualização" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "视图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢視" + } + } + } + }, + "detail.tabs.search": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بحث" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋" + } + } + } + }, + "detail.tabs.taps": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المصادر" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Taps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Taps" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taps" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Taps" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "软件源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "軟體源" + } + } + } + }, + "detail.taps.buttonAdd": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入" + } + } + } + }, + "detail.taps.buttonUntap": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除" + } + } + } + }, + "detail.taps.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد مصادر" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No taps yet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay taps" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun tap" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap はありません" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum tap" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无软件源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無軟體源" + } + } + } + }, + "detail.taps.placeholderAdd": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "user/repo أو رابط المستودع" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "user/repo or repository URL" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "user/repo o URL del repositorio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "user/repo ou URL du dépôt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "user/repo またはリポジトリ URL" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "user/repo ou URL do repositório" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "user/repo 或仓库 URL" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "user/repo 或儲存庫 URL" + } + } + } + }, + "detail.taps.titleAdd": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة مصدر" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Tap" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregar tap" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un tap" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap を追加" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar tap" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加软件源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加入軟體源" + } + } + } + }, + "detail.taps.titleList": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المصادر المفعلة (%d)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enabled Taps (%d)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Taps habilitados (%d)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taps activés (%d)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効な Tap(%d)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Taps ativados (%d)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已启用软件源(%d)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已啟用軟體源(%d)" + } + } + } + }, + "detail.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدير Homebrew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Homebrew Manager" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestor de Homebrew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gestionnaire Homebrew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 管理" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciador do Homebrew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 管理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 管理" + } + } + } + }, + "log.command.cancelled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] ألغى المستخدم العملية" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Operation cancelled by user" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Operación cancelada por el usuario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Opération annulée par l’utilisateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] ユーザーが操作をキャンセルしました" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Operação cancelada pelo usuário" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作已被用户取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作已由使用者取消" + } + } + } + }, + "log.command.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] اكتملت العملية" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Operation completed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Operación completada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Opération terminée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作が完了しました" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Operação concluída" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作成功完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作成功完成" + } + } + } + }, + "log.command.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] فشل الأمر: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Command failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Error al ejecutar comando: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Échec de la commande : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] コマンドに失敗しました: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Falha ao executar comando: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 执行命令出错:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 執行命令出錯:%@" + } + } + } + }, + "log.command.failedStatus": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] فشلت العملية، الرمز: %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Operation failed, exit code: %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Operación fallida, código: %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Opération échouée, code : %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作に失敗しました。終了コード: %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Operação falhou, código: %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作失败,错误码:%d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 操作失敗,錯誤碼:%d" + } + } + } + }, + "log.details.loaded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] تم تحميل بيانات الحزمة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Package metadata loaded" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Metadatos del paquete cargados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Métadonnées du paquet chargées" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] パッケージメタデータを読み込みました" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Metadados do pacote carregados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 包详细元数据加载完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 套件詳細中繼資料載入完成" + } + } + } + }, + "log.details.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] جارٍ تحميل بيانات الحزمة..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Loading package metadata..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Cargando metadatos del paquete..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Chargement des métadonnées du paquet..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] パッケージメタデータを読み込み中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Carregando metadados do pacote..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在加载包详细元数据..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在載入套件詳細中繼資料..." + } + } + } + }, + "log.details.parseFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] فشل تحليل بيانات الحزمة: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Failed to parse package metadata: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Error al analizar metadatos: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Échec de l’analyse des métadonnées : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] パッケージメタデータの解析に失敗しました: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Falha ao analisar metadados: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 解析详细元数据失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 解析詳細中繼資料失敗:%@" + } + } + } + }, + "log.installed.loaded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] تم تحميل %d حزمة مثبتة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Loaded %d installed packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] %d paquetes instalados cargados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] %d paquets installés chargés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] %d 個のインストール済みパッケージを読み込みました" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] %d pacotes instalados carregados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 已加载 %d 个已安装包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 已載入 %d 個已安裝套件" + } + } + } + }, + "log.installed.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] جارٍ تحميل الحزم المثبتة..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Loading installed packages..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Cargando paquetes instalados..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Chargement des paquets installés..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] インストール済みパッケージを読み込み中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Carregando pacotes instalados..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在获取已安装包列表..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在取得已安裝套件清單..." + } + } + } + }, + "log.outdated.loaded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] تم العثور على %d حزمة قديمة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Found %d outdated packages" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Se encontraron %d paquetes desactualizados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] %d paquets obsolètes trouvés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] %d 個の古いパッケージが見つかりました" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] %d pacotes desatualizados encontrados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 获取到 %d 个待更新包" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 取得 %d 個待更新套件" + } + } + } + }, + "log.outdated.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] جارٍ تحميل الحزم القديمة..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Loading outdated packages..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Cargando paquetes desactualizados..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Chargement des paquets obsolètes..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] 古いパッケージを読み込み中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Carregando pacotes desatualizados..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在获取待更新包列表..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在取得待更新套件清單..." + } + } + } + }, + "log.outdated.parseFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] فشل تحليل الحزم القديمة: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Failed to parse outdated packages: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Error al analizar paquetes desactualizados: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Échec de l’analyse des paquets obsolètes : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] 古いパッケージの解析に失敗しました: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Falha ao analisar pacotes desatualizados: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 解析待更新包失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 解析待更新套件失敗:%@" + } + } + } + }, + "log.path.invalid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] مسار Homebrew غير صالح. اختر ملف brew قابلًا للتنفيذ." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Invalid Homebrew path. Choose an executable brew file." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Ruta de Homebrew no válida. Elige un archivo brew ejecutable." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Chemin Homebrew invalide. Choisissez un fichier brew exécutable." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] Homebrew パスが無効です。実行可能な brew ファイルを選択してください。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Caminho do Homebrew inválido. Escolha um arquivo brew executável." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] Homebrew 路径无效,请选择可执行的 brew 文件。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] Homebrew 路徑無效,請選擇可執行的 brew 檔案。" + } + } + } + }, + "log.path.updated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] تم تحديث مسار Homebrew." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Homebrew path updated." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Ruta de Homebrew actualizada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Chemin Homebrew mis à jour." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] Homebrew パスを更新しました。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Caminho do Homebrew atualizado." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 已更新 Homebrew 路径。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 已更新 Homebrew 路徑。" + } + } + } + }, + "log.scan.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] اكتمل الفحص." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Scan completed." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Escaneo completado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Analyse terminée." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] スキャンが完了しました。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Verificação concluída." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 扫描完成。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 掃描完成。" + } + } + } + }, + "log.scan.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] فشل الفحص: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Scan failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Error al escanear: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Échec de l’analyse : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] スキャンに失敗しました: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Falha na verificação: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 扫描发生错误:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 掃描發生錯誤:%@" + } + } + } + }, + "log.scan.started": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] بدأ فحص حالة Homebrew..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Started scanning Homebrew status..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Se inició el escaneo del estado de Homebrew..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Analyse de l’état Homebrew démarrée..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] Homebrew の状態スキャンを開始しました..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Verificação do status do Homebrew iniciada..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 开始扫描 Homebrew 状态..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 開始掃描 Homebrew 狀態..." + } + } + } + }, + "log.taps.loaded": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] تم تحميل %d مصدر" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Loaded %d taps" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] %d taps cargados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] %d taps chargés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] %d 個の tap を読み込みました" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] %d taps carregados" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 获取到 %d 个软件源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 取得 %d 個軟體源" + } + } + } + }, + "log.taps.loading": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "[System] جارٍ تحميل المصادر المفعلة..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "[System] Loading enabled taps..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "[System] Cargando taps habilitados..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "[System] Chargement des taps activés..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[System] 有効な tap を読み込み中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "[System] Carregando taps ativados..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在获取已启用的软件源..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "[System] 正在取得已啟用的軟體源..." + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة حزم Homebrew والمستودعات والتشخيصات." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage Homebrew packages, repositories, and diagnostics." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestiona paquetes, repositorios y diagnósticos de Homebrew." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérez les paquets, dépôts et diagnostics Homebrew." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew パッケージ、リポジトリ、診断を管理します。" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerencie pacotes, repositórios e diagnósticos do Homebrew." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理 Homebrew 包、软件源与诊断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理 Homebrew 套件、軟體源與診斷" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Homebrew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Homebrew" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Homebrew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Homebrew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 管理" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Homebrew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 管理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 管理" + } + } + } + }, + "operation.cleanup": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تنظيف ذاكرة Homebrew المؤقتة..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleaning Homebrew cache..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiando caché de Homebrew..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyage du cache Homebrew..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew キャッシュをクリーンアップ中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpando cache do Homebrew..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在清理 Homebrew 缓存..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在清理 Homebrew 快取..." + } + } + } + }, + "operation.doctor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تشغيل تشخيص Homebrew..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running Homebrew diagnostics..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutando diagnóstico de Homebrew..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécution du diagnostic Homebrew..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 診断を実行中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executando diagnóstico do Homebrew..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在运行 Homebrew 诊断..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在執行 Homebrew 診斷..." + } + } + } + }, + "operation.install": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تثبيت %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Installing %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Instalando %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Installation de %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ をインストール中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Instalando %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在安装 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在安裝 %@..." + } + } + } + }, + "operation.pin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تثبيت إصدار %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinning %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijando %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épinglage de %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を固定中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fixando %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在锁定 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在鎖定 %@..." + } + } + } + }, + "operation.scanAll": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ فحص الحزم والتحديثات..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning packages and updates..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneando paquetes y actualizaciones..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse des paquets et mises à jour..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージと更新をスキャン中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando pacotes e atualizações..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描包与更新..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描套件與更新..." + } + } + } + }, + "operation.tap": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إضافة المصدر %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adding tap %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agregando tap %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajout du tap %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap %@ を追加中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionando tap %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在启用软件源 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在啟用軟體源 %@..." + } + } + } + }, + "operation.uninstall": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إزالة %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uninstalling %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desinstalando %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désinstallation de %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ をアンインストール中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desinstalando %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在卸载 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在解除安裝 %@..." + } + } + } + }, + "operation.unpin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إلغاء تثبيت إصدار %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpinning %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Dejando de fijar %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désépinglage de %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の固定を解除中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desfixando %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在解锁 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在解除鎖定 %@..." + } + } + } + }, + "operation.untap": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إزالة المصدر %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Removing tap %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminando tap %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retrait du tap %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tap %@ を削除中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Removendo tap %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在移除软件源 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在移除軟體源 %@..." + } + } + } + }, + "operation.updateBrew": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحديث مصادر Homebrew..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Updating Homebrew sources..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizando fuentes de Homebrew..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour des sources Homebrew..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew ソースを更新中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizando fontes do Homebrew..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在更新 Homebrew 源..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在更新 Homebrew 軟體源..." + } + } + } + }, + "operation.upgrade": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ ترقية %@..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrading %@..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizando %@..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour de %@..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ をアップグレード中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizando %@..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在更新 %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在更新 %@..." + } + } + } + }, + "operation.upgradeAll": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ ترقية كل الحزم..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrading all packages..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizando todos los paquetes..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour de tous les paquets..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのパッケージを更新中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizando todos os pacotes..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在更新所有包..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在更新所有套件..." + } + } + } + }, + "panel.action.cleanup": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف الذاكرة المؤقتة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup Cache" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar caché" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer le cache" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャッシュのクリーンアップ" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar cache" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理缓存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理快取" + } + } + } + }, + "panel.action.manage": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestionar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理" + } + } + } + }, + "panel.action.scan": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من وجود تحديثات" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check for Updates" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar actualizaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher des mises à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップデートを確認" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificar atualizações" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检查更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢查更新" + } + } + } + }, + "panel.action.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جاري الفحص..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneando..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escaneando..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描..." + } + } + } + }, + "panel.action.stop": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "panel.action.upgradeAll": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترقية الكل" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade All" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout mettre à jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一括アップデート" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar tudo" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一键升级" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一鍵升級" + } + } + } + }, + "panel.error.brewPathRequired": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يجب إعداد مسار brew" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Configure the brew path" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configura la ruta de brew" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurez le chemin brew" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "brew パスの設定が必要です" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Configure o caminho do brew" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要配置 brew 路径" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要設定 brew 路徑" + } + } + } + }, + "panel.subtitle.manage": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إدارة الحزم والمصادر والتشخيصات" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage packages, taps, and diagnostics" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Gestionar paquetes, taps y diagnósticos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérer les paquets, taps et diagnostics" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パッケージ、tap、診断を管理" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciar pacotes, taps e diagnósticos" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "管理包、软件源与诊断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "管理套件、軟體源與診斷" + } + } + } + }, + "panel.subtitle.notInstalled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Homebrew غير مثبت" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Homebrew not installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Homebrew no instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Homebrew non installé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Homebrew がインストールされていません" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Homebrew não instalado" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 未安装" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Homebrew 未安裝" + } + } + } + }, + "panel.subtitle.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جاري الفحص..." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneando..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse en cours..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン中..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escaneando..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描..." + } + } + } + }, + "panel.subtitle.upToDate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محدث بالكامل" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Up to date" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "À jour" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて最新" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizado" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一切最新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一切最新" + } + } + } + }, + "panel.subtitle.upgradable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d تحديثات متاحة" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d updates available" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d actualizaciones disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d mises à jour disponibles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のアップデートが利用可能" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d atualizações disponíveis" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个包可升级" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個包可升級" + } + } + } + } + } +} diff --git a/Plugins/Homebrew/Sources/HomebrewCommandRunner.swift b/Plugins/Homebrew/Sources/HomebrewCommandRunner.swift new file mode 100644 index 0000000..3fd6145 --- /dev/null +++ b/Plugins/Homebrew/Sources/HomebrewCommandRunner.swift @@ -0,0 +1,214 @@ +import Foundation + +public enum HomebrewExecutableValidator { + public static let standardPaths = [ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + "/opt/workbrew/bin/brew" + ] + + public static func validatedPath( + for rawPath: String, + fileManager: FileManager = .default + ) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var candidate = (trimmed as NSString).expandingTildeInPath + if (candidate as NSString).lastPathComponent != "brew" { + candidate = (candidate as NSString).appendingPathComponent("brew") + } + + let standardized = URL(fileURLWithPath: candidate).standardizedFileURL + guard isValidBrewExecutable(at: standardized.path, fileManager: fileManager) else { + return nil + } + return standardized.path + } + + public static func isValidBrewExecutable( + at path: String, + fileManager: FileManager = .default + ) -> Bool { + let url = URL(fileURLWithPath: path).standardizedFileURL + guard url.lastPathComponent == "brew", + url.deletingLastPathComponent().lastPathComponent == "bin" else { + return false + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue, + fileManager.isExecutableFile(atPath: url.path) else { + return false + } + + let resolvedURL = url.resolvingSymlinksInPath().standardizedFileURL + var resolvedIsDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: resolvedURL.path, isDirectory: &resolvedIsDirectory), + !resolvedIsDirectory.boolValue, + fileManager.isExecutableFile(atPath: resolvedURL.path) else { + return false + } + + return looksLikeHomebrewExecutable(at: resolvedURL) + || looksLikeHomebrewExecutable(at: url) + } + + private static func looksLikeHomebrewExecutable(at url: URL) -> Bool { + guard let fileHandle = try? FileHandle(forReadingFrom: url) else { + return false + } + defer { try? fileHandle.close() } + + guard let data = try? fileHandle.read(upToCount: 16_384), + let sample = String(data: data, encoding: .utf8) else { + return false + } + + return sample.contains("HOMEBREW") + || sample.contains("Homebrew") + || sample.contains("brew.rb") + } +} + +public protocol HomebrewCommandRunning: Sendable { + func run( + executable: String, + arguments: [String], + onOutput: @escaping @MainActor (String) -> Void, + onError: @escaping @MainActor (String) -> Void + ) async throws -> Int32 + + func cancel() async +} + +public actor HomebrewCommandRunner: HomebrewCommandRunning { + private var activeProcess: Process? + + public init() {} + + /// Runs a command asynchronously, streaming output and error chunks to the provided handlers. + /// Returns the termination status (exit code). + public func run( + executable: String, + arguments: [String], + onOutput: @escaping @MainActor (String) -> Void, + onError: @escaping @MainActor (String) -> Void + ) async throws -> Int32 { + if activeProcess != nil { + throw NSError( + domain: "HomebrewCommandRunner", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "A process is already running."] + ) + } + + let process = Process() + guard let validatedExecutable = HomebrewExecutableValidator.validatedPath(for: executable) else { + throw NSError( + domain: "HomebrewCommandRunner", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "The configured Homebrew executable is invalid."] + ) + } + + process.executableURL = URL(fileURLWithPath: validatedExecutable) + + // Setup environment path, ensuring homebrew binaries are visible + var env = ProcessInfo.processInfo.environment + let path = env["PATH"] ?? "" + let brewPaths = ["/opt/homebrew/bin", "/usr/local/bin"] + var pathComponents = path.components(separatedBy: ":") + for p in brewPaths { + if !pathComponents.contains(p) { + pathComponents.insert(p, at: 0) + } + } + env["PATH"] = pathComponents.joined(separator: ":") + env["HOMEBREW_NO_EMOJI"] = "1" + env["HOMEBREW_NO_AUTO_UPDATE"] = "1" + process.environment = env + process.arguments = arguments + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + self.activeProcess = process + + let group = DispatchGroup() + + // Attach readability handlers to stream output in real-time + outputPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + group.enter() + Task { @MainActor in + onOutput(string) + group.leave() + } + } + } + + errorPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + group.enter() + Task { @MainActor in + onError(string) + group.leave() + } + } + } + + return try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { [weak self] proc in + // Clear readability handlers to prevent resource leaks + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + + Task { [weak self] in + guard let self = self else { return } + + // Wait for all pending UI output tasks to complete + await withCheckedContinuation { (waitContinuation: CheckedContinuation) in + group.notify(queue: .global()) { + waitContinuation.resume() + } + } + + await self.clearProcess(for: proc) + continuation.resume(returning: proc.terminationStatus) + } + } + + do { + try process.run() + } catch { + outputPipe.fileHandleForReading.readabilityHandler = nil + errorPipe.fileHandleForReading.readabilityHandler = nil + Task { [weak self] in + guard let self = self else { return } + await self.clearProcess(for: process) + } + continuation.resume(throwing: error) + } + } + } + + public func cancel() { + if let process = activeProcess, process.isRunning { + process.terminate() + } + } + + private func clearProcess(for process: Process) { + if self.activeProcess === process { + self.activeProcess = nil + } + } +} diff --git a/Plugins/Homebrew/Sources/HomebrewController.swift b/Plugins/Homebrew/Sources/HomebrewController.swift new file mode 100644 index 0000000..191120e --- /dev/null +++ b/Plugins/Homebrew/Sources/HomebrewController.swift @@ -0,0 +1,686 @@ +import Foundation +import Combine +import SwiftUI +import MacToolsPluginKit + +@MainActor +public final class HomebrewController: ObservableObject { + @Published public var isBrewAvailable = false + @Published public var brewPath = "" + + @Published public var installedPackages: [BrewPackage] = [] + @Published public var outdatedPackages: [BrewPackage] = [] + @Published public var taps: [BrewTap] = [] + + // Search states + @Published public var searchQuery = "" + @Published public var searchResults: [BrewPackage] = [] + @Published public var isSearching = false + + // Command & console states + @Published public var logs: [BrewCommandLog] = [] + @Published public var isBusy = false + @Published public var currentOperationName = "" + + public var onStateChange: (() -> Void)? + + let runner: any HomebrewCommandRunning + private let localization: PluginLocalization + + public init( + runner: any HomebrewCommandRunning = HomebrewCommandRunner(), + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.runner = runner + self.localization = localization + + if let customPath = UserDefaults.standard.string(forKey: "mactools.homebrew.customPath"), + !customPath.isEmpty { + if let validatedPath = HomebrewExecutableValidator.validatedPath(for: customPath) { + self.brewPath = validatedPath + self.isBrewAvailable = true + return + } + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + } + + if let path = discoverBrewPath() { + self.brewPath = path + self.isBrewAvailable = true + } + } + + public func updateCustomPath(_ path: String) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + if let path = discoverBrewPath() { + self.brewPath = path + self.isBrewAvailable = true + } else { + self.brewPath = "" + self.isBrewAvailable = false + } + onStateChange?() + return + } + + guard let validatedPath = HomebrewExecutableValidator.validatedPath(for: trimmed) else { + appendLog(localization.string( + "log.path.invalid", + defaultValue: "[System] Homebrew 路径无效,请选择可执行的 brew 文件。" + ), isError: true) + onStateChange?() + return + } + + UserDefaults.standard.set(validatedPath, forKey: "mactools.homebrew.customPath") + self.brewPath = validatedPath + self.isBrewAvailable = true + appendLog(localization.string("log.path.updated", defaultValue: "[System] 已更新 Homebrew 路径。")) + onStateChange?() + } + + // MARK: - Path Discovery + + private func discoverBrewPath() -> String? { + for path in HomebrewExecutableValidator.standardPaths { + if let validatedPath = HomebrewExecutableValidator.validatedPath(for: path) { + return validatedPath + } + } + + // Check current ProcessInfo PATH + if let envPath = ProcessInfo.processInfo.environment["PATH"] { + let dirs = envPath.components(separatedBy: ":") + for dir in dirs { + let path = (dir as NSString).appendingPathComponent("brew") + if let validatedPath = HomebrewExecutableValidator.validatedPath(for: path) { + return validatedPath + } + } + } + return nil + } + + // MARK: - Logs Management + + public func clearLogs() { + logs.removeAll() + } + + private func appendLog(_ text: String, isError: Bool = false) { + let lines = text.components(separatedBy: .newlines) + for line in lines { + let trimmed = line.trimmingCharacters(in: .newlines) + if !trimmed.isEmpty { + logs.append(BrewCommandLog(text: trimmed, isError: isError)) + } + } + if logs.count > 1000 { + logs.removeFirst(logs.count - 1000) + } + } + + // MARK: - Scanning Packages + + public func scanAll() { + guard isBrewAvailable, !isBusy else { return } + isBusy = true + currentOperationName = localization.string("operation.scanAll", defaultValue: "正在扫描包与更新...") + clearLogs() + appendLog(localization.string("log.scan.started", defaultValue: "[System] 开始扫描 Homebrew 状态...")) + + Task { + do { + // 1. Load active Taps + try await loadTaps() + + // 2. Load installed packages (fast list first) + try await loadInstalledPackagesFast() + + // 3. Load outdated packages + try await loadOutdatedPackages() + + // 4. Load full package details in background (desc, homepage, etc.) + try await loadPackageDetails() + + appendLog(localization.string("log.scan.completed", defaultValue: "[System] 扫描完成。")) + } catch { + appendLog(localization.format( + "log.scan.failed", + defaultValue: "[System] 扫描发生错误:%@", + error.localizedDescription + ), isError: true) + } + + isBusy = false + currentOperationName = "" + onStateChange?() + } + } + + private func loadTaps() async throws { + appendLog(localization.string("log.taps.loading", defaultValue: "[System] 正在获取已启用的软件源...")) + let tempRunner = self.runner + var outputLines: [String] = [] + + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["tap"], + onOutput: { text in + outputLines.append(text) + }, + onError: { _ in } + ) + + let rawOutput = outputLines.joined() + let tapNames = rawOutput.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + taps = tapNames.map { BrewTap(name: $0) } + appendLog(localization.format("log.taps.loaded", defaultValue: "[System] 获取到 %d 个软件源", taps.count)) + } + + private func loadInstalledPackagesFast() async throws { + appendLog(localization.string("log.installed.loading", defaultValue: "[System] 正在获取已安装包列表...")) + let tempRunner = self.runner + + var formulaOutput = "" + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["list", "--formula", "--versions"], + onOutput: { formulaOutput += $0 }, + onError: { _ in } + ) + + var caskOutput = "" + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["list", "--cask", "--versions"], + onOutput: { caskOutput += $0 }, + onError: { _ in } + ) + + var tempPackages: [BrewPackage] = [] + + // Parse Formulae + for line in formulaOutput.components(separatedBy: .newlines) { + let parts = line.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.count >= 2 else { continue } + let name = parts[0] + let version = parts[1] + tempPackages.append(BrewPackage( + name: name, + version: version, + latestVersion: version, + isCask: false, + desc: "", + homepage: "", + isOutdated: false, + isPinned: false + )) + } + + // Parse Casks + for line in caskOutput.components(separatedBy: .newlines) { + let parts = line.split(separator: " ", maxSplits: 1).map(String.init) + guard parts.count >= 2 else { continue } + let name = parts[0] + let version = parts[1] + tempPackages.append(BrewPackage( + name: name, + version: version, + latestVersion: version, + isCask: true, + desc: "", + homepage: "", + isOutdated: false, + isPinned: false + )) + } + + self.installedPackages = tempPackages.sorted { $0.name.lowercased() < $1.name.lowercased() } + appendLog(localization.format("log.installed.loaded", defaultValue: "[System] 已加载 %d 个已安装包", installedPackages.count)) + } + + private func loadOutdatedPackages() async throws { + appendLog(localization.string("log.outdated.loading", defaultValue: "[System] 正在获取待更新包列表...")) + let tempRunner = self.runner + var jsonOutput = "" + + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["outdated", "--json=v2"], + onOutput: { jsonOutput += $0 }, + onError: { _ in } + ) + + guard let data = jsonOutput.data(using: .utf8) else { return } + + struct OutdatedResponse: Codable { + let formulae: [OutdatedFormulaInfo] + let casks: [OutdatedCaskInfo] + } + struct OutdatedFormulaInfo: Codable { + let name: String + let installed_versions: [String] + let current_version: String + let pinned: Bool + } + struct OutdatedCaskInfo: Codable { + let name: String + let installed_versions: [String] + let current_version: String + } + + do { + let response = try JSONDecoder().decode(OutdatedResponse.self, from: data) + + var tempOutdated: [BrewPackage] = [] + var outdatedNames = Set() + var latestVersions: [String: String] = [:] + var pinnedStatus: [String: Bool] = [:] + + for item in response.formulae { + outdatedNames.insert(item.name) + latestVersions[item.name] = item.current_version + pinnedStatus[item.name] = item.pinned + + tempOutdated.append(BrewPackage( + name: item.name, + version: item.installed_versions.first ?? "", + latestVersion: item.current_version, + isCask: false, + desc: "", + homepage: "", + isOutdated: true, + isPinned: item.pinned + )) + } + + for item in response.casks { + outdatedNames.insert(item.name) + latestVersions[item.name] = item.current_version + + tempOutdated.append(BrewPackage( + name: item.name, + version: item.installed_versions.first ?? "", + latestVersion: item.current_version, + isCask: true, + desc: "", + homepage: "", + isOutdated: true, + isPinned: false + )) + } + + self.outdatedPackages = tempOutdated.sorted { $0.name.lowercased() < $1.name.lowercased() } + + // Update outdated flag and latestVersion in installedPackages + self.installedPackages = self.installedPackages.map { pkg in + let isOutdated = outdatedNames.contains(pkg.name) + let latestVer = latestVersions[pkg.name] ?? pkg.version + let isPinned = pinnedStatus[pkg.name] ?? pkg.isPinned + return BrewPackage( + name: pkg.name, + version: pkg.version, + latestVersion: latestVer, + isCask: pkg.isCask, + desc: pkg.desc, + homepage: pkg.homepage, + isOutdated: isOutdated, + isPinned: isPinned, + dependencies: pkg.dependencies + ) + } + + appendLog(localization.format("log.outdated.loaded", defaultValue: "[System] 获取到 %d 个待更新包", outdatedPackages.count)) + } catch { + appendLog(localization.format( + "log.outdated.parseFailed", + defaultValue: "[System] 解析待更新包失败:%@", + error.localizedDescription + ), isError: true) + } + } + + private func loadPackageDetails() async throws { + appendLog(localization.string("log.details.loading", defaultValue: "[System] 正在加载包详细元数据...")) + let tempRunner = self.runner + var jsonOutput = "" + + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["info", "--json=v2", "--installed"], + onOutput: { jsonOutput += $0 }, + onError: { _ in } + ) + + guard let data = jsonOutput.data(using: .utf8) else { return } + + struct BrewInfoResponse: Codable { + let formulae: [FormulaInfo] + let casks: [CaskInfo] + } + struct FormulaInfo: Codable { + let name: String + let desc: String? + let homepage: String? + let dependencies: [String]? + } + struct CaskInfo: Codable { + let token: String + let name: [String]? + let desc: String? + let homepage: String? + } + + do { + let response = try JSONDecoder().decode(BrewInfoResponse.self, from: data) + + var details: [String: (desc: String, homepage: String, deps: [String])] = [:] + for item in response.formulae { + details[item.name] = ( + desc: item.desc ?? "", + homepage: item.homepage ?? "", + deps: item.dependencies ?? [] + ) + } + for item in response.casks { + details[item.token] = ( + desc: item.desc ?? (item.name?.first ?? ""), + homepage: item.homepage ?? "", + deps: [] + ) + } + + // Map details back to installedPackages and outdatedPackages + self.installedPackages = self.installedPackages.map { pkg in + if let detail = details[pkg.name] { + return BrewPackage( + name: pkg.name, + version: pkg.version, + latestVersion: pkg.latestVersion, + isCask: pkg.isCask, + desc: detail.desc, + homepage: detail.homepage, + isOutdated: pkg.isOutdated, + isPinned: pkg.isPinned, + dependencies: detail.deps + ) + } + return pkg + } + + self.outdatedPackages = self.outdatedPackages.map { pkg in + if let detail = details[pkg.name] { + return BrewPackage( + name: pkg.name, + version: pkg.version, + latestVersion: pkg.latestVersion, + isCask: pkg.isCask, + desc: detail.desc, + homepage: detail.homepage, + isOutdated: pkg.isOutdated, + isPinned: pkg.isPinned, + dependencies: detail.deps + ) + } + return pkg + } + + appendLog(localization.string("log.details.loaded", defaultValue: "[System] 包详细元数据加载完成")) + } catch { + appendLog(localization.format( + "log.details.parseFailed", + defaultValue: "[System] 解析详细元数据失败:%@", + error.localizedDescription + ), isError: true) + } + } + + // MARK: - Search + + public func search(query: String) { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + searchResults.removeAll() + return + } + guard !isSearching else { return } + guard searchQuery != trimmed || searchResults.isEmpty else { return } + + searchQuery = trimmed + isSearching = true + searchResults.removeAll() + + Task { + do { + let tempRunner = self.runner + + // Search formulae + var formulaOutput = "" + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["search", "--formula", trimmed], + onOutput: { formulaOutput += $0 }, + onError: { _ in } + ) + + // Search casks + var caskOutput = "" + _ = try await tempRunner.run( + executable: brewPath, + arguments: ["search", "--cask", trimmed], + onOutput: { caskOutput += $0 }, + onError: { _ in } + ) + + var results: [BrewPackage] = [] + + let formulae = formulaOutput.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + let casks = caskOutput.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for f in formulae { + results.append(BrewPackage( + name: f, + version: "", + latestVersion: "", + isCask: false, + desc: "", + homepage: "", + isOutdated: false, + isPinned: false + )) + } + + for c in casks { + results.append(BrewPackage( + name: c, + version: "", + latestVersion: "", + isCask: true, + desc: "", + homepage: "", + isOutdated: false, + isPinned: false + )) + } + + self.searchResults = results.sorted { $0.name.lowercased() < $1.name.lowercased() } + } catch {} + + isSearching = false + } + } + + // MARK: - Package Actions + + public func install(package: BrewPackage) { + runAsyncCommand( + name: localization.format("operation.install", defaultValue: "正在安装 %@...", package.name), + args: ["install"] + (package.isCask ? ["--cask"] : []) + [package.name] + ) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func uninstall(package: BrewPackage) { + runAsyncCommand( + name: localization.format("operation.uninstall", defaultValue: "正在卸载 %@...", package.name), + args: ["uninstall"] + (package.isCask ? ["--cask"] : []) + [package.name] + ) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func upgrade(package: BrewPackage) { + runAsyncCommand( + name: localization.format("operation.upgrade", defaultValue: "正在更新 %@...", package.name), + args: ["upgrade"] + (package.isCask ? ["--cask"] : []) + [package.name] + ) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func pin(package: BrewPackage) { + runAsyncCommand( + name: localization.format("operation.pin", defaultValue: "正在锁定 %@...", package.name), + args: ["pin", package.name] + ) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func unpin(package: BrewPackage) { + runAsyncCommand( + name: localization.format("operation.unpin", defaultValue: "正在解锁 %@...", package.name), + args: ["unpin", package.name] + ) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + // MARK: - Global Operations + + public func upgradeAll() { + runAsyncCommand(name: localization.string("operation.upgradeAll", defaultValue: "正在更新所有包..."), args: ["upgrade"]) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func updateBrew() { + runAsyncCommand(name: localization.string("operation.updateBrew", defaultValue: "正在更新 Homebrew 源..."), args: ["update"]) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func runDoctor() { + runAsyncCommand(name: localization.string("operation.doctor", defaultValue: "正在运行 Homebrew 诊断..."), args: ["doctor"]) { _ in } + } + + public func runCleanup() { + runAsyncCommand(name: localization.string("operation.cleanup", defaultValue: "正在清理 Homebrew 缓存..."), args: ["cleanup"]) { _ in } + } + + // MARK: - Tap Actions + + public func tapRepository(name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + runAsyncCommand(name: localization.format("operation.tap", defaultValue: "正在启用软件源 %@...", trimmed), args: ["tap", trimmed]) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + public func untapRepository(tap: BrewTap) { + runAsyncCommand(name: localization.format("operation.untap", defaultValue: "正在移除软件源 %@...", tap.name), args: ["untap", tap.name]) { [weak self] success in + if success { + self?.scanAll() + } + } + } + + // MARK: - Process Execution + + private func runAsyncCommand( + name: String, + args: [String], + completion: @escaping @MainActor (Bool) -> Void + ) { + guard isBrewAvailable, !isBusy else { return } + isBusy = true + currentOperationName = name + clearLogs() + appendLog("[Command] brew \(args.joined(separator: " "))") + + Task { + let success: Bool + do { + let status = try await runner.run( + executable: brewPath, + arguments: args, + onOutput: { [weak self] text in + self?.appendLog(text) + }, + onError: { [weak self] text in + self?.appendLog(text, isError: true) + } + ) + + success = (status == 0) + appendLog( + success + ? localization.string("log.command.completed", defaultValue: "[System] 操作成功完成") + : localization.format("log.command.failedStatus", defaultValue: "[System] 操作失败,错误码:%d", status), + isError: !success + ) + } catch { + appendLog(localization.format( + "log.command.failed", + defaultValue: "[System] 执行命令出错:%@", + error.localizedDescription + ), isError: true) + success = false + } + + isBusy = false + currentOperationName = "" + onStateChange?() + completion(success) + } + } + + public func cancelCurrentOperation() { + Task { + await runner.cancel() + appendLog(localization.string("log.command.cancelled", defaultValue: "[System] 操作已被用户取消"), isError: true) + isBusy = false + currentOperationName = "" + onStateChange?() + } + } +} diff --git a/Plugins/Homebrew/Sources/HomebrewDetailView.swift b/Plugins/Homebrew/Sources/HomebrewDetailView.swift new file mode 100644 index 0000000..85f8efd --- /dev/null +++ b/Plugins/Homebrew/Sources/HomebrewDetailView.swift @@ -0,0 +1,1341 @@ +import SwiftUI +import MacToolsPluginKit + +struct HomebrewDetailView: View { + @ObservedObject var controller: HomebrewController + private let localization: PluginLocalization + private let showsHeader: Bool + private let contentPadding: CGFloat + private let minimumContentHeight: CGFloat + + @State private var activeTab: BrewTabSection = .installed + @State private var installedFilter: BrewPackageFilter = .all + @State private var localSearchText = "" + @State private var onlineSearchText = "" + @State private var newTapName = "" + + @State private var selectedInstalledPkg: BrewPackage? + @State private var selectedSearchPkg: BrewPackage? + @State private var onlinePackageDetail: BrewPackage? + @State private var isFetchingOnlineDetail = false + @State private var customBrewPath = "" + @State private var pendingAction: HomebrewPendingAction? + @State private var didRequestInitialScan = false + + enum BrewTabSection: String, CaseIterable, Identifiable { + case installed + case search + case taps + case diagnostics + + var id: String { rawValue } + + func title(localization: PluginLocalization) -> String { + switch self { + case .installed: return localization.string("detail.tabs.installed", defaultValue: "已安装") + case .search: return localization.string("detail.tabs.search", defaultValue: "搜索") + case .taps: return localization.string("detail.tabs.taps", defaultValue: "软件源") + case .diagnostics: return localization.string("detail.tabs.diagnostics", defaultValue: "诊断") + } + } + + var icon: String { + switch self { + case .installed: return "shippingbox.fill" + case .search: return "magnifyingglass" + case .taps: return "square.stack.3d.up.fill" + case .diagnostics: return "wrench.and.screwdriver.fill" + } + } + } + + enum BrewPackageFilter: String, CaseIterable, Identifiable { + case all + case formula + case cask + + var id: String { rawValue } + + func title(localization: PluginLocalization) -> String { + switch self { + case .all: return localization.string("detail.filter.all", defaultValue: "全部") + case .formula: return localization.string("detail.filter.formula", defaultValue: "Formula") + case .cask: return localization.string("detail.filter.cask", defaultValue: "Cask") + } + } + } + + init( + controller: HomebrewController, + localization: PluginLocalization = PluginLocalization(bundle: .main), + showsHeader: Bool = true, + contentPadding: CGFloat = 16, + minimumContentHeight: CGFloat = 450 + ) { + self.controller = controller + self.localization = localization + self.showsHeader = showsHeader + self.contentPadding = contentPadding + self.minimumContentHeight = minimumContentHeight + } + + var body: some View { + VStack(spacing: 0) { + if showsHeader { + header + .padding(.horizontal, contentPadding) + .padding(.top, contentPadding) + } + + if !controller.isBrewAvailable { + pathNotFoundView + } else { + VStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + tabBar + statusSummaryBar + } + .padding(.horizontal, contentPadding) + .padding(.vertical, PluginSettingsTheme.Spacing.sectionHeaderContent) + + Divider() + + // Main Content Area + GeometryReader { _ in + VStack(spacing: 0) { + Group { + switch activeTab { + case .installed: + installedTabContent + case .search: + searchTabContent + case .taps: + tapsTabContent + case .diagnostics: + diagnosticsTabContent + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Console Output Drawer + if !controller.logs.isEmpty { + consoleDrawer + .frame(height: 140) + .transition(.move(edge: .bottom)) + } + } + } + } + } + .frame(minHeight: minimumContentHeight) + .onAppear { + customBrewPath = controller.brewPath + requestInitialScanIfNeeded() + } + .onChange(of: controller.brewPath) { _, newPath in + customBrewPath = newPath + } + .onChange(of: controller.isBrewAvailable) { _, _ in + requestInitialScanIfNeeded() + } + .onChange(of: controller.isBusy) { _, isBusy in + if !isBusy { + requestInitialScanIfNeeded() + } + } + .confirmationDialog( + pendingAction?.title(localization: localization) + ?? localization.string("detail.confirm.defaultTitle", defaultValue: "确认操作"), + isPresented: Binding( + get: { pendingAction != nil }, + set: { if !$0 { pendingAction = nil } } + ), + titleVisibility: .visible + ) { + if let pendingAction { + Button(pendingAction.buttonTitle(localization: localization), role: pendingAction.role) { + perform(pendingAction) + self.pendingAction = nil + } + } + Button(localization.string("detail.confirm.cancel", defaultValue: "取消"), role: .cancel) { + pendingAction = nil + } + } message: { + if let pendingAction { + Text(pendingAction.message(localization: localization)) + } + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(localization.string("detail.title", defaultValue: "Homebrew Manager")) + .font(PluginSettingsTheme.Typography.pageTitle) + Text(localization.string("detail.subtitle", defaultValue: "Manage Homebrew packages, applications, and repositories visually")) + .font(PluginSettingsTheme.Typography.pageDescription) + .foregroundStyle(.secondary) + } + Spacer() + + if controller.isBusy { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(controller.currentOperationName) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .pluginSettingsCardBackground(.recessed) + } + } + } + + private var tabBar: some View { + HStack { + Spacer(minLength: 0) + Picker(localization.string("detail.tabs.picker", defaultValue: "视图"), selection: $activeTab) { + ForEach(BrewTabSection.allCases) { section in + Label(section.title(localization: localization), systemImage: section.icon) + .tag(section) + } + } + .pickerStyle(.segmented) + .controlSize(.large) + .labelsHidden() + .frame(minWidth: 500, idealWidth: 560, maxWidth: 620) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } + + private var statusSummaryBar: some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + statusMetric( + title: localization.string("detail.status.installed", defaultValue: "已安装"), + value: "\(controller.installedPackages.count)", + systemImage: "shippingbox.fill", + color: Color(nsColor: .secondaryLabelColor) + ) + statusMetric( + title: localization.string("detail.status.outdated", defaultValue: "可更新"), + value: "\(controller.outdatedPackages.count)", + systemImage: "arrow.up.circle.fill", + color: controller.outdatedPackages.isEmpty + ? Color(nsColor: .secondaryLabelColor) + : .orange + ) + + Spacer(minLength: 12) + + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + Image(systemName: "terminal") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text(controller.brewPath.isEmpty + ? localization.string("detail.status.brewPathMissing", defaultValue: "未配置 brew") + : controller.brewPath) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + .frame(minWidth: 160, maxWidth: 360, alignment: .trailing) + } + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .padding(.vertical, PluginSettingsTheme.Spacing.rowVertical) + .pluginSettingsCardBackground(.recessed) + } + + private func statusMetric( + title: String, + value: String, + systemImage: String, + color: Color + ) -> some View { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + Image(systemName: systemImage) + .font(.caption.weight(.semibold)) + .foregroundStyle(color) + Text(title) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + Text(value) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.primary) + } + } + + private func refreshButton(title: String) -> some View { + Button { + controller.scanAll() + } label: { + Label(title, systemImage: "arrow.clockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!controller.isBrewAvailable || controller.isBusy) + .help(localization.string("detail.refresh.help", defaultValue: "刷新 Homebrew 状态")) + } + + private func sectionHeader(title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + } + + // MARK: - Tab Contents + + private var installedTabContent: some View { + let filtered = filteredInstalledPackages + + return HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + VStack(spacing: 0) { + installedToolbar + PluginSettingsListDivider(leadingInset: 0, trailingInset: 0) + + if filtered.isEmpty { + installedEmptyState + } else { + installedPackageList(filtered) + } + } + .pluginSettingsCardBackground(.host) + .frame(width: 340) + .frame(maxHeight: .infinity) + + installedDetailPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent) + .padding(.horizontal, contentPadding) + .padding(.bottom, contentPadding) + } + + private var filteredInstalledPackages: [BrewPackage] { + controller.installedPackages.filter { pkg in + let matchesText = localSearchText.isEmpty + || pkg.name.localizedCaseInsensitiveContains(localSearchText) + || pkg.desc.localizedCaseInsensitiveContains(localSearchText) + let matchesFilter = installedFilter == .all + || (installedFilter == .formula && !pkg.isCask) + || (installedFilter == .cask && pkg.isCask) + return matchesText && matchesFilter + } + } + + private var installedToolbar: some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + TextField(localization.string("detail.search.placeholder", defaultValue: "搜索已安装包"), text: $localSearchText) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + + Picker("", selection: $installedFilter) { + ForEach(BrewPackageFilter.allCases) { filter in + Text(filter.title(localization: localization)).tag(filter) + } + } + .pickerStyle(.menu) + .labelsHidden() + .controlSize(.small) + .frame(width: 96) + + refreshButton(title: localization.string("detail.refresh.button", defaultValue: "刷新")) + } + .pluginSettingsListRowPadding(interactive: true) + } + + private func installedPackageList(_ packages: [BrewPackage]) -> some View { + List(packages, selection: $selectedInstalledPkg) { pkg in + installedPackageRow(pkg) + .tag(pkg) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + + private func installedPackageRow(_ pkg: BrewPackage) -> some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: pkg.isCask ? "macwindow" : "terminal") + .pluginSettingsRowIconStyle(pkg.isCask ? Color.blue : Color.green) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(pkg.name) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .lineLimit(1) + if !pkg.desc.isEmpty { + Text(pkg.desc) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + if pkg.isOutdated { + Text(localization.string("detail.search.hasUpdate", defaultValue: "可更新")) + .font(PluginSettingsTheme.Typography.statusBadge) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.15), in: Capsule()) + .foregroundStyle(.orange) + } + + Text(pkg.version) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 3) + } + + private var installedDetailPane: some View { + Group { + if let pkg = selectedInstalledPkg { + packageDetailView(for: pkg) + } else { + emptyState( + icon: "info.circle", + title: localization.string("detail.detail.selectionHint", defaultValue: "选择一个包查看详情") + ) + .pluginSettingsCardBackground(.host) + } + } + } + + private var installedEmptyState: some View { + let isUnfiltered = localSearchText.isEmpty && installedFilter == .all + + return emptyState( + icon: controller.isBusy ? "arrow.clockwise" : "shippingbox", + title: controller.isBusy + ? localization.string("detail.search.loadingInstalled", defaultValue: "正在加载 Homebrew 状态") + : (isUnfiltered + ? localization.string("detail.search.notLoaded", defaultValue: "尚未加载已安装包") + : localization.string("detail.search.noMatch", defaultValue: "没有匹配的已安装包")) + ) + } + + private var searchTabContent: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + VStack(spacing: 0) { + searchToolbar + PluginSettingsListDivider(leadingInset: 0, trailingInset: 0) + searchResultsContent + } + .pluginSettingsCardBackground(.host) + .frame(width: 340) + .frame(maxHeight: .infinity) + + searchDetailPane + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent) + .padding(.horizontal, contentPadding) + .padding(.bottom, contentPadding) + } + + private var searchToolbar: some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + TextField(localization.string("detail.search.onlinePlaceholder", defaultValue: "搜索软件包"), text: $onlineSearchText) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + .onSubmit { + runOnlineSearch() + } + + Button { + runOnlineSearch() + } label: { + Label(localization.string("detail.search.button", defaultValue: "搜索"), systemImage: "magnifyingglass") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(onlineSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || controller.isSearching) + } + .pluginSettingsListRowPadding(interactive: true) + } + + @ViewBuilder + private var searchResultsContent: some View { + if controller.isSearching { + emptyState(icon: "arrow.clockwise", title: localization.string("detail.search.searching", defaultValue: "正在搜索 Homebrew")) + } else if controller.searchResults.isEmpty { + emptyState(icon: "magnifyingglass", title: localization.string("detail.search.empty", defaultValue: "输入关键词搜索软件包")) + } else { + List(controller.searchResults, selection: $selectedSearchPkg) { pkg in + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: pkg.isCask ? "macwindow" : "terminal") + .pluginSettingsRowIconStyle(pkg.isCask ? Color.blue : Color.green) + Text(pkg.name) + .font(PluginSettingsTheme.Typography.rowTitle) + .lineLimit(1) + Spacer() + Text(pkg.isCask + ? localization.string("detail.filter.cask", defaultValue: "Cask") + : localization.string("detail.filter.formula", defaultValue: "Formula")) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + .tag(pkg) + .padding(.vertical, 3) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .onChange(of: selectedSearchPkg) { _, newPkg in + if let pkg = newPkg { + fetchOnlinePackageDetail(pkg) + } + } + } + } + + @ViewBuilder + private var searchDetailPane: some View { + if isFetchingOnlineDetail { + emptyState(icon: "arrow.clockwise", title: localization.string("detail.search.fetchingDetail", defaultValue: "正在加载包详情")) + .pluginSettingsCardBackground(.host) + } else if let pkg = onlinePackageDetail { + onlinePackageDetailView(for: pkg) + } else { + emptyState(icon: "plus.circle", title: localization.string("detail.detail.onlineHint", defaultValue: "选择搜索结果后安装")) + .pluginSettingsCardBackground(.host) + } + } + + private var tapsTabContent: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader(title: localization.string("detail.taps.titleAdd", defaultValue: "添加软件源"), icon: "plus.circle") + + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + TextField(localization.string("detail.taps.placeholderAdd", defaultValue: "user/repo 或仓库 URL"), text: $newTapName) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + + Button { + let trimmed = newTapName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + pendingAction = .tap(trimmed) + } label: { + Label(localization.string("detail.taps.buttonAdd", defaultValue: "添加"), systemImage: "plus") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(newTapName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || controller.isBusy) + } + .pluginSettingsListRowPadding(interactive: true) + .pluginSettingsCardBackground(.host) + } + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader( + title: localization.format("detail.taps.titleList", defaultValue: "已启用软件源(%d)", controller.taps.count), + icon: "square.stack.3d.up" + ) + tapListCard + } + .frame(maxHeight: .infinity, alignment: .top) + } + .padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent) + .padding(.horizontal, contentPadding) + .padding(.bottom, contentPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var tapListCard: some View { + VStack(spacing: 0) { + if controller.taps.isEmpty { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "square.stack.3d.up") + .pluginSettingsRowIconStyle() + Text(localization.string("detail.taps.empty", defaultValue: "暂无软件源")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + Spacer() + } + .pluginSettingsListRowPadding() + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(controller.taps) { tap in + tapRow(tap) + if tap.id != controller.taps.last?.id { + PluginSettingsListDivider() + } + } + } + } + .scrollIndicators(.automatic) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .pluginSettingsCardBackground(.host) + } + + private func tapRow(_ tap: BrewTap) -> some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "square.stack.3d.up") + .pluginSettingsRowIconStyle() + Text(tap.name) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Button(role: .destructive) { + pendingAction = .untap(tap) + } label: { + Label(localization.string("detail.taps.buttonUntap", defaultValue: "移除"), systemImage: "minus.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(controller.isBusy) + } + .pluginSettingsListRowPadding(interactive: true) + } + + private var diagnosticsTabContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader(title: localization.string("detail.diagnostics.pathTitle", defaultValue: "brew 路径"), icon: "terminal") + + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + TextField(localization.string("detail.diagnostics.pathPlaceholder", defaultValue: "例如 /opt/homebrew/bin/brew"), text: $customBrewPath) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + + Button { + controller.updateCustomPath(customBrewPath) + } label: { + Label(localization.string("detail.diagnostics.pathApply", defaultValue: "应用"), systemImage: "checkmark") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(customBrewPath == controller.brewPath || customBrewPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .pluginSettingsListRowPadding(interactive: true) + .pluginSettingsCardBackground(.host) + } + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader(title: localization.string("detail.diagnostics.maintenance.title", defaultValue: "维护操作"), icon: "wrench.and.screwdriver") + + VStack(spacing: 0) { + diagnosticRow( + title: localization.string("detail.diagnostics.update.title", defaultValue: "更新软件源"), + description: localization.string("detail.diagnostics.update.desc", defaultValue: "同步 Homebrew formula 和 cask 列表。"), + icon: "arrow.clockwise.circle.fill", + color: .blue, + action: { controller.updateBrew() } + ) + PluginSettingsListDivider() + diagnosticRow( + title: localization.string("detail.diagnostics.upgrade.title", defaultValue: "更新所有包"), + description: localization.string("detail.diagnostics.upgrade.desc", defaultValue: "更新当前检测到的过期包。"), + icon: "arrow.up.circle.fill", + color: .orange, + action: { pendingAction = .upgradeAll } + ) + PluginSettingsListDivider() + diagnosticRow( + title: localization.string("detail.diagnostics.doctor.title", defaultValue: "运行诊断"), + description: localization.string("detail.diagnostics.doctor.desc", defaultValue: "检查环境变量、权限和构建路径。"), + icon: "heart.text.square.fill", + color: .green, + action: { controller.runDoctor() } + ) + PluginSettingsListDivider() + diagnosticRow( + title: localization.string("detail.diagnostics.cleanup.title", defaultValue: "清理缓存"), + description: localization.string("detail.diagnostics.cleanup.desc", defaultValue: "移除旧版本下载和缓存。"), + icon: "trash.circle.fill", + color: .red, + action: { pendingAction = .cleanup } + ) + } + .pluginSettingsCardBackground(.host) + } + } + .padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent) + .padding(.bottom, contentPadding) + } + .padding(.horizontal, contentPadding) + } + + private var pathNotFoundView: some View { + VStack(spacing: PluginSettingsTheme.Spacing.section) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: PluginSettingsTheme.Size.pageIcon)) + .foregroundStyle(.orange) + + Text(localization.string("detail.diagnostics.pathNotFoundTitle", defaultValue: "未检测到 Homebrew")) + .font(PluginSettingsTheme.Typography.pageTitle) + + Text(localization.string("detail.diagnostics.pathNotFoundDesc", defaultValue: "请确认已安装 Homebrew,或手动指定 brew 可执行文件路径。")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + TextField(localization.string("detail.diagnostics.pathPlaceholder", defaultValue: "例如 /opt/homebrew/bin/brew"), text: $customBrewPath) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + + Button { + controller.updateCustomPath(customBrewPath) + } label: { + Label(localization.string("detail.diagnostics.pathApply", defaultValue: "应用"), systemImage: "checkmark") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(customBrewPath.isEmpty) + } + } + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 6) { + Text(localization.string("detail.diagnostics.pathHelpTitle", defaultValue: "常见安装位置")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Text("Apple Silicon Mac: /opt/homebrew/bin/brew") + .font(.caption) + .foregroundStyle(.secondary) + Text("Intel Mac: /usr/local/bin/brew") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 12) + Spacer() + } + .padding(PluginSettingsTheme.Spacing.pagePadding) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .pluginSettingsCardBackground(.host) + } + + private func diagnosticRow( + title: String, + description: String, + icon: String, + color: Color, + action: @escaping () -> Void + ) -> some View { + HStack(alignment: .center, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: icon) + .pluginSettingsRowIconStyle(color, visualScale: 1.1) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(title) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text(description) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Button { + action() + } label: { + Label(localization.string("detail.diagnostics.buttonExecute", defaultValue: "执行"), systemImage: "play") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(controller.isBusy) + } + .pluginSettingsListRowPadding(interactive: true) + } + + // MARK: - Component Views + + private func packageDetailView(for pkg: BrewPackage) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Image(systemName: pkg.isCask ? "macwindow" : "terminal") + .font(.system(size: 20)) + .foregroundStyle(pkg.isCask ? .blue : .green) + + Text(pkg.name) + .font(PluginSettingsTheme.Typography.pageTitle) + Spacer() + + if pkg.isPinned { + Label(localization.string("detail.detail.pinned", defaultValue: "Pinned"), systemImage: "pin.fill") + .font(PluginSettingsTheme.Typography.statusBadge) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.yellow.opacity(0.15)) + .foregroundStyle(.yellow) + .cornerRadius(4) + } + } + + if !pkg.desc.isEmpty { + Text(pkg.desc) + .font(PluginSettingsTheme.Typography.rowTitle) + .foregroundStyle(.secondary) + } + + VStack(spacing: 8) { + infoRow(label: localization.string("detail.detail.type", defaultValue: "Type"), value: pkg.isCask ? localization.string("detail.detail.typeCask", defaultValue: "GUI App (Cask)") : localization.string("detail.detail.typeFormula", defaultValue: "CLI Package (Formula)")) + infoRow(label: localization.string("detail.detail.installedVer", defaultValue: "Installed"), value: pkg.version) + infoRow(label: localization.string("detail.detail.latestVer", defaultValue: "Latest"), value: pkg.latestVersion) + if !pkg.homepage.isEmpty { + HStack { + Text(localization.string("detail.detail.website", defaultValue: "Homepage")).font(PluginSettingsTheme.Typography.rowDescription).foregroundStyle(.secondary) + Spacer() + if let homepageURL = validatedHomepageURL(pkg.homepage) { + Link(pkg.homepage, destination: homepageURL) + .font(PluginSettingsTheme.Typography.rowDescription) + .lineLimit(1) + } else { + Text(pkg.homepage) + .font(PluginSettingsTheme.Typography.rowDescription) + .lineLimit(1) + .textSelection(.enabled) + } + } + } + } + .padding(10) + .pluginSettingsCardBackground(.recessed) + + if !pkg.dependencies.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(localization.format("detail.detail.dependencies", defaultValue: "Dependencies (%d)", pkg.dependencies.count)) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(pkg.dependencies, id: \.self) { dep in + Button { + navigateToPackage(name: dep) + } label: { + Text(dep) + .font(PluginSettingsTheme.Typography.monospacedValue) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.05)) + .cornerRadius(4) + } + .buttonStyle(.plain) + } + } + } + } + } + + let usedBy = pkg.requiredBy(in: controller.installedPackages) + if !usedBy.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text(localization.format("detail.detail.usedBy", defaultValue: "Required By (%d)", usedBy.count)) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(usedBy, id: \.self) { parent in + Button { + navigateToPackage(name: parent) + } label: { + Text(parent) + .font(PluginSettingsTheme.Typography.monospacedValue) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.05)) + .cornerRadius(4) + } + .buttonStyle(.plain) + } + } + } + } + } + + Spacer() + + // Actions + HStack(spacing: 10) { + if pkg.isOutdated { + Button { + controller.upgrade(package: pkg) + } label: { + Label(localization.string("detail.detail.actionUpgrade", defaultValue: "Upgrade"), systemImage: "arrow.up.circle") + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .disabled(controller.isBusy) + } + + if !pkg.isPinned { + Button { + if pkg.isPinned { + controller.unpin(package: pkg) + } else { + controller.pin(package: pkg) + } + } label: { + Label(pkg.isPinned ? localization.string("detail.detail.actionUnpin", defaultValue: "Unpin") : localization.string("detail.detail.actionPin", defaultValue: "Pin Version"), systemImage: pkg.isPinned ? "pin.slash" : "pin") + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(controller.isBusy) + } else { + Button { + if pkg.isPinned { + controller.unpin(package: pkg) + } else { + controller.pin(package: pkg) + } + } label: { + Label(pkg.isPinned ? localization.string("detail.detail.actionUnpin", defaultValue: "Unpin") : localization.string("detail.detail.actionPin", defaultValue: "Pin Version"), systemImage: pkg.isPinned ? "pin.slash" : "pin") + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(controller.isBusy) + } + + Button(role: .destructive) { + pendingAction = .uninstall(pkg) + } label: { + Label(localization.string("detail.detail.actionUninstall", defaultValue: "Uninstall"), systemImage: "trash") + } + .buttonStyle(.bordered) + .controlSize(.regular) + .disabled(controller.isBusy) + } + } + .padding(14) + .pluginSettingsCardBackground(.host) + } + + private func onlinePackageDetailView(for pkg: BrewPackage) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Image(systemName: pkg.isCask ? "macwindow" : "terminal") + .font(.system(size: 20)) + .foregroundStyle(pkg.isCask ? .blue : .green) + + Text(pkg.name) + .font(PluginSettingsTheme.Typography.pageTitle) + Spacer() + } + + if !pkg.desc.isEmpty { + Text(pkg.desc) + .font(PluginSettingsTheme.Typography.rowTitle) + .foregroundStyle(.secondary) + } + + VStack(spacing: 8) { + infoRow( + label: localization.string("detail.detail.type", defaultValue: "Type"), + value: pkg.isCask + ? localization.string("detail.filter.cask", defaultValue: "Cask") + : localization.string("detail.filter.formula", defaultValue: "Formula") + ) + infoRow(label: localization.string("detail.detail.latestAvailableVer", defaultValue: "Latest Available"), value: pkg.latestVersion) + if !pkg.homepage.isEmpty { + HStack { + Text(localization.string("detail.detail.website", defaultValue: "Homepage")).font(PluginSettingsTheme.Typography.rowDescription).foregroundStyle(.secondary) + Spacer() + if let homepageURL = validatedHomepageURL(pkg.homepage) { + Link(pkg.homepage, destination: homepageURL) + .font(PluginSettingsTheme.Typography.rowDescription) + .lineLimit(1) + } else { + Text(pkg.homepage) + .font(PluginSettingsTheme.Typography.rowDescription) + .lineLimit(1) + .textSelection(.enabled) + } + } + } + } + .padding(10) + .pluginSettingsCardBackground(.recessed) + + Spacer() + + // Actions + let alreadyInstalled = controller.installedPackages.contains { $0.name == pkg.name } + + Button { + controller.install(package: pkg) + } label: { + Label(alreadyInstalled ? localization.string("detail.search.installAgainAction", defaultValue: "Install Again") : localization.string("detail.search.installAction", defaultValue: "Install"), systemImage: "plus.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(controller.isBusy) + + if alreadyInstalled { + Text(localization.string("detail.search.installedHint", defaultValue: "This package is already installed on your system.")) + .font(.caption) + .foregroundStyle(.green) + .frame(maxWidth: .infinity, alignment: .center) + } + } + .padding(14) + .pluginSettingsCardBackground(.host) + } + + private func infoRow(label: String, value: String) -> some View { + HStack { + Text(label) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + Spacer() + Text(value) + .font(PluginSettingsTheme.Typography.rowDescription) + } + } + + // MARK: - Console Output Drawer + + private var consoleDrawer: some View { + VStack(spacing: 0) { + Divider() + + // Terminal Header + HStack { + HStack(spacing: 6) { + Circle() + .fill(controller.isBusy ? Color.green : Color.gray) + .frame(width: 8, height: 8) + .opacity(controller.isBusy ? 0.8 : 0.4) + + Text(localization.string("detail.console.title", defaultValue: "Terminal Console Logs")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + Spacer() + + Button(localization.string("detail.console.buttonClear", defaultValue: "Clear")) { + controller.clearLogs() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.05)) + .cornerRadius(4) + + if controller.isBusy { + Button(localization.string("detail.console.buttonCancel", defaultValue: "Cancel Operation")) { + controller.cancelCurrentOperation() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.red) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red.opacity(0.1)) + .cornerRadius(4) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.02)) + + // Scrollable Console + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(controller.logs.suffix(150)) { log in + Text(log.text) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(log.isError ? Color.red : Color.green) + .frame(maxWidth: .infinity, alignment: .leading) + .id(log.id) + } + } + .padding(8) + } + .background(Color(nsColor: .textBackgroundColor).opacity(0.95)) + .onChange(of: controller.logs.count) { _, _ in + if let last = controller.logs.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + + // MARK: - Helper Methods + + private func requestInitialScanIfNeeded() { + guard !didRequestInitialScan else { return } + guard controller.isBrewAvailable, !controller.isBusy else { return } + guard controller.installedPackages.isEmpty, + controller.outdatedPackages.isEmpty, + controller.taps.isEmpty else { + return + } + + didRequestInitialScan = true + controller.scanAll() + } + + private func emptyState(icon: String, title: String) -> some View { + VStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + if icon == "arrow.clockwise" { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: icon) + .font(.system(size: PluginSettingsTheme.Size.emptyStateIcon)) + .foregroundStyle(.secondary) + } + + Text(title) + .font(PluginSettingsTheme.Typography.pageDescription) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func fetchOnlinePackageDetail(_ pkg: BrewPackage) { + let requestedName = pkg.name + let requestedIsCask = pkg.isCask + isFetchingOnlineDetail = true + onlinePackageDetail = nil + + Task { + defer { + if selectedSearchPkg?.name == requestedName && selectedSearchPkg?.isCask == requestedIsCask { + isFetchingOnlineDetail = false + } + } + do { + let tempRunner = controller.runner + var jsonOutput = "" + + _ = try await tempRunner.run( + executable: controller.brewPath, + arguments: ["info", "--json=v2", pkg.name], + onOutput: { jsonOutput += $0 }, + onError: { _ in } + ) + + guard selectedSearchPkg?.name == requestedName && selectedSearchPkg?.isCask == requestedIsCask else { return } + + guard let data = jsonOutput.data(using: .utf8) else { + return + } + + struct BrewInfoResponse: Codable { + let formulae: [FormulaInfo] + let casks: [CaskInfo] + } + struct FormulaInfo: Codable { + let name: String + let desc: String? + let homepage: String? + let versions: StableVersion? + } + struct StableVersion: Codable { + let stable: String? + } + struct CaskInfo: Codable { + let token: String + let name: [String]? + let desc: String? + let homepage: String? + let version: String? + } + + let response = try JSONDecoder().decode(BrewInfoResponse.self, from: data) + + guard selectedSearchPkg?.name == requestedName && selectedSearchPkg?.isCask == requestedIsCask else { return } + + if pkg.isCask, let cask = response.casks.first { + onlinePackageDetail = BrewPackage( + name: cask.token, + version: "", + latestVersion: cask.version ?? "", + isCask: true, + desc: cask.desc ?? (cask.name?.first ?? ""), + homepage: cask.homepage ?? "", + isOutdated: false, + isPinned: false + ) + } else if let formula = response.formulae.first { + onlinePackageDetail = BrewPackage( + name: formula.name, + version: "", + latestVersion: formula.versions?.stable ?? "", + isCask: false, + desc: formula.desc ?? "", + homepage: formula.homepage ?? "", + isOutdated: false, + isPinned: false + ) + } + } catch {} + } + } + + private func navigateToPackage(name: String) { + if let found = controller.installedPackages.first(where: { $0.name == name }) { + activeTab = .installed + selectedInstalledPkg = found + } else { + activeTab = .search + onlineSearchText = name + runOnlineSearch() + } + } + + private func runOnlineSearch() { + let trimmed = onlineSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + selectedSearchPkg = nil + onlinePackageDetail = nil + controller.search(query: trimmed) + } + + private func perform(_ action: HomebrewPendingAction) { + switch action { + case .upgradeAll: + controller.upgradeAll() + case .cleanup: + controller.runCleanup() + case let .uninstall(package): + controller.uninstall(package: package) + case let .tap(name): + controller.tapRepository(name: name) + newTapName = "" + case let .untap(tap): + controller.untapRepository(tap: tap) + } + } + + private func validatedHomepageURL(_ value: String) -> URL? { + guard let components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + let url = components.url else { + return nil + } + return url + } +} + +private enum HomebrewPendingAction: Identifiable { + case upgradeAll + case cleanup + case uninstall(BrewPackage) + case tap(String) + case untap(BrewTap) + + var id: String { + switch self { + case .upgradeAll: + return "upgrade-all" + case .cleanup: + return "cleanup" + case let .uninstall(package): + return "uninstall-\(package.name)" + case let .tap(name): + return "tap-\(name)" + case let .untap(tap): + return "untap-\(tap.name)" + } + } + + var role: ButtonRole? { + switch self { + case .cleanup, + .uninstall, + .untap: + return .destructive + case .upgradeAll, + .tap: + return nil + } + } + + func title(localization: PluginLocalization) -> String { + switch self { + case .upgradeAll: + return localization.string("detail.confirm.upgradeAll.title", defaultValue: "确认更新所有包?") + case .cleanup: + return localization.string("detail.confirm.cleanup.title", defaultValue: "确认清理 Homebrew 缓存?") + case let .uninstall(package): + return localization.format( + "detail.confirm.uninstall.title", + defaultValue: "确认卸载 %@?", + package.name + ) + case .tap: + return localization.string("detail.confirm.tap.title", defaultValue: "确认添加软件源?") + case let .untap(tap): + return localization.format( + "detail.confirm.untap.title", + defaultValue: "确认移除 %@?", + tap.name + ) + } + } + + func message(localization: PluginLocalization) -> String { + switch self { + case .upgradeAll: + return localization.string( + "detail.confirm.upgradeAll.message", + defaultValue: "将执行 brew upgrade,更新所有可升级的 Homebrew 包。此操作可能修改已安装软件。" + ) + case .cleanup: + return localization.string( + "detail.confirm.cleanup.message", + defaultValue: "将执行 brew cleanup,删除 Homebrew 旧版本和下载缓存。" + ) + case let .uninstall(package): + return localization.format( + "detail.confirm.uninstall.message", + defaultValue: "将执行 brew uninstall %@。如果其他包依赖它,相关功能可能受到影响。", + package.name + ) + case let .tap(name): + return localization.format( + "detail.confirm.tap.message", + defaultValue: "将执行 brew tap %@,Homebrew 会从该软件源获取配方和 cask 信息。", + name + ) + case let .untap(tap): + return localization.format( + "detail.confirm.untap.message", + defaultValue: "将执行 brew untap %@。依赖该软件源的包可能无法继续更新。", + tap.name + ) + } + } + + func buttonTitle(localization: PluginLocalization) -> String { + switch self { + case .upgradeAll: + return localization.string("detail.confirm.upgradeAll.button", defaultValue: "更新全部") + case .cleanup: + return localization.string("detail.confirm.cleanup.button", defaultValue: "清理") + case .uninstall: + return localization.string("detail.confirm.uninstall.button", defaultValue: "卸载") + case .tap: + return localization.string("detail.confirm.tap.button", defaultValue: "添加") + case .untap: + return localization.string("detail.confirm.untap.button", defaultValue: "移除") + } + } +} diff --git a/Plugins/Homebrew/Sources/HomebrewModels.swift b/Plugins/Homebrew/Sources/HomebrewModels.swift new file mode 100644 index 0000000..09a0cff --- /dev/null +++ b/Plugins/Homebrew/Sources/HomebrewModels.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct BrewPackage: Identifiable, Hashable, Sendable, Codable { + public var id: String { "\(isCask ? "cask" : "formula"):\(name)" } + public let name: String + public let version: String + public let latestVersion: String + public let isCask: Bool + public let desc: String + public let homepage: String + public let isOutdated: Bool + public let isPinned: Bool + public let dependencies: [String] + + public init( + name: String, + version: String, + latestVersion: String, + isCask: Bool, + desc: String, + homepage: String, + isOutdated: Bool, + isPinned: Bool, + dependencies: [String] = [] + ) { + self.name = name + self.version = version + self.latestVersion = latestVersion + self.isCask = isCask + self.desc = desc + self.homepage = homepage + self.isOutdated = isOutdated + self.isPinned = isPinned + self.dependencies = dependencies + } + + public func requiredBy(in packages: [BrewPackage]) -> [String] { + packages.filter { $0.dependencies.contains(self.name) }.map { $0.name } + } +} + +public struct BrewTap: Identifiable, Hashable, Sendable, Codable { + public var id: String { name } + public let name: String + + public init(name: String) { + self.name = name + } +} + +public struct BrewCommandLog: Identifiable, Sendable { + public let id = UUID() + public let text: String + public let isError: Bool + + public init(text: String, isError: Bool = false) { + self.text = text + self.isError = isError + } +} diff --git a/Plugins/Homebrew/Sources/HomebrewPlugin.swift b/Plugins/Homebrew/Sources/HomebrewPlugin.swift new file mode 100644 index 0000000..e8ad35a --- /dev/null +++ b/Plugins/Homebrew/Sources/HomebrewPlugin.swift @@ -0,0 +1,154 @@ +import AppKit +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class HomebrewPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + HomebrewPluginProvider(context: context) + } +} + +@MainActor +private struct HomebrewPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + let localization = PluginLocalization(bundle: context.resourceBundle) + let controller = HomebrewController(localization: localization) + return [HomebrewPlugin( + controller: controller, + localization: localization + )] + } +} + +@MainActor +public final class HomebrewPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginConfigurationPresenting { + public enum ControlID { + static let manage = "execute" + } + + public let metadata: PluginMetadata + public let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + public var onStateChange: (() -> Void)? + public var requestPermissionGuidance: ((String) -> Void)? + public var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + public var requestConfigurationPresentation: (() -> Void)? + + private let controller: HomebrewController + private let localization: PluginLocalization + + public init( + controller: HomebrewController, + localization: PluginLocalization + ) { + self.controller = controller + self.localization = localization + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .dismissBeforeHandling, + buttonTitleProvider: { localization.string("panel.action.manage", defaultValue: "管理") } + ) + + self.metadata = PluginMetadata( + id: "homebrew", + title: localization.string("metadata.title", defaultValue: "Homebrew"), + iconName: "shippingbox.fill", + iconTint: Color(nsColor: .systemOrange), + order: 92, + defaultDescription: localization.string("metadata.description", defaultValue: "Manage Homebrew packages, repositories, and perform diagnostics") + ) + + self.controller.onStateChange = { [weak self] in + self?.onStateChange?() + } + } + + public func activate(context: PluginRuntimeContext) { + onStateChange?() + } + + public func deactivate(reason: PluginDeactivationReason) { + guard reason.requiresStateCleanup else { return } + controller.cancelCurrentOperation() + } + + public func refresh() { + onStateChange?() + } + + public var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: subtitleText, + isOn: controller.isBusy, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: controller.isBrewAvailable + ? nil + : localization.string("panel.error.brewPathRequired", defaultValue: "需要配置 brew 路径") + ) + } + + public var permissionRequirements: [PluginPermissionRequirement] { [] } + public var settingsSections: [PluginSettingsSection] { [] } + public var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + public var configuration: PluginConfiguration? { + let controller = self.controller + let localization = self.localization + return PluginConfiguration( + description: metadata.defaultDescription, + prefersFullHeight: true + ) { _ in + HomebrewDetailView( + controller: controller, + localization: localization, + showsHeader: false, + contentPadding: 0, + minimumContentHeight: 480 + ) + } + } + + public func handleAction(_ action: PluginPanelAction) { + switch action { + case let .invokeAction(controlID): + handleInvoke(controlID: controlID) + default: + break + } + } + + public func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + public func handlePermissionAction(id: String) {} + public func handleSettingsAction(id: String) {} + public func handleShortcutAction(id: String) {} + + // MARK: - Private + + private var subtitleText: String { + guard controller.isBrewAvailable else { + return localization.string("panel.error.brewPathRequired", defaultValue: "需要配置 brew 路径") + } + if controller.isBusy { + return controller.currentOperationName + } + return localization.string("panel.subtitle.manage", defaultValue: "管理包、软件源与诊断") + } + + private func handleInvoke(controlID: String) { + switch controlID { + case ControlID.manage: + requestConfigurationPresentation?() + default: + break + } + } +} diff --git a/Plugins/Homebrew/Tests/HomebrewPluginTests.swift b/Plugins/Homebrew/Tests/HomebrewPluginTests.swift new file mode 100644 index 0000000..0a02497 --- /dev/null +++ b/Plugins/Homebrew/Tests/HomebrewPluginTests.swift @@ -0,0 +1,319 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import HomebrewPlugin + +@MainActor +final class HomebrewPluginTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + super.tearDown() + } + + // Fake runner for testing + @MainActor + final class FakeHomebrewCommandRunner: HomebrewCommandRunning { + var stubbedStatus: Int32 = 0 + var stubbedOutputs: [String: String] = [:] + var runCalls: [[String]] = [] + var isCancelled = false + + func run( + executable: String, + arguments: [String], + onOutput: @escaping @MainActor (String) -> Void, + onError: @escaping @MainActor (String) -> Void + ) async throws -> Int32 { + runCalls.append(arguments) + + // Determine stubbed output based on arguments + var matchKey = "" + if arguments.contains("tap") { + matchKey = "tap" + } else if arguments.contains("list") && arguments.contains("--formula") { + matchKey = "list-formula" + } else if arguments.contains("list") && arguments.contains("--cask") { + matchKey = "list-cask" + } else if arguments.contains("outdated") { + matchKey = "outdated" + } else if arguments.contains("info") && arguments.contains("--installed") { + matchKey = "info-installed" + } else if arguments.contains("search") && arguments.contains("--formula") { + matchKey = "search-formula" + } else if arguments.contains("search") && arguments.contains("--cask") { + matchKey = "search-cask" + } + + if let output = stubbedOutputs[matchKey] { + onOutput(output) + } + + return stubbedStatus + } + + func cancel() async { + isCancelled = true + } + } + + func testMetadataIdentifiesHomebrewPlugin() { + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + let localization = PluginLocalization(bundle: .main) + let plugin = HomebrewPlugin(controller: controller, localization: localization) + + XCTAssertEqual(plugin.metadata.id, "homebrew") + XCTAssertEqual(plugin.metadata.title, "Homebrew") + } + + func testPanelUsesManageButton() { + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + let localization = PluginLocalization(bundle: .main) + let plugin = HomebrewPlugin(controller: controller, localization: localization) + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button) + XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .dismissBeforeHandling) + XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "管理") + XCTAssertNil(plugin.primaryPanelState.detail) + } + + func testManageButtonRequestsConfigurationPresentation() { + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + let localization = PluginLocalization(bundle: .main) + let plugin = HomebrewPlugin(controller: controller, localization: localization) + var requestCount = 0 + plugin.requestConfigurationPresentation = { + requestCount += 1 + } + + plugin.handleAction(.invokeAction(controlID: HomebrewPlugin.ControlID.manage)) + + XCTAssertEqual(requestCount, 1) + } + + func testScanPopulatesPackagesAndTaps() async throws { + let runner = FakeHomebrewCommandRunner() + runner.stubbedOutputs = [ + "tap": "homebrew/core\nhomebrew/cask\n", + "list-formula": "git 2.51.0\nripgrep 15.1.0\n", + "list-cask": "iterm2 3.5.0\n", + "outdated": "{\"formulae\":[{\"name\":\"git\",\"installed_versions\":[\"2.51.0\"],\"current_version\":\"2.55.0\",\"pinned\":false}],\"casks\":[]}", + "info-installed": "{\"formulae\":[{\"name\":\"git\",\"desc\":\"Distributed revision control system\",\"homepage\":\"https://git-scm.com/\",\"dependencies\":[\"pcre2\",\"openssl\"]}],\"casks\":[]}" + ] + + let controller = HomebrewController(runner: runner) + controller.isBrewAvailable = true // override for testing + controller.brewPath = "/opt/homebrew/bin/brew" + + // Trigger scan + controller.scanAll() + + // Wait for async operations to settle in controller (robust wait loop) + let deadline = Date().addingTimeInterval(5.0) + while controller.isBusy && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(controller.taps.count, 2) + XCTAssertEqual(controller.taps.map(\.name), ["homebrew/core", "homebrew/cask"]) + + XCTAssertEqual(controller.installedPackages.count, 3) + let gitPkg = try XCTUnwrap(controller.installedPackages.first { $0.name == "git" }) + XCTAssertEqual(gitPkg.version, "2.51.0") + XCTAssertEqual(gitPkg.latestVersion, "2.55.0") + XCTAssertTrue(gitPkg.isOutdated) + XCTAssertEqual(gitPkg.desc, "Distributed revision control system") + XCTAssertEqual(gitPkg.homepage, "https://git-scm.com/") + XCTAssertEqual(gitPkg.dependencies, ["pcre2", "openssl"]) + + // Verify reverse dependency logic + let ripPkg = try XCTUnwrap(controller.installedPackages.first { $0.name == "ripgrep" }) + XCTAssertFalse(ripPkg.isOutdated) + XCTAssertEqual(ripPkg.requiredBy(in: controller.installedPackages), []) + } + + func testSearchSkipsDuplicatePopulatedQuery() async throws { + let runner = FakeHomebrewCommandRunner() + runner.stubbedOutputs = [ + "search-formula": "wget\n", + "search-cask": "warp\n" + ] + + let controller = HomebrewController(runner: runner) + controller.isBrewAvailable = true + controller.brewPath = "/opt/homebrew/bin/brew" + + controller.search(query: "w") + let deadline = Date().addingTimeInterval(5.0) + while controller.isSearching && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + XCTAssertEqual(controller.searchResults.map(\.name), ["warp", "wget"]) + XCTAssertEqual(runner.runCalls.count, 2) + + controller.search(query: " w ") + + XCTAssertEqual(controller.searchResults.map(\.name), ["warp", "wget"]) + XCTAssertEqual(runner.runCalls.count, 2) + } + + func testCustomPathPersistence() { + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + let binDir = tempDir.appendingPathComponent("bin") + let tempBrewFile = binDir.appendingPathComponent("brew") + try? FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try? "#!/bin/sh\n# HOMEBREW test shim\n".write(to: tempBrewFile, atomically: true, encoding: .utf8) + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempBrewFile.path) + defer { + try? FileManager.default.removeItem(at: tempDir) + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + } + + // Test updating path + controller.updateCustomPath(tempBrewFile.path) + XCTAssertTrue(controller.isBrewAvailable) + XCTAssertEqual(controller.brewPath, tempBrewFile.path) + XCTAssertEqual(UserDefaults.standard.string(forKey: "mactools.homebrew.customPath"), tempBrewFile.path) + + // Test empty path resets standard path discovery + controller.updateCustomPath("") + XCTAssertEqual(UserDefaults.standard.string(forKey: "mactools.homebrew.customPath"), nil) + } + + func testUnknownPanelActionIsIgnored() { + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + controller.isBrewAvailable = true + controller.brewPath = "/opt/homebrew/bin/brew" + + let localization = PluginLocalization(bundle: .main) + let plugin = HomebrewPlugin(controller: controller, localization: localization) + + plugin.handleAction(.invokeAction(controlID: "legacy-scan")) + + XCTAssertFalse(controller.isBusy) + XCTAssertTrue(runner.runCalls.isEmpty) + } + + func testCaskPackageActionsUseBrewSubcommandBeforeCaskFlag() async throws { + let runner = FakeHomebrewCommandRunner() + runner.stubbedStatus = 1 + + let controller = HomebrewController(runner: runner) + controller.isBrewAvailable = true + controller.brewPath = "/opt/homebrew/bin/brew" + + let package = BrewPackage( + name: "iterm2", + version: "3.5.0", + latestVersion: "3.5.0", + isCask: true, + desc: "", + homepage: "", + isOutdated: false, + isPinned: false + ) + + controller.install(package: package) + var deadline = Date().addingTimeInterval(5.0) + while controller.isBusy && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(runner.runCalls.last, ["install", "--cask", "iterm2"]) + + runner.runCalls.removeAll() + controller.uninstall(package: package) + deadline = Date().addingTimeInterval(5.0) + while controller.isBusy && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(runner.runCalls.last, ["uninstall", "--cask", "iterm2"]) + + runner.runCalls.removeAll() + controller.upgrade(package: package) + deadline = Date().addingTimeInterval(5.0) + while controller.isBusy && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + XCTAssertEqual(runner.runCalls.last, ["upgrade", "--cask", "iterm2"]) + } + + func testPanelStateBusyAndNotAvailable() { + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + let localization = PluginLocalization(bundle: .main) + let plugin = HomebrewPlugin(controller: controller, localization: localization) + + // Case 1: Not installed + controller.isBrewAvailable = false + var state = plugin.primaryPanelState + XCTAssertNotNil(state.errorMessage) + + // Case 2: Available and Busy + controller.isBrewAvailable = true + controller.isBusy = true + controller.currentOperationName = "Scanning..." + state = plugin.primaryPanelState + XCTAssertNil(state.errorMessage) + XCTAssertTrue(state.isOn) + XCTAssertEqual(state.subtitle, "Scanning...") + } + + func testScanAllFailurePath() async throws { + let runner = FakeHomebrewCommandRunner() + runner.stubbedStatus = 1 // non-zero status indicating failure + + let controller = HomebrewController(runner: runner) + controller.isBrewAvailable = true + controller.brewPath = "/opt/homebrew/bin/brew" + + controller.scanAll() + + let deadline = Date().addingTimeInterval(5.0) + while controller.isBusy && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + + // Verify logs contain system error entries + let hasErrorLog = controller.logs.contains { $0.isError } + XCTAssertTrue(hasErrorLog) + } + + func testInvalidCustomPathIsRejected() { + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + let runner = FakeHomebrewCommandRunner() + let controller = HomebrewController(runner: runner) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + let binDir = tempDir.appendingPathComponent("bin") + let invalidBrewFile = binDir.appendingPathComponent("brew") + try? FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try? "#!/bin/sh\n# not homebrew\n".write(to: invalidBrewFile, atomically: true, encoding: .utf8) + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: invalidBrewFile.path) + defer { + try? FileManager.default.removeItem(at: tempDir) + UserDefaults.standard.removeObject(forKey: "mactools.homebrew.customPath") + } + + controller.updateCustomPath(invalidBrewFile.path) + + XCTAssertNil(UserDefaults.standard.string(forKey: "mactools.homebrew.customPath")) + XCTAssertNotEqual(controller.brewPath, invalidBrewFile.path) + XCTAssertTrue(controller.logs.contains { $0.isError }) + } +} diff --git a/Plugins/Homebrew/plugin.json b/Plugins/Homebrew/plugin.json new file mode 100644 index 0000000..9ce18ad --- /dev/null +++ b/Plugins/Homebrew/plugin.json @@ -0,0 +1,55 @@ +{ + "id": "homebrew", + "displayName": "Homebrew 管理", + "summary": "管理 Homebrew 包、库仓库并执行系统诊断", + "localizedMetadata": { + "ar": { + "displayName": "Homebrew", + "summary": "إدارة حزم Homebrew والمستودعات وتشغيل تشخيصات النظام." + }, + "en": { + "displayName": "Homebrew Manager", + "summary": "Manage Homebrew packages, repositories, and perform system diagnostics" + }, + "es": { + "displayName": "Homebrew", + "summary": "Administrar paquetes de Homebrew, repositorios y realizar diagnósticos del sistema." + }, + "fr": { + "displayName": "Homebrew", + "summary": "Gérer les paquets Homebrew, les dépôts et effectuer des diagnostics système." + }, + "ja": { + "displayName": "Homebrew", + "summary": "Homebrew パッケージ、リポジトリの管理、およびシステム診断の実行。" + }, + "pt": { + "displayName": "Homebrew", + "summary": "Gerenciar pacotes do Homebrew, repositórios e executar diagnósticos do sistema." + }, + "zh-Hans": { + "displayName": "Homebrew 管理", + "summary": "管理 Homebrew 包、软件仓库并执行系统诊断" + }, + "zh-Hant": { + "displayName": "Homebrew 管理", + "summary": "管理 Homebrew 包、軟體倉庫並執行系統診斷" + } + }, + "version": "1.0.1", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "Homebrew.bundle", + "factoryClass": "HomebrewPlugin.HomebrewPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "HomebrewPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "system" +} diff --git a/Plugins/IPOverview/Bundle/IPOverviewPluginBundleEntrypoint.swift b/Plugins/IPOverview/Bundle/IPOverviewPluginBundleEntrypoint.swift new file mode 100644 index 0000000..28a0cf4 --- /dev/null +++ b/Plugins/IPOverview/Bundle/IPOverviewPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import IPOverviewPlugin + +private let ipOverviewPluginFactoryAnchor: Any.Type = IPOverviewPluginFactory.self diff --git a/Plugins/IPOverview/Resources/Localizable.xcstrings b/Plugins/IPOverview/Resources/Localizable.xcstrings new file mode 100644 index 0000000..3ff83d4 --- /dev/null +++ b/Plugins/IPOverview/Resources/Localizable.xcstrings @@ -0,0 +1,10798 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "common.checking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測中" + } + } + } + }, + "common.notDetected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشافه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No detectado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検出されません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "감지되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não detectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не обнаружено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到" + } + } + } + }, + "common.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "common.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتظار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待" + } + } + } + }, + "component.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فحص IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Verificación de IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP チェック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação de IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 檢測" + } + } + } + }, + "connectivity.error.invalidURL": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان URL غير صالح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige URL" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid URL" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "URL no válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "URL invalide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な URL" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 URL" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "URL inválido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверный URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "URL 无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "URL 無效" + } + } + } + }, + "connectivity.refresh.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث الاتصال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konnektivität aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh connectivity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar conectividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser la connectivité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続を更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결 새로 고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar conectividade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить подключение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新连通性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新連通性" + } + } + } + }, + "connectivity.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاتصال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konnektivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connectivity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectivité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conectividade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络连通性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網絡連通性" + } + } + } + }, + "connectivity.unreachable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قابل للوصول" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht erreichbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unreachable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inalcanzable" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Injoignable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "到達不能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "접근할 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Inacessível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недоступно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不可达" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不可達" + } + } + } + }, + "customTarget.error.emptyName": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسمًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen Namen ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingrese un nombre" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Entrez un nom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を入力してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름을 입력하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Digite um nome" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите имя" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入名稱" + } + } + } + }, + "customTarget.error.invalidURL": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل URL" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen gültigen URL ein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid URL" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ingrese un URL" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Entrez un URL valide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効な URL を入力してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유효한 URL을(를) 입력하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Insira um URL válido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Введите действительный URL" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入有效 URL" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入有效 URL" + } + } + } + }, + "customTarget.name.placeholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاسم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Name" + } + }, + "en": { + "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": "Имя" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "名稱" + } + } + } + }, + "detail.back.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الرجوع إلى النظرة العامة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück zur Übersicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back to overview" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volver a la descripción general" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retour à l'aperçu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "概要に戻る" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개요로 돌아가기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Voltar à visão geral" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вернуться к обзору" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "返回概览" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "返回概覽" + } + } + } + }, + "detail.copyReport.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ نتيجة الفحص الكاملة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollständiges Prüfergebnis kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy full check result" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar el resultado completo de la comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier le résultat complet de la vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全なチェック結果をコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 점검 결과 복사" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar resultado completo da verificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать полный результат проверки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制完整检测结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製完整檢測結果" + } + } + } + }, + "detail.refresh.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث كافة عمليات التحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Prüfungen aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh all checks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar todas las comprobaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser toutes les vérifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのチェックを更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 검사 새로고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar todas as verificações" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить все проверки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新全部检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新全部檢測" + } + } + } + }, + "detail.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفاصيل IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Details" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detalles de IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détails IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP の詳細" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 세부정보" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Detalhes de IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сведения об IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 詳情" + } + } + } + }, + "dns.note": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعني تسريبات نظام أسماء النطاقات (DNS) أنه بعد تمكين VPN أو وكيل، قد يستمر حل عمليات البحث عن النطاق بواسطة موفر خدمة الإنترنت المحلي لديك. يمكن أن يؤدي هذا إلى الكشف عن موقعك الحقيقي على الشبكة.\\n\\nيقوم هذا الاختبار بزيارة المجالات التي تم إنشاؤها حديثًا والتحقق من مخرج DNS الذي يحلها. إذا كانت منطقة الخروج تتطابق مع منطقة مزود خدمة الإنترنت المحلي لديك، فقد يكون لديك تسرب DNS وقد تحتاج إلى ضبط إعدادات VPN أو الخادم الوكيل." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DNS-Lecks bedeuten, dass Domain-Abfragen nach dem Aktivieren eines VPN oder Proxys weiterhin über Ihren lokalen ISP aufgelöst werden können. Das kann Ihren tatsächlichen Netzwerkstandort offenlegen.\n\nDieser Test ruft neu generierte Domains auf und prüft, welcher DNS-Ausgang sie auflöst. Wenn dessen Region mit der Region Ihres lokalen ISP übereinstimmt, liegt möglicherweise ein DNS-Leck vor und Sie sollten Ihre VPN- oder Proxy-Einstellungen prüfen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "DNS leaks mean that after enabling a VPN or proxy, domain lookups may still be resolved by your local ISP. This can expose your real network location.\\n\\nThis test visits newly generated domains and checks which DNS exit resolves them. If the exit region matches your local ISP region, you may have a DNS leak and may need to adjust your VPN or proxy settings." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Una fuga de DNS significa que, después de activar una VPN o un proxy, las consultas de dominio aún pueden resolverse mediante tu ISP local. Esto puede revelar tu ubicación real de red.\n\nEsta prueba visita dominios recién generados y comprueba qué salida DNS los resuelve. Si la región de salida coincide con la de tu ISP local, puede haber una fuga de DNS y conviene revisar la configuración de VPN o proxy." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une fuite DNS signifie qu'après l'activation d'un VPN ou d'un proxy, les requêtes de domaine peuvent encore être résolues par votre FAI local. Cela peut révéler votre emplacement réseau réel.\n\nCe test visite des domaines nouvellement générés et vérifie quelle sortie DNS les résout. Si sa région correspond à celle de votre FAI local, vous avez peut-être une fuite DNS et devriez vérifier vos paramètres VPN ou proxy." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リークとは、VPN やプロキシを有効にしていても、ドメイン照会がローカル ISP によって解決される状態です。実際のネットワーク所在地が露出する可能性があります。\n\nこのテストでは、新しく生成したドメインにアクセスし、どの DNS 出口で解決されたかを確認します。出口の地域がローカル ISP の地域と一致する場合は DNS リークの可能性があるため、VPN またはプロキシ設定を確認してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 누출은 VPN 또는 프록시를 활성화한 뒤에도 도메인 조회가 로컬 ISP를 통해 해석될 수 있음을 의미합니다. 이 경우 실제 네트워크 위치가 노출될 수 있습니다.\n\n이 테스트는 새로 생성한 도메인에 접속해 어떤 DNS 출구에서 해석되는지 확인합니다. 출구 지역이 로컬 ISP 지역과 일치하면 DNS 누출 가능성이 있으므로 VPN 또는 프록시 설정을 확인하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Um vazamento de DNS significa que, depois de ativar uma VPN ou proxy, consultas de domínio ainda podem ser resolvidas pelo seu ISP local. Isso pode expor sua localização real na rede.\n\nEste teste acessa domínios recém-gerados e verifica qual saída DNS os resolve. Se a região de saída corresponder à região do seu ISP local, pode haver um vazamento de DNS e talvez seja preciso ajustar a VPN ou o proxy." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Утечка DNS означает, что после включения VPN или прокси доменные запросы всё еще могут обрабатываться локальным провайдером. Это может раскрыть ваше реальное сетевое местоположение.\n\nТест обращается к новым доменам и проверяет, через какой DNS-выход они разрешаются. Если регион выхода совпадает с регионом локального провайдера, возможна утечка DNS и стоит проверить настройки VPN или прокси." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DNS 泄露(DNS Leaks)的意思是,当你挂上 VPN/代理后,你解析域名时,依然通过当地的运营商进行解析,这时就有 DNS 泄露的风险。\\n\\n泄露测试的方法是通过访问新生成的域名,检测你是通过哪个地区的 DNS 出口进行解析,如果返回的出口区域和你当地的运营商区域相同,则有 DNS 泄露风险,你可能需要修改 VPN/代理设置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DNS 泄露(DNS Leaks)的意思是,當你掛上 VPN/代理後,你解析域名時,依然通過當地的運營商進行解析,這時就有 DNS 泄露的風險。\\n\\n泄露測試的方法是通過訪問新生成的域名,檢測你是通過哪個地區的 DNS 出口進行解析,如果返回的出口區域和你當地的運營商區域相同,則有 DNS 泄露風險,你可能需要修改 VPN/代理設定。" + } + } + } + }, + "dns.probe.name": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من خروج DNS #%d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DNS-Exit-Prüfung #%d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "DNS exit check #%d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobación de salida de DNS #%d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification de sortie DNS #%d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS 出口チェック #%d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 출구 확인 #%d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação de saída DNS #%d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка выхода DNS #%d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DNS 出口检测 #%d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DNS 出口檢測 #%d" + } + } + } + }, + "dns.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختبار تسرب DNS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DNS-Lecktest" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "DNS Leak Test" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Prueba de fuga de DNS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Test de fuite DNS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リークテスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 누출 테스트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Teste de vazamento de DNS" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тест на утечку DNS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DNS 泄漏测试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DNS 泄漏測試" + } + } + } + }, + "geo.proxy.notDetected": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم اكتشاف وكيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Proxy erkannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No proxy detected" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se detectó ningún proxy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun proxy détecté" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロキシが検出されませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프록시가 감지되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum proxy detectado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прокси-сервер не обнаружен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未识别为代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未識別為代理" + } + } + } + }, + "geo.proxy.possible": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوكيل المحتمل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Möglicher Proxy" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Possible proxy" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Posible proxy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Proxy possible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "可能なプロキシ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가능한 프록시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Possível proxy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возможный прокси" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可能为代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可能為代理" + } + } + } + }, + "info.network": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشبكة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Red" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réseau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rede" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網絡" + } + } + } + }, + "info.proxy": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوكيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Proxy" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Proxy" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Proxy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Proxy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロキシ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프록시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proxy" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прокси" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理" + } + } + } + }, + "info.timezone": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المنطقة الزمنية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitzone" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Time Zone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Zona horaria" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fuseau horaire" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイムゾーン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시간대" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fuso horário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Часовой пояс" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "时区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "時區" + } + } + } + }, + "info.type": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النوع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Typ" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイプ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유형" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tipo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тип" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "类型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "類型" + } + } + } + }, + "ipCard.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر الحصول على عنوان %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es konnte keine %@-Adresse abgerufen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not get a %@ address" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo obtener una dirección %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'obtenir une adresse %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ アドレスを取得できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 주소를 얻을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível obter um endereço %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось получить адрес %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取失败或不存在 %@ 地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "獲取失敗或不存在 %@ 地址" + } + } + } + }, + "ipCard.geoUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم تحميل معلومات الموقع أو أنها غير متاحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standortinformationen werden geladen oder sind nicht verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Location information is loading or unavailable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La información de ubicación se está cargando o no está disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les informations de localisation sont en cours de chargement ou indisponibles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "位置情報が読み込まれているか、利用できない" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위치 정보가 로드 중이거나 사용할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As informações de local estão sendo carregadas ou indisponíveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Информация о местоположении загружается или недоступна" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归属地信息获取中或不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歸屬地信息獲取中或不可用" + } + } + } + }, + "ipCard.ipv4.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مصدر IP: IPCheck.ing IPv4" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Quelle: IPCheck.ing IPv4" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Source: IPCheck.ing IPv4" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente IP: IPCheck.ing IPv4" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source IP : IPCheck.ing IPv4" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP ソース: IPCheck.ing IPv4" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 소스: IPCheck.ing IPv4" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fonte IP: IPCheck.ing IPv4" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник IP: IPCheck.ing IPv4" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 来源: IPCheck.ing IPv4" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 來源: IPCheck.ing IPv4" + } + } + } + }, + "ipCard.ipv6.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مصدر IP: IPCheck.ing IPv6" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Quelle: IPCheck.ing IPv6" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Source: IPCheck.ing IPv6" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuente IP: IPCheck.ing IPv6" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Source IP : IPCheck.ing IPv6" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP ソース: IPCheck.ing IPv6" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 소스: IPCheck.ing IPv6" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fonte IP: IPCheck.ing IPv6" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источник IP: IPCheck.ing IPv6" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 来源: IPCheck.ing IPv6" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 來源: IPCheck.ing IPv6" + } + } + } + }, + "landing.local": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محلي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokal" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Local" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Local" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Local" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Local" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Локальный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本地" + } + } + } + }, + "landing.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان IP العام والموقع والاتصال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP, Standort und Konnektivität" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Public IP, location, and connectivity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IP pública, ubicación y conectividad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IP publique, emplacement et connectivité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP、所在地、接続" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IP, 위치 및 연결" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IP público, localização e conectividade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Публичный IP, местоположение и подключение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "公网、归属地与连通性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "公網、歸屬地與連通性" + } + } + } + }, + "leak.error.noDNSExit": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مخرج DNS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es wurde kein DNS-Exit gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No DNS exit was found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna salida DNS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune sortie DNS n'a été trouvée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS 出口が見つかりませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 종료를 찾을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma saída DNS foi encontrada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выход DNS не найден" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未获取到 DNS 出口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未獲取到 DNS 出口" + } + } + } + }, + "leak.exitRegion": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "منطقة الخروج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgangsregion" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit Region" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Región de salida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Région de sortie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "出口地域" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출구 지역" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Região de saída" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Регион выхода" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "出口地区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "出口地區" + } + } + } + }, + "leak.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "獲取失敗" + } + } + } + }, + "leak.nat.portRestrictedOrSymmetric": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مقيد بالمنفذ أو متماثل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Portbeschränkt oder symmetrisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Port-restricted or symmetric" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puerto restringido o simétrico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Port restreint ou symétrique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポート制限または対称" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포트 제한 또는 대칭" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Restrito por porta ou simétrico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ограничение портов или симметричный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "端口限制型或对称型" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "端口限制型或對稱型" + } + } + } + }, + "leak.network": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشبكة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Red" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réseau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rede" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網絡" + } + } + } + }, + "local.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على عنوان محلي قابل للاستخدام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine verwendbare lokale Adresse gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No usable local address found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna dirección local utilizable" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune adresse locale utilisable trouvée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用可能なローカル アドレスが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 가능한 로컬 주소를 찾을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum endereço local utilizável encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не найден подходящий локальный адрес" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到可用本地地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到可用本地地址" + } + } + } + }, + "local.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان IP المحلي للشبكة المحلية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokale LAN-IP" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Local LAN IP" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IP de LAN local" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IP du réseau local" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカル LAN IP" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 LAN IP" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IP da LAN local" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "IP-адрес локальной сети" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地局域网 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本地局域網 IP" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض عنوان IP العام والعناوين المحلية والموقع." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP, lokale Adressen und Standort anzeigen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View public IP, local addresses, and location." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver IP pública, direcciones locales y ubicación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Affichez l'adresse IP publique, les adresses locales et l'emplacement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP、ローカルアドレス、所在地を表示します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IP, 로컬 주소, 위치를 확인하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver IP público, endereços locais e localização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотр публичного IP, локальных адресов и местоположения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看公网 IP、本地地址和归属地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看公網 IP、本地地址和歸屬地" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Verificación de IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP チェック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação de IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 檢測" + } + } + } + }, + "networkType.business": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شبكة الأعمال" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unternehmensnetzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Business network" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Red empresarial" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réseau d'entreprise" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ビジネスネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비즈니스 네트워크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rede empresarial" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Деловая сеть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "企业网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "企業網絡" + } + } + } + }, + "networkType.datacenter": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مركز البيانات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rechenzentrum" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Data center" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Centro de datos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Centre de données" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "データセンター" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데이터 센터" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Centro de dados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Центр обработки данных" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "数据中心" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "數據中心" + } + } + } + }, + "networkType.education": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شبكة التعليم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildungsnetzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Education network" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Red educativa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réseau éducatif" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "教育ネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "교육 네트워크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rede educacional" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Образовательная сеть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "教育网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "教育網絡" + } + } + } + }, + "networkType.mobile": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شبكة الهاتف المحمول" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mobilfunknetz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mobile network" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Red móvil" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réseau mobile" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイルネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모바일 네트워크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rede móvel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мобильная сеть" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動網絡" + } + } + } + }, + "networkType.residential": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سكني/نطاق عريض" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wohnbereich/Breitband" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Residential/broadband" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Residencial/banda ancha" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résidentiel/haut débit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "住宅/ブロードバンド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주거용/광대역" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Residencial/banda larga" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Жилой/широкополосный доступ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "住宅/宽带" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "住宅/寬帶" + } + } + } + }, + "panel.action.copyPublicIP": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ عنوان IP العام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Public IP" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar IP pública" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier l'adresse IP publique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP をコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IP 복사" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar IP público" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать публичный IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制公网 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製公網 IP" + } + } + } + }, + "panel.action.copyReport": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ النتيجة الكاملة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollständiges Ergebnis kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Full Result" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar resultado completo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier le résultat complet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果全体をコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 결과 복사" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar resultado completo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать полный результат" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制完整结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製完整結果" + } + } + } + }, + "panel.action.openDetails": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح التفاصيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Details öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir detalles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir les détails" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "세부정보 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir detalhes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть подробности" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開詳情" + } + } + } + }, + "panel.action.refreshAll": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث كافة عمليات التحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Prüfungen aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh All Checks" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar todas las comprobaciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser toutes les vérifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのチェックを更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 확인 새로고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar todas as verificações" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить все проверки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新全部检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新全部檢測" + } + } + } + }, + "panel.action.refreshing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erfrischend…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refreshing…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Refrescante…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rafraîchissant…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "さわやか…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновление…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新中..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新中..." + } + } + } + }, + "panel.subtitle.refreshing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحقق من عنوان IP العام..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP prüfen…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking public IP…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobando IP pública…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification de l'adresse IP publique…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP を確認しています…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공인 IP 확인 중…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando IP público…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка публичного IP…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检测公网 IP..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢測公網 IP..." + } + } + } + }, + "privacy.footer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطلب عمليات التحقق من عنوان IP العام والموقع والاتصال خدمات خارجية. تتم قراءة العناوين المحلية على جهاز Mac هذا فقط." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP-, Standort- und Konnektivitätsprüfungen fordern externe Dienste an. Lokale Adressen sind auf diesem Mac schreibgeschützt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Public IP, location, and connectivity checks request external services. Local addresses are read only on this Mac." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las comprobaciones de conectividad, ubicación y IP pública solicitan servicios externos. Las direcciones locales son de solo lectura en esta Mac." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les vérifications d'adresse IP publique, d'emplacement et de connectivité nécessitent des services externes. Les adresses locales sont en lecture seule sur ce Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP、所在地、接続の検査では外部サービスへリクエストします。ローカルアドレスはこの Mac 上でのみ読み取られます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IP, 위치, 연결 확인을 통해 외부 서비스를 요청합니다. 이 Mac에서는 로컬 주소를 읽을 수만 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "As verificações de IP público, localização e conectividade solicitam serviços externos. Os endereços locais são somente leitura neste Mac." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверки публичного IP, местоположения и подключения обращаются к внешним службам. Локальные адреса считываются только на этом Mac." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "公网、归属地和连通性检测会请求外部服务;本地地址仅在本机读取。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "公網、歸屬地和連通性檢測會請求外部服務;本地地址僅在本機讀取。" + } + } + } + }, + "report.city": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " المدينة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Stadt: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " City: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Ciudad: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Ville : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " 都市: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 도시: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Cidade: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Город: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 城市:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 城市:%@" + } + } + } + }, + "report.error": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Error: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "오류: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Erro: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "错误:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "錯誤:%@" + } + } + } + }, + "report.geo": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الموقع:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Location:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ubicación:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Emplacement :" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "所在地:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위치:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Localização:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Местоположение:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "归属地:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "歸屬地:" + } + } + } + }, + "report.localAddress": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@ : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@:%@" + } + } + } + }, + "report.localAddresses": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "العناوين المحلية:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lokale Adressen:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Local addresses:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Direcciones locales:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Adresses locales :" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ローカルアドレス:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬 주소:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Endereços locais:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Локальные адреса:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本地地址:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本地地址:" + } + } + } + }, + "report.network": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " الشبكة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Netzwerk: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Network: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Red: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Réseau : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " ネットワーク: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 네트워크: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Rede: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Сеть: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 网络:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 網絡:%@" + } + } + } + }, + "report.province": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " المنطقة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Region: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Region: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Región: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Région : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " 地域: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 지역: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Região: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Регион: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 省份:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 省份:%@" + } + } + } + }, + "report.proxy": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " الوكيل: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Proxy: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Proxy: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Proxy: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Mandataire : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " プロキシ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 프록시: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Proxy: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Прокси: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 代理:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 代理:%@" + } + } + } + }, + "report.publicIPv4": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "IPv4 العام: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IPv4: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Public IPv4: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IPv4 pública: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IPv4 publique : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IPv4: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IPv4: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IPv4 público: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Публичный IPv4: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "公网 IPv4:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "公網 IPv4:%@" + } + } + } + }, + "report.publicIPv6": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "IPv6 العام: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IPv6: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Public IPv6: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IPv6 pública: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IPv6 publique : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IPv6: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IPv6: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IPv6 público: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Публичный IPv6: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "公网 IPv6:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "公網 IPv6:%@" + } + } + } + }, + "report.region": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " المنطقة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Region: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Region: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Región: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Région : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " 地域: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 지역: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Região: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Регион: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 地区:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 地區:%@" + } + } + } + }, + "report.source": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " المصدر: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Quelle: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Source: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Fuente: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Source : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " ソース: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 출처: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Fonte: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Источник: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 来源:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 來源:%@" + } + } + } + }, + "report.source.failure": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: فشل (%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: fehlgeschlagen (%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: failed (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: falló (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@ : échec (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: 失敗しました (%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: 실패함(%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: falhou (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: ошибка (%@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@:失败(%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@:失敗(%@)" + } + } + } + }, + "report.source.success": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@ : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "- %@ %@:%@" + } + } + } + }, + "report.sources": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المصادر:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Quellen:" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sources:" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fuentes:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sources :" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ソース:" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출처:" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Fontes:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Источники:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测源:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測源:" + } + } + } + }, + "report.timezone": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " المنطقة الزمنية: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Zeitzone: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Time Zone: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Zona horaria: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Fuseau horaire : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " タイムゾーン: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 시간대: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Fuso horário: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Часовой пояс: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 时区:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 時區:%@" + } + } + } + }, + "report.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نتيجة فحص IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ergebnis der IP-Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "IP Check Result" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resultado de la comprobación de IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat de la vérification IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP チェック結果" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 확인 결과" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resultado da verificação de IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат проверки IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 检测结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 檢測結果" + } + } + } + }, + "report.type": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " النوع: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Typ: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " Type: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " Tipo: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " Type : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " タイプ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 유형: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": " Tipo: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " Тип: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 类型:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 類型:%@" + } + } + } + }, + "report.updatedAt": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التحديث: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisiert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Updated: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mise à jour : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新日: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업데이트됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновлено: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更新时间:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "更新時間:%@" + } + } + } + }, + "service.error.invalidIP": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان IP غير صالح: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige IP: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid IP: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IP no válida: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IP invalide : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な IP: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 IP: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IP inválido: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверный IP: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "IP 无效:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "IP 無效:%@" + } + } + } + }, + "service.error.invalidPayload": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنسيق البيانات غير صالح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiges Datenformat" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid data format" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Formato de datos no válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Format de données invalide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なデータ形式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 데이터 형식" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Formato de dados inválido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверный формат данных" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "数据格式不正确" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "數據格式不正確" + } + } + } + }, + "service.error.invalidResponse": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "استجابة غير صالحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige Antwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid response" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Respuesta no válida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réponse invalide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な応答" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 응답" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resposta inválida" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверный ответ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "响应无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "響應無效" + } + } + } + }, + "service.error.noPublicIP": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر الحصول على عنوان IP عام من مصادر خارجية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es konnte keine öffentliche IP von externen Quellen abgerufen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not get a public IP from external sources" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo obtener una IP pública de fuentes externas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'obtenir une adresse IP publique à partir de sources externes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外部ソースからパブリック IP を取得できませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외부 소스에서 공용 IP를 얻을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível obter um IP público de fontes externas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось получить публичный IP из внешних источников" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未能从外部检测源获取公网 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未能從外部檢測源獲取公網 IP" + } + } + } + }, + "status.checking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測中" + } + } + } + }, + "status.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتظار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待檢測" + } + } + } + }, + "stun.error.invalidPort": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "منفذ STUN غير صالح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiger STUN-Port" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid STUN port" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puerto STUN no válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Port STUN invalide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効な STUN ポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 STUN 포트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Porta STUN inválida" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неверный порт STUN" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "STUN 端口无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "STUN 端口無效" + } + } + } + }, + "stun.error.noMappedAddress": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم إرجاع أي عنوان خارجي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine externe Adresse zurückgegeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No external address returned" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se devolvió ninguna dirección externa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune adresse externe renvoyée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "外部アドレスが返されません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "외부 주소가 반환되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum endereço externo retornado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внешний адрес не возвращен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未返回外部地址" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未返回外部地址" + } + } + } + }, + "stun.error.timedOut": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة طلب STUN" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung der STUN-Anfrage" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "STUN request timed out" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo de espera de la solicitud STUN" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La requête STUN a expiré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "STUN リクエストがタイムアウトしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "STUN 요청 시간 초과" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A solicitação STUN expirou" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тайм-аут запроса STUN" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "STUN 请求超时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "STUN 請求超時" + } + } + } + }, + "webrtc.note": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينشئ WebRTC غالبًا اتصالات UDP مباشرة. إذا أعاد هذا الاختبار عنوان IP الحقيقي، فهذا يعني أن إعدادات الوكيل لديك لا تغطي تلك الاتصالات. بالإضافة إلى التحقق من عنوان IP المستخدم لـ WebRTC، يتحقق الاختبار من نوع NAT لديك. اكتشاف NAT ليس دقيقًا بالكامل وهو للمرجع فقط." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "WebRTC baut häufig direkte UDP-Verbindungen auf. Wenn dieser Test Ihre echte IP zurückgibt, decken Ihre Proxy-Einstellungen diese Verbindungen nicht ab. Zusätzlich zur IP, die WebRTC verwendet, wird auch Ihr NAT-Typ geprüft. Die NAT-Erkennung ist nicht vollständig genau und dient nur als Referenz." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "WebRTC often establishes direct UDP connections. If this test returns your real IP, your proxy settings are not covering those connections. In addition to checking the IP used for WebRTC, it also checks your NAT type. NAT detection is not fully accurate and is only for reference." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "WebRTC suele establecer conexiones UDP directas. Si esta prueba devuelve tu IP real, la configuración del proxy no cubre esas conexiones. Además de comprobar la IP usada por WebRTC, también comprueba tu tipo de NAT. La detección de NAT no es completamente exacta y solo sirve como referencia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "WebRTC établit souvent des connexions UDP directes. Si ce test renvoie votre véritable adresse IP, vos paramètres proxy ne couvrent pas ces connexions. En plus de vérifier l'adresse IP utilisée par WebRTC, il vérifie aussi votre type de NAT. La détection du NAT n'est pas totalement précise et n'est fournie qu'à titre indicatif." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC は直接 UDP 接続を確立することがあります。このテストで実際の IP が返る場合、プロキシ設定がそれらの接続をカバーしていません。WebRTC が使用する IP に加えて、NAT タイプも確認します。NAT タイプの検出は完全には正確ではなく、参考情報です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC는 직접 UDP 연결을 만드는 경우가 많습니다. 이 테스트에서 실제 IP가 반환되면 프록시 설정이 해당 연결을 포함하지 않는 것입니다. WebRTC에서 사용하는 IP와 함께 NAT 유형도 확인합니다. NAT 감지는 완전히 정확하지 않으며 참고용입니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O WebRTC costuma estabelecer conexões UDP diretas. Se este teste retornar seu IP real, as configurações de proxy não estão cobrindo essas conexões. Além de verificar o IP usado pelo WebRTC, ele também verifica o tipo de NAT. A detecção de NAT não é totalmente precisa e serve apenas como referência." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "WebRTC часто устанавливает прямые UDP-соединения. Если тест возвращает ваш реальный IP, настройки прокси не покрывают эти соединения. Помимо IP, используемого WebRTC, проверяется и тип NAT. Определение NAT не является полностью точным и приведено только для справки." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 往往通过 UDP 直连进行建立,如果测试返回了真实 IP,则意味着你的代理设置没有覆盖这些连接。除了检测你连接 WebRTC 时所使用的 IP,我们还会检测你的 NAT 类型。然而,NAT 类型的检测并非完全准确,仅供参考。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 往往通過 UDP 直連進行建立,如果測試返回了真實 IP,則意味着你的代理設定沒有覆蓋這些連接。除了檢測你連接 WebRTC 時所使用的 IP,我們還會檢測你的 NAT 類型。然而,NAT 類型的檢測並非完全準確,僅供參考。" + } + } + } + }, + "webrtc.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختبار تسرب WebRTC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "WebRTC-Lecktest" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "WebRTC Leak Test" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Prueba de fuga WebRTC" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Test de fuite WebRTC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC リークテスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 누출 테스트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Teste de vazamento WebRTC" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка утечки WebRTC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 泄露测试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 泄露測試" + } + } + } + }, + "common.refresh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リフレッシュ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + } + } + }, + "copy.ip.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انسخ %@ IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kopiere %@ IP" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy %@ IP" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar %@ IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier %@ IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ IP をコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ IP 복사" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar %@ IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать %@ IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制 %@ IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製 %@ IP" + } + } + } + }, + "copy.leakEndpoint.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ عنوان IP للخروج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgangs-IP kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy egress IP" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar IP de salida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier l'adresse IP de sortie" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "出力 IP をコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "송신 IP 복사" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar IP de saída" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать исходящий IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制出口 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製出口 IP" + } + } + } + }, + "detail.copyReport": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ النتيجة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ergebnis kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Result" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar resultado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier le résultat" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果をコピー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과 복사" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Copiar resultado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Копировать результат" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "複製結果" + } + } + } + }, + "detail.refresh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測" + } + } + } + }, + "detail.refreshing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測中" + } + } + } + }, + "dns.assessment.checking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق من DNS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DNS überprüfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking DNS" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobando DNS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification du DNS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS をチェックしています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando DNS" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка DNS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检测 DNS" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢測 DNS" + } + } + } + }, + "dns.assessment.clear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على تسرب DNS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein DNS-Leck gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No DNS leak found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna fuga de DNS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune fuite DNS trouvée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リークは見つかりませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 누출이 발견되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum vazamento de DNS encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Утечки DNS не обнаружено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未发现 DNS 泄露" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未發現 DNS 泄露" + } + } + } + }, + "dns.assessment.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نتيجة DNS غير مكتملة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DNS-Ergebnis unvollständig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "DNS result incomplete" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resultado de DNS incompleto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat DNS incomplet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS 結果が不完全" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 결과가 불완전합니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resultado de DNS incompleto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результат DNS неполный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DNS 结果不完整" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DNS 結果不完整" + } + } + } + }, + "dns.assessment.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "في انتظار فحص DNS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf DNS-Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for DNS check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando comprobación de DNS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente de vérification DNS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS チェックを待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 확인을 기다리는 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aguardando verificação de DNS" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание проверки DNS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待 DNS 检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待 DNS 檢測" + } + } + } + }, + "dns.assessment.warning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تسرب DNS محتمل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mögliches DNS-Leck" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Possible DNS leak" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Posible fuga de DNS" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fuite DNS possible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リークの可能性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 유출 가능성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Possível vazamento de DNS" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возможная утечка DNS" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可能存在 DNS 泄露" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可能存在 DNS 泄露" + } + } + } + }, + "egress.domestic": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الخروج المحلي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inländischer Ausgang" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Domestic egress" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salida nacional" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sortie nationale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国内出口" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국내 출구" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Saída nacional" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внутренний выход" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国内出口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國內出口" + } + } + } + }, + "egress.international": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الخروج الدولي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Internationaler Ausgang" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "International egress" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Salida internacional" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sortie internationale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国際出口" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국제 출구" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Saída internacional" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Международный выход" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国际出口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國際出口" + } + } + } + }, + "info.isp": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مزود خدمة الإنترنت" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ISP" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "ISP" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ISP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "FAI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ISP" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ISP" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "ISP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Интернет-провайдер" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运营商" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運營商" + } + } + } + }, + "info.location": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الموقع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Standort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Location" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ubicación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Emplacement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "場所" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "위치" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Localização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Местоположение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "位置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "位置" + } + } + } + }, + "landing.domestic": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "محلي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inland" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Domestic" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nacional" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Domestique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国内" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국내" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Doméstico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внутренний" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国内出口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國內出口" + } + } + } + }, + "landing.international": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "دولية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "International" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "International" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Internacional" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "International" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国際" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Internacional" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Международный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国际出口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國際出口" + } + } + } + }, + "leak.badge.checking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測中" + } + } + } + }, + "leak.badge.clear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ناجح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestanden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Passed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aprobado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réussi" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合格" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "통과" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aprovado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пройдено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "通過" + } + } + } + }, + "leak.badge.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法判断" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法判斷" + } + } + } + }, + "leak.badge.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الانتظار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "待機中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대기 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esperando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "待检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "待檢測" + } + } + } + }, + "leak.badge.warning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتباه" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Achtung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Attention" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Atención" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Attention" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "注意" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주의" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atenção" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внимание" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "注意" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "注意" + } + } + } + }, + "leak.details.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض تفاصيل الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfdetails anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View check details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver detalles de la comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les détails de la vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック詳細を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 세부정보 보기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver detalhes da verificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотреть сведения о проверке" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看检测详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看檢測詳情" + } + } + } + }, + "leak.details.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفاصيل الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfdetails" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check Details" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detalles de la comprobación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détails de la vérification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック詳細" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 세부정보" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificar detalhes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сведения о проверке" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测详情" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測詳情" + } + } + } + }, + "leak.evidence.count": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d/%d العقد التي تم الإبلاغ عنها" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d/%d Knoten gemeldet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d/%d nodes reported" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d/%d nodos reportados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nœuds %d/%d signalés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d/%d ノードが報告されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d/%d 노드가 보고됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d/%d nós relatados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сообщено об узлах %d/%d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d/%d 个节点有结果" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d/%d 個節點有結果" + } + } + } + }, + "leak.observed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت ملاحظته" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beobachtet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Observed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Observado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Observé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "観測済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관찰" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Observado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Замечено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已获取" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已獲取" + } + } + } + }, + "leak.reason.checking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جمع معلومات الخروج التي يتم إرجاعها بواسطة كل عقدة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sammeln der von jedem Knoten zurückgegebenen Ausgangsinformationen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Collecting egress information returned by each node." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recopilación de información de salida devuelta por cada nodo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Collecte des informations de sortie renvoyées par chaque nœud." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各ノードから返された出口情報を収集しています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "각 노드에서 반환된 송신 정보를 수집합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Coletando informações de saída retornadas por cada nó." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбор исходящей информации, возвращаемой каждым узлом." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在收集各节点返回的出口信息。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在收集各節點返回的出口信息。" + } + } + } + }, + "leak.reason.dnsDifferentEgressRegion": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تختلف منطقة محلل DNS عن منطقة عنوان IP العام الحالي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die DNS-Resolver-Region unterscheidet sich vom aktuellen öffentlichen Ausgang." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The DNS resolver region differs from the current public egress." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La región del resolutor DNS difiere de la región de la IP pública actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La région du résolveur DNS diffère de celle de l'IP publique actuelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リゾルバーの地域が現在のパブリック IP の地域と異なります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 리졸버 지역이 현재 공인 IP 지역과 다릅니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A região do resolvedor DNS é diferente da saída pública atual." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Регион DNS-резолвера отличается от региона текущего публичного IP." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DNS 解析器出口地区与当前公网出口不一致。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DNS 解析器出口地區與當前公網出口不一致。" + } + } + } + }, + "leak.reason.dnsMatchesEgressRegion": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطابق منطقة محلل DNS منطقة عنوان IP العام الحالي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die DNS-Resolver-Region entspricht dem aktuellen öffentlichen Ausgang." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The DNS resolver region matches the current public egress." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La región del resolutor DNS coincide con la región de la IP pública actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La région du résolveur DNS correspond à celle de l'IP publique actuelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リゾルバーの地域が現在のパブリック IP の地域と一致します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 리졸버 지역이 현재 공인 IP 지역과 일치합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A região do resolvedor DNS corresponde à saída pública atual." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Регион DNS-резолвера совпадает с регионом текущего публичного IP." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "DNS 解析器出口地区与当前公网出口一致。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "DNS 解析器出口地區與當前公網出口一致。" + } + } + } + }, + "leak.reason.dnsObservedWithoutBaselineRegion": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم العثور على مخرج DNS، لكن بيانات الموقع العامة غير كافية للمقارنة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "DNS-Ausgangsdaten wurden gefunden, aber die öffentlichen Standortdaten reichen für einen Vergleich nicht aus." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "DNS egress was found, but public location data is insufficient for comparison." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se encontró salida de DNS, pero los datos de ubicación pública no son suficientes para comparar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Une sortie DNS a été trouvée, mais les données de localisation publique sont insuffisantes pour permettre une comparaison." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS 出口は取得できましたが、比較に必要なパブリック IP の所在地情報が不足しています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 출구는 확인했지만 비교할 공인 IP 위치 데이터가 부족합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A saída de DNS foi encontrada, mas os dados de localização pública são insuficientes para comparação." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "DNS-выход найден, но данных о местоположении публичного IP недостаточно для сравнения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已获取 DNS 出口,但公网归属地不足,暂时无法对照。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已獲取 DNS 出口,但公網歸屬地不足,暫時無法對照。" + } + } + } + }, + "leak.reason.noDNSEndpoint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مخرج لمحلل DNS؛ ربما تقوم الشبكة أو الخدمة بحظره." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein DNS-Resolver-Ausgang gefunden; das Netzwerk oder der Dienst blockiert ihn möglicherweise." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No DNS resolver egress was found; the network or service may be blocking it." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna salida del resolutor DNS; puede que la red o el servicio la estén bloqueando." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune sortie du résolveur DNS n’a été trouvée ; le réseau ou le service peut le bloquer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "DNS リゾルバーの出口が見つかりません。ネットワークまたはサービスでブロックされている可能性があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "DNS 확인자 송신을 찾을 수 없습니다. 네트워크나 서비스가 이를 차단하고 있을 수 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma saída do resolvedor DNS foi encontrada; a rede ou serviço pode estar bloqueando." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выходной канал DNS-преобразователя не найден; сеть или служба могут блокировать его." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有获取到 DNS 解析器出口,可能被网络或服务拦截。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有獲取到 DNS 解析器出口,可能被網絡或服務攔截。" + } + } + } + }, + "leak.reason.noPublicIP": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان IP العام الحالي ليس جاهزًا، لذا لا يوجد خط أساس بعد." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die aktuelle öffentliche IP ist noch nicht bereit, daher gibt es noch keine Basislinie." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Current public IP is not ready, so there is no baseline yet." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La IP pública actual no está lista, por lo que aún no existe una línea de base." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'adresse IP publique actuelle n'est pas prête, il n'y a donc pas encore de référence." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のパブリック IP の準備ができていないため、ベースラインはまだありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 공용 IP가 준비되지 않아 아직 기준이 없습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O IP público atual não está pronto, portanto ainda não há uma linha de base." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущий публичный IP еще не готов, поэтому базовая точка пока недоступна." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前公网 IP 未获取完成,暂时无法建立对照。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前公網 IP 未獲取完成,暫時無法建立對照。" + } + } + } + }, + "leak.reason.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بالتحديث للتحقق مما إذا كان الخروج المرئي يطابق عنوان IP العام الحالي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisieren Sie, um zu prüfen, ob der sichtbare ausgehende Traffic mit der aktuellen öffentlichen IP übereinstimmt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh to check whether visible egress matches the current public IP." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualice para comprobar si la salida visible coincide con la IP pública actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualisez pour vérifier si la sortie visible correspond à l'adresse IP publique actuelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新して、見えている出口が現在のパブリック IP と一致するか確認します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로 고침하여 표시되는 송신이 현재 공용 IP와 일치하는지 확인하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualize para verificar se a saída visível corresponde ao IP público atual." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновите, чтобы проверить, соответствует ли видимый выход текущему публичному IP." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击刷新后会检测出口是否和当前公网信息一致。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊刷新後會檢測出口是否和當前公網信息一致。" + } + } + } + }, + "leak.reason.webRTCDifferentIP": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أرجعت STUN عنوان IP للخروج مختلفًا عن عنوان IP العام الحالي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "STUN hat eine Ausgangs-IP zurückgegeben, die sich von der aktuellen öffentlichen IP unterscheidet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "STUN returned an egress IP different from the current public IP." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "STUN devolvió una IP de salida diferente de la IP pública actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "STUN a renvoyé une adresse IP de sortie différente de l'adresse IP publique actuelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "STUN が現在のパブリック IP とは異なる出口 IP を返しました。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "STUN이 현재 공용 IP와 다른 송신 IP를 반환했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "STUN retornou um IP de saída diferente do IP público atual." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "STUN вернул выходной IP, отличный от текущего публичного IP." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "STUN 返回了不同于当前公网 IP 的出口。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "STUN 返回了不同於當前公網 IP 的出口。" + } + } + } + }, + "leak.reason.webRTCMatchesPublicIP": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطابق مخرج STUN مع عنوان IP العام الحالي." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der STUN-Ausgang stimmt mit der aktuellen öffentlichen IP überein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The STUN egress matches the current public IP." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La salida de STUN coincide con la IP pública actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La sortie STUN correspond à l'adresse IP publique actuelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "STUN の出口が現在のパブリック IP と一致しています。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "STUN 송신은 현재 공용 IP와 일치합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A saída STUN corresponde ao IP público atual." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выход STUN совпадает с текущим публичным IP." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "STUN 返回的出口与当前公网 IP 一致。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "STUN 返回的出口與當前公網 IP 一致。" + } + } + } + }, + "leak.reason.webRTCNoVisibleEndpoint": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مخرج WebRTC مرئي؛ قد يكون STUN محظورًا أو غير قابل للوصول." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es wurde kein sichtbarer WebRTC-Ausgang gefunden; STUN ist möglicherweise blockiert oder nicht erreichbar." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No visible WebRTC egress was found; STUN may be blocked or unreachable." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna salida visible de WebRTC; STUN puede estar bloqueado o ser inalcanzable." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune sortie WebRTC visible n'a été trouvée ; STUN peut être bloqué ou inaccessible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示可能な WebRTC 出口が見つかりません。STUN がブロックされているか到達できない可能性があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "표시 가능한 WebRTC 출구를 찾을 수 없습니다. STUN이 차단되었거나 도달할 수 없을 수 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma saída WebRTC visível foi encontrada; STUN pode estar bloqueado ou inacessível." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Видимый выход WebRTC не найден; STUN может быть заблокирован или недоступен." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未检测到 WebRTC 可见出口,通常表示 STUN 被阻断或不可达。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未檢測到 WebRTC 可見出口,通常表示 STUN 被阻斷或不可達。" + } + } + } + }, + "panel.button.check": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحقق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prüfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérifier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チェック" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢測" + } + } + } + }, + "privacy.hideIP.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخفاء عناوين IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Adressen ausblenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide IP addresses" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ocultar direcciones IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Masquer les adresses IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP アドレスを非表示にする" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 주소 숨기기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ocultar endereços IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрыть IP-адреса" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏 IP" + } + } + } + }, + "privacy.showIP.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إظهار عناوين IP" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "IP-Adressen anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show IP addresses" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar direcciones IP" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les adresses IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP アドレスを表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "IP 주소 표시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mostrar endereços IP" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать IP-адреса" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显示 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "顯示 IP" + } + } + } + }, + "publicIP.refresh.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث عنوان IP العام والموقع" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP und Standort aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh public IP and location" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar IP pública y ubicación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser l'adresse IP publique et l'emplacement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP と所在地を更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IP 및 위치 새로고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar IP público e localização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить публичный IP и местоположение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新公网 IP 和归属地" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新公網 IP 和歸屬地" + } + } + } + }, + "publicIP.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عنوان IP العام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffentliche IP" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Public IP" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IP pública" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "IP publique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パブリック IP" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공용 IP" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "IP público" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Публичный IP" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "公网 IP" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "公網 IP" + } + } + } + }, + "report.domesticIPv4": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خروج محلي IPv4: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inländischer Ausgang IPv4: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Domestic egress IPv4: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IPv4 de salida nacional: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sortie nationale IPv4 : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国内出口 IPv4: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국내 송신 IPv4: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Saída doméstica IPv4: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внутренний исходящий IPv4: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国内出口 IPv4:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國內出口 IPv4:%@" + } + } + } + }, + "report.domesticIPv6": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خروج محلي IPv6: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inländischer Ausgang IPv6: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Domestic egress IPv6: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "IPv6 de salida nacional: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sortie nationale IPv6 : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "国内出口 IPv6: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "국내 송신 IPv6: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Saída doméstica IPv6: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внутренний исходящий IPv6: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "国内出口 IPv6:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "國內出口 IPv6:%@" + } + } + } + }, + "speed.download": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنزيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Baixar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + } + } + }, + "speed.duration.seconds": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%.1f ثانية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%.1f Sek." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%.1f sec" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%.1f seg" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%.1f s" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%.1f 秒" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%.1f초" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%.1f segundos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%.1f сек" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%.1f 秒" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%.1f 秒" + } + } + } + }, + "speed.error.cancelled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إلغاء اختبار السرعة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeitstest abgebrochen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Speed test cancelled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Prueba de velocidad cancelada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Test de vitesse annulé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スピードテストがキャンセルされました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "속도 테스트 취소됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Teste de velocidade cancelado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка скорости отменена" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测速已取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測速已取消" + } + } + } + }, + "speed.error.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل اختبار السرعة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeitstest fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Speed test failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La prueba de velocidad falló" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec du test de vitesse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "速度テストに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "속도 테스트 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha no teste de velocidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка скорости не удалась" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测速失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測速失敗" + } + } + } + }, + "speed.error.invalidOutput": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إخراج اختبار السرعة غير قابل للقراءة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeitstest-Ausgabe ist nicht lesbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Speed test output is not readable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La salida de la prueba de velocidad no es legible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La sortie du test de vitesse n'est pas lisible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "速度テストの出力が読み取れません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "속도 테스트 출력을 읽을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A saída do teste de velocidade não é legível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вывод теста скорости не читается" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测速结果格式不可读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測速結果格式不可讀" + } + } + } + }, + "speed.error.unavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جودة الشبكة غير متوفرة على هذا النظام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "networkQuality ist auf diesem System nicht verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "networkQuality is not available on this system" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "networkQuality no está disponible en este sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "networkQuality n'est pas disponible sur ce système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このシステムでは networkQuality を利用できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 시스템에서는 networkQuality를 사용할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "networkQuality não está disponível neste sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "сетевое качество недоступно в этой системе" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前系统不可用 networkQuality" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前系統不可用 networkQuality" + } + } + } + }, + "speed.failed.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل اختبار السرعة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeitstest fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Speed test failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La prueba de velocidad falló" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le test de vitesse a échoué" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "速度テストに失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "속도 테스트 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha no teste de velocidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка скорости не удалась" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测速失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測速失敗" + } + } + } + }, + "speed.grade.excellent": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ممتاز" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgezeichnet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Excellent" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Excelente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Excellent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "素晴らしい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "훌륭함" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excelente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отлично" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "优秀" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "優秀" + } + } + } + }, + "speed.grade.fair": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عادل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausreichend" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fair" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Justo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Juste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フェア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공정하다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Justo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ярмарка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "一般" + } + } + } + }, + "speed.grade.good": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جيد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gut" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Good" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Bien" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bien" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "良い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "좋아요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Bom" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хорошо" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "良好" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "良好" + } + } + } + }, + "speed.grade.poor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بطيء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Langsam" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Slow" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "遅い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "느림" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Медленно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "偏慢" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "偏慢" + } + } + } + }, + "speed.latency": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "زمن الوصول" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Latenz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Latency" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Latencia" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Latence" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイテンシ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지연" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Latência" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Задержка" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "延迟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "延遲" + } + } + } + }, + "speed.phase.download": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قياس التنزيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download messen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Measuring download" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descarga de mediciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement des mesures" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードを測定中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "측정 다운로드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Medição de download" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Измерение загрузки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测量下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測量下載" + } + } + } + }, + "speed.phase.initializing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحضير اختبار السرعة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeitstest wird vorbereitet" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing speed test" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Preparando prueba de velocidad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Préparation du test de vitesse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スピードテストの準備中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "속도 테스트 준비 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Preparando teste de velocidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подготовка теста скорости" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "准备测速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "準備測速" + } + } + } + }, + "speed.phase.latency": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قياس زمن الاستجابة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Latenz messen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Measuring latency" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Medición de latencia" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mesurer la latence" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レイテンシの測定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지연 시간 측정" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Medindo a latência" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Измерение задержки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测量延迟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測量延遲" + } + } + } + }, + "speed.phase.upload": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قياس التحميل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Upload messen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Measuring upload" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carga de mediciones" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargement de mesures" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップロードの測定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업로드 측정" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Medindo o upload" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Измерение загрузки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测量上传" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測量上傳" + } + } + } + }, + "speed.responsiveness": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستجابة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Reaktionsfähigkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Responsiveness" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Capacidad de respuesta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réactivité" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "応答性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "응답성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Responsividade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отзывчивость" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "响应性" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "響應性" + } + } + } + }, + "speed.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاختبار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Testen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Testing" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pruebas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tests" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테스트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Teste" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тестирование" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "测速中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "測速中" + } + } + } + }, + "speed.start.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بقياس الشبكة الحالية باستخدام جودة شبكة macOS" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Messen Sie das aktuelle Netzwerk mit macOS networkQuality" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Measure the current network with macOS networkQuality" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mida la red actual con macOS networkQuality" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mesurer le réseau actuel avec macOS networkQuality" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "macOS networkQuality を使用して現在のネットワークを測定する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "macOS networkQuality로 현재 네트워크 측정" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Meça a rede atual com o macOS networkQuality" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Измерение текущей сети с помощью macOS networkQuality" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用 macOS networkQuality 测量当前网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用 macOS networkQuality 測量當前網絡" + } + } + } + }, + "speed.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سرعة الشبكة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerkgeschwindigkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network Speed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Velocidad de red" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vitesse du réseau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク速度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크 속도" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Velocidade da rede" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скорость сети" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络测速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網絡測速" + } + } + } + }, + "speed.upload": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحميل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hochladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upload" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Subir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Télécharger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "업로드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Enviar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上传" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上傳" + } + } + } + }, + "speed.waiting.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قم بالتحديث لقياس التنزيل والتحميل ووقت الاستجابة والاستجابة بالتسلسل." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisieren, um Download, Upload, Latenz und Reaktionsfähigkeit nacheinander zu messen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh to measure download, upload, latency, and responsiveness in sequence." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualice para medir la descarga, carga, latencia y capacidad de respuesta en secuencia." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualisez pour mesurer le téléchargement, l’envoi, la latence et la réactivité dans l’ordre." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新して、ダウンロード、アップロード、遅延、応答性を順番に測定します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드, 업로드, 대기 시간 및 응답성을 순서대로 측정하려면 새로고침하세요." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualize para medir download, upload, latência e capacidade de resposta em sequência." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновите, чтобы последовательно измерить загрузку, выгрузку, задержку и скорость отклика." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击刷新后,会顺序测量下载、上传、延迟和响应性。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊刷新後,會順序測量下載、上傳、延遲和響應性。" + } + } + } + }, + "webrtc.assessment.checking": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جاري التحقق من WebRTC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Überprüfe WebRTC" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Checking WebRTC" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comprobando WebRTC" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vérification de WebRTC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC を確認中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 확인 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando WebRTC" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проверка WebRTC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在检测 WebRTC" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在檢測 WebRTC" + } + } + } + }, + "webrtc.assessment.clear": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على تسرب WebRTC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein WebRTC-Leck gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No WebRTC leak found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna fuga WebRTC" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune fuite WebRTC trouvée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC リークは見つかりませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 누출이 발견되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum vazamento de WebRTC encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Утечки WebRTC не обнаружено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未发现 WebRTC 泄露" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未發現 WebRTC 泄露" + } + } + } + }, + "webrtc.assessment.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "WebRTC النتيجة غير مكتملة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "WebRTC Ergebnis unvollständig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "WebRTC result incomplete" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "WebRTC resultado incompleto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultat WebRTC incomplet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 結果が不完全" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 결과가 불완전함" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "WebRTC resultado incompleto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "WebRTC результат неполный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 结果不完整" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 結果不完整" + } + } + } + }, + "webrtc.assessment.waiting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "في انتظار فحص WebRTC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf WebRTC-Prüfung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for WebRTC check" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando comprobación WebRTC" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente du contrôle WebRTC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC チェックを待っています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 확인을 기다리는 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aguardando verificação de WebRTC" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание проверки WebRTC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待 WebRTC 检测" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待 WebRTC 檢測" + } + } + } + }, + "webrtc.assessment.warning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "احتمال تسرب WebRTC" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mögliches WebRTC-Leck" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Possible WebRTC leak" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Posible fuga de WebRTC" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Fuite possible de WebRTC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebRTC リークの可能性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WebRTC 누출 가능성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Possível vazamento de WebRTC" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Возможная утечка WebRTC" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可能存在 WebRTC 泄露" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可能存在 WebRTC 泄露" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/IPOverview/Sources/IPOverviewComponentView.swift b/Plugins/IPOverview/Sources/IPOverviewComponentView.swift new file mode 100644 index 0000000..3773f5b --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewComponentView.swift @@ -0,0 +1,1771 @@ +import SwiftUI +import MacToolsPluginKit + +struct IPOverviewComponentView: View { + private enum Layout { + static let leakCardMinimumWidth: CGFloat = 240 + static let leakCardMaximumWidth: CGFloat = 360 + } + + @ObservedObject var viewModel: IPOverviewViewModel + let localization: PluginLocalization + let startsInDetails: Bool + let showsBackButton: Bool + @State private var customName = "" + @State private var customURL = "" + @State private var addError = "" + @State private var didRunInitialDiagnostics = false + @State private var presentedLeakDetails: IPOverviewLeakAssessmentKind? + + init( + viewModel: IPOverviewViewModel, + localization: PluginLocalization = PluginLocalization(bundle: .main), + startsInDetails: Bool = false, + showsBackButton: Bool = true + ) { + self.viewModel = viewModel + self.localization = localization + self.startsInDetails = startsInDetails + self.showsBackButton = showsBackButton + } + + var body: some View { + Group { + if viewModel.isShowingDetails { + detailPage + } else { + landingCard + } + } + .onAppear { + viewModel.refreshIfNeeded() + if startsInDetails { + viewModel.showDetails() + } + } + } + + private var landingCard: some View { + Button { + viewModel.showDetails() + } label: { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 9) { + Image(systemName: "network") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(nsColor: .systemBlue)) + .frame(width: 30, height: 30) + .background(.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + + VStack(alignment: .leading, spacing: 1) { + Text(localization.string("component.title", defaultValue: "IP 检测")) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(viewModel.snapshot.preferredGeoInfo?.locationText ?? localization.string( + "landing.subtitle", + defaultValue: "公网、归属地与连通性" + )) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + if viewModel.snapshot.isRefreshing { + ProgressView() + .controlSize(.small) + } + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + + HStack(alignment: .top, spacing: 8) { + landingMetric( + title: localization.string("landing.domestic", defaultValue: "国内出口"), + value: landingEgressValue( + viewModel.snapshot.domesticIPv4 ?? viewModel.snapshot.domesticIPv6 + ) + ) + landingMetric( + title: localization.string("landing.international", defaultValue: "国际出口"), + value: landingEgressValue( + viewModel.snapshot.internationalIPv4 ?? viewModel.snapshot.internationalIPv6 + ) + ) + landingMetric( + title: localization.string("landing.local", defaultValue: "本地"), + value: viewModel.snapshot.localAddresses.first.map { displayIP($0.address) } + ?? localization.string("common.waiting", defaultValue: "等待") + ) + } + } + .padding(10) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var detailPage: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + detailHeader + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + networkQualitySection + publicIPSection + localAddressSection + connectivitySection + webRTCLeakSection + dnsLeakSection + privacyFooter + } + .padding(.bottom, 2) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .onAppear { + guard !didRunInitialDiagnostics else { + return + } + + didRunInitialDiagnostics = true + viewModel.refreshAllIfNeeded() + } + } + + private var detailHeader: some View { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + if showsBackButton { + Button { + viewModel.showSummary() + } label: { + Image(systemName: "chevron.left") + } + .buttonStyle(.borderless) + .help(localization.string("detail.back.help", defaultValue: "返回概览")) + } + + Text(localization.string("detail.title", defaultValue: "IP 详情")) + .font(PluginSettingsTheme.Typography.sectionTitle) + + Spacer(minLength: 8) + + if viewModel.snapshot.isRefreshing { + ProgressView() + .controlSize(.small) + } + + Button { + viewModel.toggleSensitiveInfoVisibility() + } label: { + Image(systemName: viewModel.hidesSensitiveInfo ? "eye.slash" : "eye") + .frame(width: 14, height: 14) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help(viewModel.hidesSensitiveInfo + ? localization.string("privacy.showIP.help", defaultValue: "显示 IP") + : localization.string("privacy.hideIP.help", defaultValue: "隐藏 IP") + ) + + Button { + viewModel.refreshAll() + } label: { + Label( + viewModel.isRefreshingAll + ? localization.string("detail.refreshing", defaultValue: "检测中") + : localization.string("detail.refresh", defaultValue: "检测"), + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(viewModel.isRefreshingAll) + .help(localization.string("detail.refresh.help", defaultValue: "刷新全部检测")) + + Button { + viewModel.copy(viewModel.snapshot.reportText(localization: localization)) + } label: { + Label(localization.string("detail.copyReport", defaultValue: "复制结果"), systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.snapshot.lastUpdated == nil) + .help(localization.string("detail.copyReport.help", defaultValue: "复制完整检测结果")) + } + } + + private var publicIPSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + sectionHeader( + title: localization.string("publicIP.title", defaultValue: "公网 IP"), + icon: "network" + ) + Spacer(minLength: 4) + Button { + viewModel.refresh() + } label: { + Label( + viewModel.snapshot.isRefreshing + ? localization.string("status.checking", defaultValue: "检测中") + : localization.string("common.refresh", defaultValue: "刷新"), + systemImage: viewModel.snapshot.isRefreshing ? "hourglass" : "arrow.clockwise" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.snapshot.isRefreshing) + .help(localization.string("publicIP.refresh.help", defaultValue: "刷新公网 IP 和归属地")) + } + + VStack(spacing: 0) { + publicIPRow( + title: "IPv4", + route: .domestic, + result: viewModel.snapshot.domesticIPv4 + ) + PluginSettingsListDivider() + publicIPRow( + title: "IPv4", + route: .international, + result: viewModel.snapshot.internationalIPv4 + ) + PluginSettingsListDivider() + publicIPRow( + title: "IPv6", + route: .domestic, + result: viewModel.snapshot.domesticIPv6 + ) + PluginSettingsListDivider() + publicIPRow( + title: "IPv6", + route: .international, + result: viewModel.snapshot.internationalIPv6 + ) + } + .pluginSettingsCardBackground(.host) + } + } + + private func publicIPRow( + title: String, + route: IPOverviewEgressRoute, + result: IPOverviewPublicIPResult? + ) -> some View { + let geoInfo = result.flatMap { viewModel.snapshot.geoInfoByIP[$0.ip] } + + return PublicIPRowView( + title: title, + route: route, + result: result, + geoInfo: geoInfo, + notDetectedText: notDetectedText, + geoUnavailableText: localization.string("ipCard.geoUnavailable", defaultValue: "归属地不可用"), + unknownText: unknownText, + localization: localization, + displayIP: result.map { displayIP($0.ip) }, + onCopyIP: { viewModel.copy(result?.ip) } + ) + .pluginSettingsListRowPadding(interactive: true) + } + + private var localAddressSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + sectionHeader(title: localization.string("local.title", defaultValue: "本地局域网 IP"), icon: "wifi.router") + + VStack(spacing: 0) { + if viewModel.snapshot.localAddresses.isEmpty { + Text(localization.string("local.empty", defaultValue: "未检测到可用本地地址")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .pluginSettingsListRowPadding() + } else { + ForEach(Array(viewModel.snapshot.localAddresses.prefix(5).enumerated()), id: \.element.id) { index, address in + localAddressRow(address) + if index < min(viewModel.snapshot.localAddresses.count, 5) - 1 { + PluginSettingsListDivider() + } + } + } + } + .pluginSettingsCardBackground(.host) + } + } + + private func localAddressRow(_ address: IPOverviewLocalAddress) -> some View { + LocalAddressRowView( + address: address, + displayAddress: displayIP(address.address), + localization: localization, + onCopyIP: { viewModel.copy(address.address) } + ) + .pluginSettingsListRowPadding() + } + + private var networkQualitySection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + sectionHeader( + title: localization.string("speed.title", defaultValue: "网络测速"), + icon: "speedometer" + ) + Spacer(minLength: 4) + Button { + viewModel.measureNetworkQuality() + } label: { + Label( + viewModel.isMeasuringNetworkQuality + ? localization.string("speed.running", defaultValue: "测速中") + : localization.string("common.refresh", defaultValue: "刷新"), + systemImage: viewModel.isMeasuringNetworkQuality ? "hourglass" : "arrow.clockwise" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.isMeasuringNetworkQuality) + .help(localization.string("speed.start.help", defaultValue: "使用 macOS networkQuality 测量当前网络")) + } + + NetworkQualityCardView( + state: viewModel.networkQualityState, + localization: localization + ) + } + } + + private var connectivitySection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + sectionHeader( + title: localization.string("connectivity.title", defaultValue: "网络连通性"), + icon: "point.3.connected.trianglepath.dotted" + ) + Spacer(minLength: 4) + Button { + viewModel.checkConnectivity() + } label: { + Label( + viewModel.isCheckingConnectivity + ? localization.string("status.checking", defaultValue: "检测中") + : localization.string("common.refresh", defaultValue: "刷新"), + systemImage: viewModel.isCheckingConnectivity ? "hourglass" : "arrow.clockwise" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.isCheckingConnectivity) + .help(localization.string("connectivity.refresh.help", defaultValue: "刷新连通性")) + } + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ], spacing: 8) { + ForEach(viewModel.connectivityResults) { result in + connectivityTile(result) + } + } + + customTargetForm + } + .padding(PluginSettingsTheme.Spacing.cardContent) + .pluginSettingsCardBackground(.host) + } + } + + private var webRTCLeakSection: some View { + leakSection( + kind: .webRTC, + title: localization.string("webrtc.title", defaultValue: "WebRTC 泄露测试"), + icon: "network.badge.shield.half.filled", + isRunning: viewModel.isCheckingWebRTC, + action: { viewModel.checkWebRTCLeak() }, + results: viewModel.webRTCResults, + showsNAT: true + ) + } + + private var dnsLeakSection: some View { + leakSection( + kind: .dns, + title: localization.string("dns.title", defaultValue: "DNS 泄露测试"), + icon: "octagon.fill", + isRunning: viewModel.isCheckingDNSLeak, + action: { viewModel.checkDNSLeak() }, + results: viewModel.dnsLeakResults, + showsNAT: false + ) + } + + private func leakSection( + kind: IPOverviewLeakAssessmentKind, + title: String, + icon: String, + isRunning: Bool, + action: @escaping () -> Void, + results: [IPOverviewLeakTestResult], + showsNAT: Bool + ) -> some View { + let assessment = IPOverviewLeakAssessment.evaluate( + kind: kind, + results: results, + snapshot: viewModel.snapshot, + isRunning: isRunning + ) + + return VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + Spacer(minLength: 4) + Button(action: action) { + Label( + isRunning + ? localization.string("status.checking", defaultValue: "检测中") + : localization.string("common.refresh", defaultValue: "刷新"), + systemImage: isRunning ? "hourglass" : "arrow.clockwise" + ) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isRunning) + } + + leakSummaryCard(assessment, results: results, showsNAT: showsNAT) + } + } + + private func leakSummaryCard( + _ assessment: IPOverviewLeakAssessment, + results: [IPOverviewLeakTestResult], + showsNAT: Bool + ) -> some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: leakAssessmentIcon(assessment.state)) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(leakAssessmentColor(assessment.state)) + .frame(width: 20, height: 20) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(leakAssessmentTitle(assessment)) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .foregroundStyle(leakAssessmentColor(assessment.state)) + Text(leakAssessmentDetail(assessment)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + HStack(spacing: 6) { + Text(leakAssessmentBadge(assessment)) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(leakAssessmentColor(assessment.state)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + leakAssessmentColor(assessment.state).opacity(0.12), + in: Capsule() + ) + .fixedSize() + + Button { + presentedLeakDetails = assessment.kind + } label: { + Image(systemName: "info.circle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .help(localization.string("leak.details.help", defaultValue: "查看检测详情")) + .popover( + isPresented: Binding( + get: { presentedLeakDetails == assessment.kind }, + set: { isPresented in + presentedLeakDetails = isPresented ? assessment.kind : nil + } + ), + arrowEdge: .trailing + ) { + leakDetailsPopover( + assessment: assessment, + results: results, + showsNAT: showsNAT + ) + } + } + } + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .padding(.vertical, PluginSettingsTheme.Spacing.rowVertical) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + PluginSettingsTheme.Palette.nativeCardBackground, + in: RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.card, style: .continuous) + ) + } + + private func leakDetailsPopover( + assessment: IPOverviewLeakAssessment, + results: [IPOverviewLeakTestResult], + showsNAT: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: leakAssessmentIcon(assessment.state)) + .foregroundStyle(leakAssessmentColor(assessment.state)) + Text(localization.string("leak.details.title", defaultValue: "检测详情")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Spacer(minLength: 8) + Text(leakEvidenceCountText(assessment)) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + + VStack(spacing: 0) { + ForEach(Array(results.enumerated()), id: \.element.id) { index, result in + LeakEvidenceListRowView( + result: result, + showsNAT: showsNAT, + unknownText: unknownText, + localization: localization, + color: leakColor(result.status), + primaryText: leakPrimaryText(result.status), + displayIP: result.endpoint.map { displayIP($0.ip) }, + onCopyIP: { viewModel.copy(result.endpoint?.ip) } + ) + + if index < results.count - 1 { + Divider() + } + } + } + .background( + PluginSettingsTheme.Palette.recessedControlBackground, + in: RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous) + ) + } + .padding(12) + .frame(width: 380) + } + + private func leakAssessmentTitle(_ assessment: IPOverviewLeakAssessment) -> String { + switch assessment.kind { + case .webRTC: + switch assessment.state { + case .waiting: + return localization.string("webrtc.assessment.waiting", defaultValue: "等待 WebRTC 检测") + case .checking: + return localization.string("webrtc.assessment.checking", defaultValue: "正在检测 WebRTC") + case .clear: + return localization.string("webrtc.assessment.clear", defaultValue: "未发现 WebRTC 泄露") + case .warning: + return localization.string("webrtc.assessment.warning", defaultValue: "可能存在 WebRTC 泄露") + case .unknown: + return localization.string("webrtc.assessment.unknown", defaultValue: "WebRTC 结果不完整") + } + case .dns: + switch assessment.state { + case .waiting: + return localization.string("dns.assessment.waiting", defaultValue: "等待 DNS 检测") + case .checking: + return localization.string("dns.assessment.checking", defaultValue: "正在检测 DNS") + case .clear: + return localization.string("dns.assessment.clear", defaultValue: "未发现 DNS 泄露") + case .warning: + return localization.string("dns.assessment.warning", defaultValue: "可能存在 DNS 泄露") + case .unknown: + return localization.string("dns.assessment.unknown", defaultValue: "DNS 结果不完整") + } + } + } + + private func leakAssessmentDetail(_ assessment: IPOverviewLeakAssessment) -> String { + switch assessment.reason { + case .waiting: + return localization.string("leak.reason.waiting", defaultValue: "点击刷新后会检测出口是否和当前公网信息一致。") + case .checking: + return localization.string("leak.reason.checking", defaultValue: "正在收集各节点返回的出口信息。") + case .noPublicIP: + return localization.string("leak.reason.noPublicIP", defaultValue: "当前公网 IP 未获取完成,暂时无法建立对照。") + case .noDNSEndpoint: + return localization.string("leak.reason.noDNSEndpoint", defaultValue: "没有获取到 DNS 解析器出口,可能被网络或服务拦截。") + case .webRTCMatchesPublicIP: + return localization.string("leak.reason.webRTCMatchesPublicIP", defaultValue: "STUN 返回的出口与当前公网 IP 一致。") + case .webRTCNoVisibleEndpoint: + return localization.string("leak.reason.webRTCNoVisibleEndpoint", defaultValue: "未检测到 WebRTC 可见出口,通常表示 STUN 被阻断或不可达。") + case .webRTCDifferentIP: + return localization.string("leak.reason.webRTCDifferentIP", defaultValue: "STUN 返回了不同于当前公网 IP 的出口。") + case .dnsMatchesEgressRegion: + return localization.string("leak.reason.dnsMatchesEgressRegion", defaultValue: "DNS 解析器出口地区与当前公网出口一致。") + case .dnsDifferentEgressRegion: + return localization.string("leak.reason.dnsDifferentEgressRegion", defaultValue: "DNS 解析器出口地区与当前公网出口不一致。") + case .dnsObservedWithoutBaselineRegion: + return localization.string("leak.reason.dnsObservedWithoutBaselineRegion", defaultValue: "已获取 DNS 出口,但公网归属地不足,暂时无法对照。") + } + } + + private func leakAssessmentBadge(_ assessment: IPOverviewLeakAssessment) -> String { + leakStateBadge(assessment.state) + } + + private func leakStateBadge(_ state: IPOverviewLeakAssessmentState) -> String { + switch state { + case .waiting: + return localization.string("leak.badge.waiting", defaultValue: "待检测") + case .checking: + return localization.string("leak.badge.checking", defaultValue: "检测中") + case .clear: + return localization.string("leak.badge.clear", defaultValue: "通过") + case .warning: + return localization.string("leak.badge.warning", defaultValue: "注意") + case .unknown: + return localization.string("leak.badge.unknown", defaultValue: "无法判断") + } + } + + private func leakEvidenceCountText(_ assessment: IPOverviewLeakAssessment) -> String { + localization.format( + "leak.evidence.count", + defaultValue: "%d/%d 个节点有结果", + assessment.observedCount, + assessment.totalCount + ) + } + + private func leakAssessmentColor(_ state: IPOverviewLeakAssessmentState) -> Color { + switch state { + case .waiting: + return .secondary + case .checking: + return Color(nsColor: .systemBlue) + case .clear: + return Color(nsColor: .systemGreen) + case .warning: + return Color(nsColor: .systemRed) + case .unknown: + return Color(nsColor: .systemOrange) + } + } + + private func leakAssessmentIcon(_ state: IPOverviewLeakAssessmentState) -> String { + switch state { + case .waiting: + return "clock" + case .checking: + return "hourglass" + case .clear: + return "checkmark.shield" + case .warning: + return "exclamationmark.triangle" + case .unknown: + return "questionmark.circle" + } + } + + private func connectivityTile(_ result: IPOverviewConnectivityResult) -> some View { + HStack(spacing: 7) { + Circle() + .fill(connectivityColor(result.status)) + .frame(width: 7, height: 7) + Text(result.target.name) + .font(.caption.weight(.semibold)) + .lineLimit(1) + + Spacer(minLength: 4) + + Text(connectivityText(result.status)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + + if result.target.isCustom { + Button { + viewModel.removeConnectivityTarget(id: result.id) + } label: { + Image(systemName: "xmark") + .font(.caption2) + } + .buttonStyle(.borderless) + } + } + .padding(.horizontal, 2) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var customTargetForm: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + TextField(localization.string("customTarget.name.placeholder", defaultValue: "名称"), text: $customName) + .textFieldStyle(.roundedBorder) + TextField("https://example.com", text: $customURL) + .textFieldStyle(.roundedBorder) + Button { + addCustomTarget() + } label: { + Image(systemName: "plus") + } + .buttonStyle(.borderless) + .disabled(customName.isEmpty || customURL.isEmpty) + } + + if !addError.isEmpty { + Text(addError) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + + private var privacyFooter: some View { + HStack(spacing: 6) { + Image(systemName: "lock.shield") + Text(localization.string( + "privacy.footer", + defaultValue: "公网、归属地和连通性检测会请求外部服务;本地地址仅在本机读取。" + )) + .lineLimit(2) + Spacer(minLength: 0) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func landingMetric(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func sectionHeader(title: String, icon: String) -> some View { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + } + + private func addCustomTarget() { + if let error = viewModel.addConnectivityTarget(name: customName, urlString: customURL) { + addError = error + return + } + + addError = "" + customName = "" + customURL = "" + } + + private func connectivityColor(_ status: IPOverviewConnectivityResult.Status) -> Color { + switch status { + case .waiting: + return .secondary.opacity(0.45) + case .checking: + return Color(nsColor: .systemBlue) + case .reachable(let milliseconds): + return milliseconds < 250 ? Color(nsColor: .systemGreen) : Color(nsColor: .systemOrange) + case .unreachable: + return Color(nsColor: .systemRed) + } + } + + private func connectivityText(_ status: IPOverviewConnectivityResult.Status) -> String { + switch status { + case .waiting: + return localization.string("status.waiting", defaultValue: "等待检测") + case .checking: + return localization.string("status.checking", defaultValue: "检测中") + case .reachable(let milliseconds): + return "\(milliseconds) ms" + case .unreachable: + return localization.string("connectivity.unreachable", defaultValue: "不可达") + } + } + + private func leakColor(_ status: IPOverviewLeakTestResult.Status) -> Color { + switch status { + case .waiting: + return .secondary.opacity(0.45) + case .checking: + return Color(nsColor: .systemBlue) + case .success: + return Color(nsColor: .systemBlue) + case .failure: + return .secondary.opacity(0.65) + } + } + + private func leakPrimaryText(_ status: IPOverviewLeakTestResult.Status) -> String { + switch status { + case .waiting: + return localization.string("status.waiting", defaultValue: "等待检测") + case .checking: + return localization.string("status.checking", defaultValue: "检测中") + case .success(let endpoint): + return endpoint.ip + case .failure: + return localization.string("leak.failed", defaultValue: "获取失败") + } + } + + private var unknownText: String { + localization.string("common.unknown", defaultValue: "未知") + } + + private var notDetectedText: String { + localization.string("common.notDetected", defaultValue: "未检测到") + } + + private func landingEgressValue(_ result: IPOverviewPublicIPResult?) -> String { + if let result { + return displayIP(result.ip) + } + + if viewModel.snapshot.isRefreshing || viewModel.snapshot.lastUpdated == nil { + return localization.string("common.checking", defaultValue: "检测中") + } + + return notDetectedText + } + + private func displayIP(_ value: String) -> String { + viewModel.hidesSensitiveInfo ? IPOverviewSensitiveValueMask.maskedIP(value) : value + } +} + +private struct PublicIPRowView: View { + let title: String + let route: IPOverviewEgressRoute + let result: IPOverviewPublicIPResult? + let geoInfo: IPOverviewGeoInfo? + let notDetectedText: String + let geoUnavailableText: String + let unknownText: String + let localization: PluginLocalization + let displayIP: String? + let onCopyIP: () -> Void + + @State private var isHovered = false + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + HStack(alignment: .center, spacing: 4) { + if let result { + Text(displayIP ?? result.ip) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .layoutPriority(1) + } else { + Text(notDetectedText) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + + IPOverviewTagBadge(title: title, style: .addressFamily(title)) + IPOverviewTagBadge(title: route.title(localization: localization), style: .egressRoute(route)) + + IPOverviewInlineCopyButton( + help: localization.format( + "copy.ip.help", + defaultValue: "复制 %@ IP", + title + ), + action: onCopyIP + ) + .opacity(isHovered && result != nil ? 1 : 0) + .allowsHitTesting(isHovered && result != nil) + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + } + + if result != nil { + if geoInfo != nil { + publicIPDetails + .padding(.top, 2) + } else { + unavailableGeoInfo + } + } + } + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + } + + private var publicIPDetails: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + publicIPDetailValues + } + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8) + ], spacing: 8) { + publicIPDetailValues + } + } + } + + @ViewBuilder + private var publicIPDetailValues: some View { + detailValue( + title: localization.string("info.location", defaultValue: "位置"), + value: geoInfo?.locationText ?? geoInfo?.countryDisplayText ?? geoUnavailableText, + icon: "mappin.and.ellipse" + ) + detailValue( + title: localization.string("info.isp", defaultValue: "运营商"), + value: geoInfo?.organization ?? geoInfo?.isp ?? unknownText, + icon: "server.rack" + ) + detailValue( + title: "ASN", + value: geoInfo?.asn ?? unknownText, + icon: "building.2" + ) + } + + private func detailValue(title: String, value: String, icon: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + + Text(value) + .font(PluginSettingsTheme.Typography.rowDescription) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var unavailableGeoInfo: some View { + Text(geoUnavailableText) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } +} + +private struct LocalAddressRowView: View { + let address: IPOverviewLocalAddress + let displayAddress: String + let localization: PluginLocalization + let onCopyIP: () -> Void + + @State private var isHovered = false + + var body: some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(address.interfaceName) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + + HStack(alignment: .center, spacing: 4) { + Text(displayAddress) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .layoutPriority(1) + + IPOverviewTagBadge(title: address.family.rawValue, style: .addressFamily(address.family.rawValue)) + + IPOverviewInlineCopyButton( + help: localization.format( + "copy.ip.help", + defaultValue: "复制 %@ IP", + address.family.rawValue + ), + action: onCopyIP + ) + .opacity(isHovered ? 1 : 0) + .allowsHitTesting(isHovered) + } + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + } + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + } +} + +private struct NetworkQualityCardView: View { + let state: IPOverviewNetworkQualityRunState + let localization: PluginLocalization + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + switch state { + case .waiting: + chartContent( + progress: .started(), + measurement: nil + ) + case .running(let progress): + chartContent( + progress: progress, + measurement: nil + ) + case .completed(let measurement, let progress): + chartContent( + progress: progress, + measurement: measurement + ) + case .failed(let message): + failureContent(message) + } + } + .padding(PluginSettingsTheme.Spacing.cardContent) + .pluginSettingsCardBackground(.host) + } + + private func chartContent( + progress: IPOverviewNetworkQualityProgress, + measurement: IPOverviewNetworkQualityMeasurement? + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + NetworkQualityGraphPanel( + progress: progress, + measurement: measurement, + localization: localization + ) + + if let measurement { + NetworkQualityResultStrip(measurement: measurement, localization: localization) + metadataRow(measurement) + } else { + Text(localization.string( + "speed.waiting.description", + defaultValue: "点击刷新后,会顺序测量下载、上传、延迟和响应性。" + )) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + + private func failureContent(_ message: String) -> some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "exclamationmark.triangle") + .pluginSettingsRowIconStyle(Color(nsColor: .systemOrange)) + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("speed.failed.title", defaultValue: "测速失败")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text(message) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func metadataRow(_ measurement: IPOverviewNetworkQualityMeasurement) -> some View { + HStack(spacing: 8) { + if let interfaceName = measurement.interfaceName, !interfaceName.isEmpty { + Label(interfaceName, systemImage: "antenna.radiowaves.left.and.right") + } + if let endpoint = measurement.testEndpoint, !endpoint.isEmpty { + Label(endpoint, systemImage: "server.rack") + .lineLimit(1) + .truncationMode(.middle) + } + if let totalDuration = measurement.totalPhaseDuration { + Label(durationText(totalDuration), systemImage: "clock") + } + Spacer(minLength: 0) + } + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + + private func durationText(_ value: TimeInterval) -> String { + localization.format("speed.duration.seconds", defaultValue: "%.1f 秒", value) + } +} + +private struct NetworkQualityGraphPanel: View { + let progress: IPOverviewNetworkQualityProgress + let measurement: IPOverviewNetworkQualityMeasurement? + let localization: PluginLocalization + + var body: some View { + HStack(alignment: .center, spacing: 18) { + NetworkQualityGaugeView( + value: gaugeValue, + maximumValue: gaugeMaximum, + subtitle: gaugeSubtitle, + tint: gaugeTint + ) + .frame(width: 148, height: 148) + + VStack(alignment: .leading, spacing: 12) { + NetworkQualitySparklineView( + title: localization.string("speed.download", defaultValue: "下载"), + valueText: valueText(measurement?.downloadMbps ?? progress.latestDownloadMbps, unit: "Mbps"), + samples: sparklineSamples(progress.downloadSamples, fallback: measurement?.downloadMbps), + tint: Color(nsColor: .systemTeal) + ) + NetworkQualitySparklineView( + title: localization.string("speed.upload", defaultValue: "上传"), + valueText: valueText(measurement?.uploadMbps ?? progress.latestUploadMbps, unit: "Mbps"), + samples: sparklineSamples(progress.uploadSamples, fallback: measurement?.uploadMbps), + tint: Color(nsColor: .systemCyan) + ) + } + .frame(minHeight: 150, alignment: .center) + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, minHeight: 174, alignment: .center) + .background( + PluginSettingsTheme.Palette.recessedControlBackground.opacity(0.55), + in: RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous) + ) + } + + private var gaugeValue: Double? { + if let measurement { + return measurement.downloadMbps + } + + switch progress.phase { + case .measuringUpload: + return progress.latestUploadMbps + case .measuringDownload: + return progress.latestDownloadMbps + case .initializing, .measuringLatency: + return nil + } + } + + private var gaugeSubtitle: String { + if let value = gaugeValue { + return valueText(value, unit: "Mbps") + } + + return "-- Mbps" + } + + private var gaugeMaximum: Double { + let value = gaugeValue ?? 0 + switch value { + case ..<100: + return 100 + case ..<300: + return 300 + case ..<1_000: + return 1_000 + default: + return (ceil(value / 1_000) * 1_000) + } + } + + private var gaugeTint: Color { + if measurement != nil { + return Color(nsColor: .systemTeal) + } + + return progress.phase.tint + } + + private func sparklineSamples(_ samples: [Double], fallback: Double?) -> [Double] { + if !samples.isEmpty { + return samples + } + + return fallback.map { [$0] } ?? [] + } + + private func valueText(_ value: Double?, unit: String) -> String { + guard let value else { + return "-- \(unit)" + } + + let format = value >= 100 ? "%.0f %@" : value >= 10 ? "%.1f %@" : "%.2f %@" + return String(format: format, value, unit) + } +} + +private struct NetworkQualityGaugeView: View { + let value: Double? + let maximumValue: Double + let subtitle: String + let tint: Color + + var body: some View { + ZStack { + Circle() + .fill(PluginSettingsTheme.Palette.cardBackground.opacity(0.66)) + .frame(width: 116, height: 116) + GaugeArc(progress: 1) + .stroke(.secondary.opacity(0.15), style: StrokeStyle(lineWidth: 10, lineCap: .round)) + GaugeArc(progress: progress) + .stroke(tint, style: StrokeStyle(lineWidth: 10, lineCap: .round)) + VStack(spacing: 0) { + Text(subtitle) + .font(.system(size: 19.5, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.68) + } + } + } + + private var progress: Double { + guard let value else { + return 0.08 + } + + return min(max(value / maximumValue, 0.08), 1) + } + +} + +private struct GaugeArc: Shape { + let progress: Double + + func path(in rect: CGRect) -> Path { + var path = Path() + let size = min(rect.width, rect.height) + let radius = size / 2 - 10 + let center = CGPoint(x: rect.midX, y: rect.midY) + let start = Angle.degrees(135) + let end = Angle.degrees(135 + 270 * min(max(progress, 0), 1)) + path.addArc(center: center, radius: radius, startAngle: start, endAngle: end, clockwise: false) + return path + } +} + +private struct NetworkQualitySparklineView: View { + let title: String + let valueText: String + let samples: [Double] + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text(valueText) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(tint) + .monospacedDigit() + Spacer(minLength: 0) + if let maximumText { + Text(maximumText) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + GeometryReader { proxy in + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(PluginSettingsTheme.Palette.cardBackground.opacity(0.42)) + NetworkQualityGrid() + .stroke(.secondary.opacity(0.11), lineWidth: 0.7) + if normalizedSamples.isEmpty { + NetworkQualityBaseline() + .stroke( + .secondary.opacity(0.18), + style: StrokeStyle(lineWidth: 1, dash: [4, 4]) + ) + } else { + NetworkQualityArea(samples: normalizedSamples) + .fill(tint.opacity(0.14)) + NetworkQualityLine(samples: normalizedSamples) + .stroke(tint, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + .shadow(color: tint.opacity(0.20), radius: 2, x: 0, y: 1) + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .frame(height: 54) + } + } + + private var maximumText: String? { + guard let maximum = samples.max(), maximum > 0 else { + return nil + } + + let format = maximum >= 100 ? "%.0f max" : maximum >= 10 ? "%.1f max" : "%.2f max" + return String(format: format, maximum) + } + + private var normalizedSamples: [Double] { + guard !samples.isEmpty else { + return [] + } + + let maximum = max(samples.max() ?? 1, 1) + return samples.map { min(max($0 / maximum, 0.02), 1) } + } +} + +private extension CGRect { + var networkQualityPlotRect: CGRect { + insetBy(dx: 6, dy: 5) + } +} + +private struct NetworkQualityGrid: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let plotRect = rect.networkQualityPlotRect + let columns = 12 + let rows = 4 + for column in 0...columns { + let x = plotRect.minX + plotRect.width * CGFloat(column) / CGFloat(columns) + path.move(to: CGPoint(x: x, y: plotRect.minY)) + path.addLine(to: CGPoint(x: x, y: plotRect.maxY)) + } + for row in 0...rows { + let y = plotRect.minY + plotRect.height * CGFloat(row) / CGFloat(rows) + path.move(to: CGPoint(x: plotRect.minX, y: y)) + path.addLine(to: CGPoint(x: plotRect.maxX, y: y)) + } + return path + } +} + +private struct NetworkQualityLine: Shape { + let samples: [Double] + + func path(in rect: CGRect) -> Path { + var path = Path() + guard !samples.isEmpty else { + return path + } + + let plotRect = rect.networkQualityPlotRect + let points = samples.enumerated().map { index, value in + let x = samples.count == 1 + ? plotRect.midX + : plotRect.minX + plotRect.width * CGFloat(index) / CGFloat(samples.count - 1) + let y = plotRect.maxY - plotRect.height * CGFloat(value) + return CGPoint(x: x, y: y) + } + + path.move(to: points[0]) + for point in points.dropFirst() { + path.addLine(to: point) + } + return path + } +} + +private struct NetworkQualityArea: Shape { + let samples: [Double] + + func path(in rect: CGRect) -> Path { + guard !samples.isEmpty else { + return Path() + } + + let plotRect = rect.networkQualityPlotRect + var path = NetworkQualityLine(samples: samples).path(in: rect) + let trailingX = samples.count == 1 ? plotRect.midX : plotRect.maxX + let leadingX = samples.count == 1 ? plotRect.midX : plotRect.minX + path.addLine(to: CGPoint(x: trailingX, y: plotRect.maxY)) + path.addLine(to: CGPoint(x: leadingX, y: plotRect.maxY)) + path.closeSubpath() + return path + } +} + +private struct NetworkQualityBaseline: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let plotRect = rect.networkQualityPlotRect + let y = plotRect.minY + plotRect.height * 0.68 + path.move(to: CGPoint(x: plotRect.minX, y: y)) + path.addLine(to: CGPoint(x: plotRect.maxX, y: y)) + return path + } +} + +private struct NetworkQualityResultStrip: View { + let measurement: IPOverviewNetworkQualityMeasurement + let localization: PluginLocalization + + var body: some View { + HStack(spacing: 0) { + resultItem( + title: localization.string("speed.download", defaultValue: "下载"), + value: speedText(measurement.downloadMbps), + unit: "Mbps", + icon: "arrow.down.circle", + tint: Color(nsColor: .systemTeal) + ) + Divider().padding(.vertical, 5) + resultItem( + title: localization.string("speed.upload", defaultValue: "上传"), + value: speedText(measurement.uploadMbps), + unit: "Mbps", + icon: "arrow.up.circle", + tint: Color(nsColor: .systemCyan) + ) + Divider().padding(.vertical, 5) + resultItem( + title: localization.string("speed.latency", defaultValue: "延迟"), + value: integerText(measurement.baseRTTMilliseconds), + unit: "ms", + icon: "timer", + tint: Color(nsColor: .systemOrange) + ) + Divider().padding(.vertical, 5) + resultItem( + title: localization.string("speed.responsiveness", defaultValue: "响应性"), + value: integerText(measurement.uploadResponsivenessRPM ?? measurement.downloadResponsivenessRPM), + unit: "RPM", + icon: "waveform.path.ecg", + tint: Color(nsColor: .systemPurple) + ) + } + .padding(.vertical, 2) + } + + private func resultItem( + title: String, + value: String, + unit: String, + icon: String, + tint: Color + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Label(title, systemImage: icon) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(tint) + .lineLimit(1) + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(value) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + Text(unit) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func speedText(_ value: Double?) -> String { + guard let value else { + return "--" + } + + let format = value >= 100 ? "%.0f" : value >= 10 ? "%.1f" : "%.2f" + return String(format: format, value) + } + + private func integerText(_ value: Double?) -> String { + guard let value else { + return "--" + } + + return String(format: "%.0f", value) + } +} + +private extension IPOverviewNetworkQualityPhase { + var systemImage: String { + switch self { + case .initializing: + return "hourglass" + case .measuringDownload: + return "arrow.down.circle" + case .measuringUpload: + return "arrow.up.circle" + case .measuringLatency: + return "waveform.path.ecg" + } + } + + var tint: Color { + switch self { + case .initializing, .measuringDownload: + return Color(nsColor: .systemTeal) + case .measuringUpload: + return Color(nsColor: .systemCyan) + case .measuringLatency: + return Color(nsColor: .systemPurple) + } + } +} + +private extension IPOverviewNetworkQualityGrade { + var systemImage: String { + switch self { + case .excellent: + return "checkmark.seal.fill" + case .good: + return "checkmark.circle.fill" + case .fair: + return "exclamationmark.circle.fill" + case .poor: + return "exclamationmark.triangle.fill" + case .unknown: + return "questionmark.circle" + } + } + + var tint: Color { + switch self { + case .excellent: + return Color(nsColor: .systemGreen) + case .good: + return Color(nsColor: .systemTeal) + case .fair: + return Color(nsColor: .systemOrange) + case .poor: + return Color(nsColor: .systemRed) + case .unknown: + return Color(nsColor: .secondaryLabelColor) + } + } +} + +private struct LeakEvidenceListRowView: View { + let result: IPOverviewLeakTestResult + let showsNAT: Bool + let unknownText: String + let localization: PluginLocalization + let color: Color + let primaryText: String + let displayIP: String? + let onCopyIP: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(result.name) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .lineLimit(1) + Text(statusText) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + + if let endpoint = result.endpoint { + HStack(spacing: 6) { + IPOverviewCopyableMonospacedValue( + value: displayIP ?? endpoint.ip, + help: localization.string("copy.leakEndpoint.help", defaultValue: "复制出口 IP"), + onCopy: onCopyIP + ) + Text(endpoint.countryDisplayText ?? unknownText) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Text(detailText(endpoint)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text(primaryText) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 9) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func detailText(_ endpoint: IPOverviewLeakEndpoint) -> String { + var parts: [String] = [] + if let organization = endpoint.organization, !organization.isEmpty { + parts.append(organization) + } + if showsNAT, let natType = endpoint.natType, !natType.isEmpty { + parts.append(natType) + } + return parts.isEmpty ? unknownText : parts.joined(separator: " · ") + } + + private var statusText: String { + switch result.status { + case .waiting: + return localization.string("status.waiting", defaultValue: "等待检测") + case .checking: + return localization.string("status.checking", defaultValue: "检测中") + case .success: + return localization.string("leak.observed", defaultValue: "已获取") + case .failure: + return localization.string("leak.failed", defaultValue: "获取失败") + } + } +} + +private struct IPOverviewTagBadge: View { + let title: String + let style: IPOverviewTagBadgeStyle + + var body: some View { + Text(title) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(style.foregroundColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule().fill(style.backgroundColor) + ) + .overlay( + Capsule().stroke(style.strokeColor, lineWidth: 0.5) + ) + .fixedSize() + } +} + +private enum IPOverviewTagBadgeStyle { + case ipv4 + case ipv6 + case domestic + case international + + static func addressFamily(_ title: String) -> IPOverviewTagBadgeStyle { + title.uppercased() == "IPV6" ? .ipv6 : .ipv4 + } + + static func egressRoute(_ route: IPOverviewEgressRoute) -> IPOverviewTagBadgeStyle { + switch route { + case .domestic: + return .domestic + case .international: + return .international + } + } + + var tint: Color { + switch self { + case .ipv4: + return Self.adaptiveColor( + light: NSColor(calibratedRed: 0.05, green: 0.32, blue: 0.72, alpha: 1), + dark: .systemBlue + ) + case .ipv6: + return Self.adaptiveColor( + light: NSColor(calibratedRed: 0.42, green: 0.20, blue: 0.68, alpha: 1), + dark: .systemPurple + ) + case .domestic: + return Self.adaptiveColor( + light: NSColor(calibratedRed: 0.08, green: 0.48, blue: 0.24, alpha: 1), + dark: .systemGreen + ) + case .international: + return Self.adaptiveColor( + light: NSColor(calibratedRed: 0.64, green: 0.34, blue: 0.00, alpha: 1), + dark: .systemOrange + ) + } + } + + var foregroundColor: Color { + tint + } + + var backgroundColor: Color { + tint.opacity(0.12) + } + + var strokeColor: Color { + tint.opacity(0.2) + } + + private static func adaptiveColor(light: NSColor, dark: NSColor) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light + }) + } +} + +private struct IPOverviewCopyableMonospacedValue: View { + let value: String + let help: String + let onCopy: () -> Void + + @State private var isHovered = false + + var body: some View { + HStack(alignment: .center, spacing: 4) { + Text(value) + .font(PluginSettingsTheme.Typography.monospacedValue) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .layoutPriority(1) + + IPOverviewInlineCopyButton(help: help, action: onCopy) + .opacity(isHovered ? 1 : 0) + .allowsHitTesting(isHovered) + } + .contentShape(Rectangle()) + .onHover { isHovered = $0 } + } +} + +private struct IPOverviewInlineCopyButton: View { + let help: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "doc.on.doc") + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 12, height: 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(help) + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewConnectivityService.swift b/Plugins/IPOverview/Sources/IPOverviewConnectivityService.swift new file mode 100644 index 0000000..50d8333 --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewConnectivityService.swift @@ -0,0 +1,63 @@ +import Foundation +import MacToolsPluginKit + +protocol IPOverviewConnectivityChecking: Sendable { + func check(target: IPOverviewConnectivityTarget) async -> IPOverviewConnectivityResult +} + +struct IPOverviewConnectivityService: IPOverviewConnectivityChecking { + private let httpClient: any IPOverviewHTTPClient + private let timeout: TimeInterval + private let localization: PluginLocalization + + init( + httpClient: any IPOverviewHTTPClient = URLSession.shared, + timeout: TimeInterval = 3, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.httpClient = httpClient + self.timeout = timeout + self.localization = localization + } + + func check(target: IPOverviewConnectivityTarget) async -> IPOverviewConnectivityResult { + guard let url = target.url else { + return IPOverviewConnectivityResult( + id: target.id, + target: target, + status: .unreachable(localization.string("connectivity.error.invalidURL", defaultValue: "URL 无效")) + ) + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.httpMethod = "GET" + request.setValue("MacTools IP Overview", forHTTPHeaderField: "User-Agent") + + let startedAt = Date() + do { + let (_, response) = try await httpClient.data(for: request) + let elapsed = max(1, Int(Date().timeIntervalSince(startedAt) * 1000)) + if (200..<500).contains(response.statusCode) { + return IPOverviewConnectivityResult( + id: target.id, + target: target, + status: .reachable(milliseconds: elapsed) + ) + } + + return IPOverviewConnectivityResult( + id: target.id, + target: target, + status: .unreachable("HTTP \(response.statusCode)") + ) + } catch { + return IPOverviewConnectivityResult( + id: target.id, + target: target, + status: .unreachable(error.localizedDescription) + ) + } + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewLeakTestService.swift b/Plugins/IPOverview/Sources/IPOverviewLeakTestService.swift new file mode 100644 index 0000000..8767872 --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewLeakTestService.swift @@ -0,0 +1,366 @@ +import Foundation +import Network +import MacToolsPluginKit + +protocol IPOverviewLeakTesting: Sendable { + func checkWebRTC(target: IPOverviewWebRTCTarget) async -> IPOverviewLeakTestResult + func checkDNS(id: String, name: String) async -> IPOverviewLeakTestResult +} + +struct IPOverviewLeakTestService: IPOverviewLeakTesting { + private let httpClient: any IPOverviewHTTPClient + private let timeout: TimeInterval + private let localization: PluginLocalization + + init( + httpClient: any IPOverviewHTTPClient = URLSession.shared, + timeout: TimeInterval = 5, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.httpClient = httpClient + self.timeout = timeout + self.localization = localization + } + + func checkWebRTC(target: IPOverviewWebRTCTarget) async -> IPOverviewLeakTestResult { + do { + let ip = try await STUNClient(timeout: timeout).reflexiveAddress( + host: target.host, + port: target.port + ) + let geo = await geoEndpoint( + ip: ip, + natType: localization.string("leak.nat.portRestrictedOrSymmetric", defaultValue: "端口限制型或对称型") + ) + return IPOverviewLeakTestResult( + id: target.id, + name: target.name, + status: .success(geo) + ) + } catch { + return IPOverviewLeakTestResult( + id: target.id, + name: target.name, + status: .failure(Self.userFacingMessage(for: error, localization: localization)) + ) + } + } + + func checkDNS(id: String, name: String) async -> IPOverviewLeakTestResult { + if let endpoint = await checkIPAPIDNSLeak() { + return IPOverviewLeakTestResult(id: id, name: name, status: .success(endpoint)) + } + + if let endpoint = await checkSurfsharkDNSLeak() { + return IPOverviewLeakTestResult(id: id, name: name, status: .success(endpoint)) + } + + return IPOverviewLeakTestResult( + id: id, + name: name, + status: .failure(localization.string("leak.error.noDNSExit", defaultValue: "未获取到 DNS 出口")) + ) + } + + private func checkIPAPIDNSLeak() async -> IPOverviewLeakEndpoint? { + guard let url = URL(string: "https://\(randomToken()).edns.ip-api.com/json") else { + return nil + } + + do { + let (data, response) = try await fetch(url) + guard (200..<300).contains(response.statusCode), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dns = object["dns"] as? [String: Any], + let ip = dns["ip"] as? String + else { + return nil + } + + let geo = dns["geo"] as? String + let parts = geo?.components(separatedBy: " - ") ?? [] + return IPOverviewLeakEndpoint( + ip: ip, + natType: nil, + country: parts.first, + countryCode: nil, + organization: parts.dropFirst().first + ) + } catch { + return nil + } + } + + private func checkSurfsharkDNSLeak() async -> IPOverviewLeakEndpoint? { + guard let url = URL(string: "https://\(shortToken()).ipv4.surfsharkdns.com") else { + return nil + } + + do { + let (data, response) = try await fetch(url) + guard (200..<300).contains(response.statusCode), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let first = object.values.first as? [String: Any], + let ip = first["IP"] as? String + else { + return nil + } + + return IPOverviewLeakEndpoint( + ip: ip, + natType: nil, + country: first["Country"] as? String, + countryCode: first["CountryCode"] as? String, + organization: first["ISP"] as? String + ) + } catch { + return nil + } + } + + private func geoEndpoint(ip: String, natType: String?) async -> IPOverviewLeakEndpoint { + let url = IPOverviewGeoSource.ipwhois.url(ip: ip) + + do { + let (data, response) = try await fetch(url) + guard (200..<300).contains(response.statusCode), + let info = IPOverviewParser.geoInfoFromIPWhois(data, ip: ip) + else { + return IPOverviewLeakEndpoint( + ip: ip, + natType: natType, + country: nil, + countryCode: nil, + organization: nil + ) + } + + return IPOverviewLeakEndpoint( + ip: ip, + natType: natType, + country: info.country, + countryCode: info.countryCode, + organization: info.organization ?? info.isp + ) + } catch { + return IPOverviewLeakEndpoint( + ip: ip, + natType: natType, + country: nil, + countryCode: nil, + organization: nil + ) + } + } + + private func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.setValue("MacTools IP Overview", forHTTPHeaderField: "User-Agent") + return try await httpClient.data(for: request) + } + + private func randomToken() -> String { + "\(Int(Date().timeIntervalSince1970))mactools\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(10))" + } + + private func shortToken() -> String { + "mt\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(10))" + } + + private static func userFacingMessage(for error: Error, localization: PluginLocalization) -> String { + if let stunError = error as? STUNClient.Error { + return stunError.localizedDescription(localization: localization) + } + return error.localizedDescription + } +} + +private struct STUNClient { + enum Error: LocalizedError { + case invalidPort + case timedOut + case noMappedAddress + case failed(String) + + var errorDescription: String? { + localizedDescription() + } + + func localizedDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .invalidPort: + return localization.string("stun.error.invalidPort", defaultValue: "STUN 端口无效") + case .timedOut: + return localization.string("stun.error.timedOut", defaultValue: "STUN 请求超时") + case .noMappedAddress: + return localization.string("stun.error.noMappedAddress", defaultValue: "未返回外部地址") + case .failed(let message): + return message + } + } + } + + let timeout: TimeInterval + + func reflexiveAddress(host: String, port: UInt16) async throws -> String { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw Error.invalidPort + } + + return try await withCheckedThrowingContinuation { continuation in + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: nwPort, + using: .udp + ) + let transactionID = Self.transactionID() + let request = Self.bindingRequest(transactionID: transactionID) + let resumeState = STUNResumeState(connection: connection, continuation: continuation) + + @Sendable func finish(_ result: Result) { + resumeState.finish(result) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + connection.send(content: request, completion: .contentProcessed { error in + if let error { + finish(.failure(Error.failed(error.localizedDescription))) + return + } + + connection.receiveMessage { data, _, _, error in + if let error { + finish(.failure(Error.failed(error.localizedDescription))) + return + } + guard let data, + let ip = Self.parseMappedAddress(data, transactionID: transactionID) + else { + finish(.failure(Error.noMappedAddress)) + return + } + finish(.success(ip)) + } + }) + case .failed(let error): + finish(.failure(Error.failed(error.localizedDescription))) + case .cancelled: + break + default: + break + } + } + + connection.start(queue: .global(qos: .utility)) + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { + finish(.failure(Error.timedOut)) + } + } + } + + private static func transactionID() -> [UInt8] { + (0..<12).map { _ in UInt8.random(in: 0...255) } + } + + private static func bindingRequest(transactionID: [UInt8]) -> Data { + var bytes: [UInt8] = [ + 0x00, 0x01, + 0x00, 0x00, + 0x21, 0x12, 0xA4, 0x42 + ] + bytes.append(contentsOf: transactionID) + return Data(bytes) + } + + private static func parseMappedAddress(_ data: Data, transactionID: [UInt8]) -> String? { + let bytes = [UInt8](data) + guard bytes.count >= 20, + bytes[0] == 0x01, + bytes[1] == 0x01, + Array(bytes[8..<20]) == transactionID + else { + return nil + } + + var index = 20 + while index + 4 <= bytes.count { + let type = UInt16(bytes[index]) << 8 | UInt16(bytes[index + 1]) + let length = Int(UInt16(bytes[index + 2]) << 8 | UInt16(bytes[index + 3])) + let valueStart = index + 4 + let valueEnd = valueStart + length + guard valueEnd <= bytes.count else { return nil } + + if type == 0x0020 || type == 0x0001 { + let value = Array(bytes[valueStart.. String? { + guard value.count >= 8 else { return nil } + let family = value[1] + if family == 0x01 { + var address = Array(value[4..<8]) + if isXOR { + let cookie: [UInt8] = [0x21, 0x12, 0xA4, 0x42] + for index in 0..<4 { + address[index] ^= cookie[index] + } + } + return address.map(String.init).joined(separator: ".") + } + + if family == 0x02, value.count >= 20 { + var address = Array(value[4..<20]) + if isXOR { + let mask: [UInt8] = [0x21, 0x12, 0xA4, 0x42] + for index in 0..<4 { + address[index] ^= mask[index] + } + } + var chunks: [String] = [] + for index in stride(from: 0, to: address.count, by: 2) { + let chunk = UInt16(address[index]) << 8 | UInt16(address[index + 1]) + chunks.append(String(chunk, radix: 16)) + } + return chunks.joined(separator: ":") + } + + return nil + } +} + +private final class STUNResumeState: @unchecked Sendable { + private let lock = NSLock() + private var didResume = false + private let connection: NWConnection + private let continuation: CheckedContinuation + + init( + connection: NWConnection, + continuation: CheckedContinuation + ) { + self.connection = connection + self.continuation = continuation + } + + func finish(_ result: Result) { + lock.lock() + defer { lock.unlock() } + guard !didResume else { return } + didResume = true + connection.cancel() + continuation.resume(with: result) + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewModels.swift b/Plugins/IPOverview/Sources/IPOverviewModels.swift new file mode 100644 index 0000000..56b96aa --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewModels.swift @@ -0,0 +1,1088 @@ +import Foundation +import MacToolsPluginKit + +enum IPOverviewAddressFamily: String, CaseIterable, Codable, Sendable { + case ipv4 = "IPv4" + case ipv6 = "IPv6" +} + +enum IPOverviewEgressRoute: String, CaseIterable, Codable, Sendable { + case domestic + case international + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .domestic: + return localization.string("egress.domestic", defaultValue: "国内出口") + case .international: + return localization.string("egress.international", defaultValue: "国际出口") + } + } +} + +struct IPOverviewPublicIPResult: Codable, Equatable, Sendable { + let family: IPOverviewAddressFamily + let route: IPOverviewEgressRoute + let ip: String + let source: String + + init( + family: IPOverviewAddressFamily, + route: IPOverviewEgressRoute = .international, + ip: String, + source: String + ) { + self.family = family + self.route = route + self.ip = ip + self.source = source + } +} + +struct IPOverviewSourceResult: Identifiable, Codable, Equatable, Sendable { + enum Status: Codable, Equatable, Sendable { + case success(String) + case failure(String) + } + + let id: String + let family: IPOverviewAddressFamily + let route: IPOverviewEgressRoute + let source: String + let status: Status + + init( + id: String, + family: IPOverviewAddressFamily, + route: IPOverviewEgressRoute = .international, + source: String, + status: Status + ) { + self.id = id + self.family = family + self.route = route + self.source = source + self.status = status + } +} + +struct IPOverviewLocalAddress: Identifiable, Codable, Equatable, Sendable { + let id: String + let interfaceName: String + let address: String + let family: IPOverviewAddressFamily +} + +struct IPOverviewGeoInfo: Codable, Equatable, Sendable { + let ip: String + let country: String? + let countryCode: String? + let region: String? + let city: String? + let isp: String? + let organization: String? + let asn: String? + let timezone: String? + let networkType: IPOverviewNetworkType + let isProxy: Bool? + let isHosting: Bool? + let source: String + + var countryFlag: String? { + guard let countryCode else { + return nil + } + + return IPOverviewFormatter.flagEmoji(countryCode: countryCode) + } + + var locationText: String? { + [country, region, city] + .compactMap { value in + guard let value, !value.isEmpty else { return nil } + return value + } + .joined(separator: " / ") + .nilIfEmpty + } + + var countryDisplayText: String? { + guard let country, !country.isEmpty else { + return nil + } + + if let countryFlag { + return "\(countryFlag) \(country)" + } + + return country + } + + var networkText: String? { + [asn, organization ?? isp] + .compactMap { value in + guard let value, !value.isEmpty else { return nil } + return value + } + .joined(separator: " ") + .nilIfEmpty + } + + var proxyText: String { + proxyText() + } + + func proxyText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch isProxy { + case true: + return localization.string("geo.proxy.possible", defaultValue: "可能为代理") + case false: + return localization.string("geo.proxy.notDetected", defaultValue: "未识别为代理") + case nil: + return localization.string("common.unknown", defaultValue: "未知") + } + } +} + +enum IPOverviewNetworkType: Codable, Sendable { + case residential + case mobile + case datacenter + case education + case business + case unknown + + var rawValue: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .residential: + return localization.string("networkType.residential", defaultValue: "住宅/宽带") + case .mobile: + return localization.string("networkType.mobile", defaultValue: "移动网络") + case .datacenter: + return localization.string("networkType.datacenter", defaultValue: "数据中心") + case .education: + return localization.string("networkType.education", defaultValue: "教育网络") + case .business: + return localization.string("networkType.business", defaultValue: "企业网络") + case .unknown: + return localization.string("common.unknown", defaultValue: "未知") + } + } + + static func infer(organization: String?, isp: String?, isHosting: Bool?) -> IPOverviewNetworkType { + if isHosting == true { + return .datacenter + } + + let text = [organization, isp] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + guard !text.isEmpty else { + return .unknown + } + + if text.contains("mobile") || text.contains("wireless") || text.contains("cellular") + || text.contains("移动") || text.contains("unicom") || text.contains("telecom") { + return .mobile + } + + if text.contains("university") || text.contains("education") || text.contains("college") { + return .education + } + + if text.contains("cloud") || text.contains("hosting") || text.contains("data") + || text.contains("amazon") || text.contains("aws") || text.contains("google") + || text.contains("microsoft") || text.contains("azure") || text.contains("cloudflare") + || text.contains("digitalocean") || text.contains("hetzner") || text.contains("ovh") + || text.contains("linode") || text.contains("vultr") { + return .datacenter + } + + if text.contains("business") || text.contains("enterprise") || text.contains("corp") { + return .business + } + + return .residential + } +} + +struct IPOverviewConnectivityTarget: Identifiable, Codable, Equatable, Sendable { + let id: String + var name: String + var urlString: String + var isCustom: Bool + + var url: URL? { + URL(string: urlString) + } + + static let defaults: [IPOverviewConnectivityTarget] = [ + IPOverviewConnectivityTarget( + id: "wechat", + name: "WeChat", + urlString: "https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico", + isCustom: false + ), + IPOverviewConnectivityTarget( + id: "google", + name: "Google", + urlString: "https://www.google.com/favicon.ico", + isCustom: false + ), + IPOverviewConnectivityTarget( + id: "cloudflare", + name: "Cloudflare", + urlString: "https://www.cloudflare.com/favicon.ico", + isCustom: false + ), + IPOverviewConnectivityTarget( + id: "youtube", + name: "YouTube", + urlString: "https://www.youtube.com/favicon.ico", + isCustom: false + ), + IPOverviewConnectivityTarget( + id: "github", + name: "GitHub", + urlString: "https://github.com/favicon.ico", + isCustom: false + ), + IPOverviewConnectivityTarget( + id: "chatgpt", + name: "ChatGPT", + urlString: "https://chatgpt.com/favicon.ico", + isCustom: false + ) + ] +} + +struct IPOverviewConnectivityResult: Identifiable, Codable, Equatable, Sendable { + enum Status: Codable, Equatable, Sendable { + case waiting + case checking + case reachable(milliseconds: Int) + case unreachable(String) + } + + let id: String + let target: IPOverviewConnectivityTarget + var status: Status + + var latencyMilliseconds: Int? { + guard case .reachable(let milliseconds) = status else { + return nil + } + + return milliseconds + } +} + +enum IPOverviewNetworkQualityRunState: Codable, Equatable, Sendable { + case waiting + case running(IPOverviewNetworkQualityProgress) + case completed(IPOverviewNetworkQualityMeasurement, IPOverviewNetworkQualityProgress) + case failed(String) + + var measurement: IPOverviewNetworkQualityMeasurement? { + guard case .completed(let measurement, _) = self else { + return nil + } + + return measurement + } +} + +struct IPOverviewNetworkQualityProgress: Codable, Equatable, Sendable { + var startedAt: Date + var phase: IPOverviewNetworkQualityPhase + var downloadSamples: [Double] + var uploadSamples: [Double] + + static func started(at date: Date = Date()) -> IPOverviewNetworkQualityProgress { + IPOverviewNetworkQualityProgress( + startedAt: date, + phase: .initializing, + downloadSamples: [], + uploadSamples: [] + ) + } + + var latestDownloadMbps: Double? { + downloadSamples.last + } + + var latestUploadMbps: Double? { + uploadSamples.last + } +} + +enum IPOverviewNetworkQualityPhase: Codable, Equatable, Sendable { + case initializing + case measuringDownload + case measuringUpload + case measuringLatency + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .initializing: + return localization.string("speed.phase.initializing", defaultValue: "准备测速") + case .measuringDownload: + return localization.string("speed.phase.download", defaultValue: "测量下载") + case .measuringUpload: + return localization.string("speed.phase.upload", defaultValue: "测量上传") + case .measuringLatency: + return localization.string("speed.phase.latency", defaultValue: "测量延迟") + } + } +} + +struct IPOverviewNetworkQualityMeasurement: Codable, Equatable, Sendable { + let baseRTTMilliseconds: Double? + let downloadThroughputBitsPerSecond: Double? + let uploadThroughputBitsPerSecond: Double? + let downloadResponsivenessRPM: Double? + let uploadResponsivenessRPM: Double? + let downloadPhaseDuration: TimeInterval? + let uploadPhaseDuration: TimeInterval? + let interfaceName: String? + let testEndpoint: String? + let startDate: String? + let endDate: String? + + var downloadMbps: Double? { + downloadThroughputBitsPerSecond.map { $0 / 1_000_000 } + } + + var uploadMbps: Double? { + uploadThroughputBitsPerSecond.map { $0 / 1_000_000 } + } + + var totalPhaseDuration: TimeInterval? { + let durations = [downloadPhaseDuration, uploadPhaseDuration].compactMap(\.self) + guard !durations.isEmpty else { + return nil + } + + return durations.reduce(0, +) + } + + var quality: IPOverviewNetworkQualityGrade { + IPOverviewNetworkQualityGrade.evaluate( + baseRTTMilliseconds: baseRTTMilliseconds, + downloadMbps: downloadMbps, + uploadMbps: uploadMbps + ) + } +} + +enum IPOverviewNetworkQualityGrade: Sendable { + case excellent + case good + case fair + case poor + case unknown + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .excellent: + return localization.string("speed.grade.excellent", defaultValue: "优秀") + case .good: + return localization.string("speed.grade.good", defaultValue: "良好") + case .fair: + return localization.string("speed.grade.fair", defaultValue: "一般") + case .poor: + return localization.string("speed.grade.poor", defaultValue: "偏慢") + case .unknown: + return localization.string("common.unknown", defaultValue: "未知") + } + } + + static func evaluate( + baseRTTMilliseconds: Double?, + downloadMbps: Double?, + uploadMbps: Double? + ) -> IPOverviewNetworkQualityGrade { + guard baseRTTMilliseconds != nil || downloadMbps != nil || uploadMbps != nil else { + return .unknown + } + + let latencyScore: Int + switch baseRTTMilliseconds { + case let value? where value <= 50: + latencyScore = 3 + case let value? where value <= 100: + latencyScore = 2 + case let value? where value <= 200: + latencyScore = 1 + case .some: + latencyScore = 0 + case nil: + latencyScore = 1 + } + + let downloadScore: Int + switch downloadMbps { + case let value? where value >= 100: + downloadScore = 3 + case let value? where value >= 25: + downloadScore = 2 + case let value? where value >= 5: + downloadScore = 1 + case .some: + downloadScore = 0 + case nil: + downloadScore = 1 + } + + let uploadScore: Int + switch uploadMbps { + case let value? where value >= 40: + uploadScore = 3 + case let value? where value >= 10: + uploadScore = 2 + case let value? where value >= 2: + uploadScore = 1 + case .some: + uploadScore = 0 + case nil: + uploadScore = 1 + } + + switch min(latencyScore, downloadScore, uploadScore) { + case 3: + return .excellent + case 2: + return .good + case 1: + return .fair + default: + return .poor + } + } +} + +struct IPOverviewLeakTestResult: Identifiable, Codable, Equatable, Sendable { + enum Status: Codable, Equatable, Sendable { + case waiting + case checking + case success(IPOverviewLeakEndpoint) + case failure(String) + } + + let id: String + let name: String + let status: Status + + var endpoint: IPOverviewLeakEndpoint? { + guard case .success(let endpoint) = status else { + return nil + } + + return endpoint + } + + var isWaiting: Bool { + guard case .waiting = status else { + return false + } + + return true + } + + var isChecking: Bool { + guard case .checking = status else { + return false + } + + return true + } + + var isFailure: Bool { + guard case .failure = status else { + return false + } + + return true + } +} + +struct IPOverviewLeakEndpoint: Codable, Equatable, Sendable { + let ip: String + let natType: String? + let country: String? + let countryCode: String? + let organization: String? + + var countryDisplayText: String? { + guard let country, !country.isEmpty else { + return nil + } + + if let countryCode, let flag = IPOverviewFormatter.flagEmoji(countryCode: countryCode) { + return "\(flag) \(country)" + } + + return country + } +} + +private extension IPOverviewGeoInfo { + var regionKey: String? { + IPOverviewRegionKey.countryCodeOrName(countryCode: countryCode, country: country) + } +} + +private extension IPOverviewLeakEndpoint { + var regionKey: String? { + IPOverviewRegionKey.countryCodeOrName(countryCode: countryCode, country: country) + } +} + +private enum IPOverviewRegionKey { + static func countryCodeOrName(countryCode: String?, country: String?) -> String? { + if let code = countryCode? + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased(), + !code.isEmpty { + return "code:\(code)" + } + + if let country = country? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !country.isEmpty { + return "country:\(country)" + } + + return nil + } +} + +enum IPOverviewLeakAssessmentKind: Equatable, Sendable { + case webRTC + case dns +} + +enum IPOverviewLeakAssessmentState: Equatable, Sendable { + case waiting + case checking + case clear + case warning + case unknown +} + +enum IPOverviewLeakAssessmentReason: Equatable, Sendable { + case waiting + case checking + case noPublicIP + case noDNSEndpoint + case webRTCMatchesPublicIP + case webRTCNoVisibleEndpoint + case webRTCDifferentIP + case dnsMatchesEgressRegion + case dnsDifferentEgressRegion + case dnsObservedWithoutBaselineRegion +} + +struct IPOverviewLeakAssessment: Equatable, Sendable { + let kind: IPOverviewLeakAssessmentKind + let state: IPOverviewLeakAssessmentState + let reason: IPOverviewLeakAssessmentReason + let totalCount: Int + let observedCount: Int + let failureCount: Int + let issueEndpoint: IPOverviewLeakEndpoint? + + static func evaluate( + kind: IPOverviewLeakAssessmentKind, + results: [IPOverviewLeakTestResult], + snapshot: IPOverviewSnapshot, + isRunning: Bool + ) -> IPOverviewLeakAssessment { + let endpoints = results.compactMap(\.endpoint) + let failureCount = results.filter(\.isFailure).count + let hasChecking = isRunning || results.contains(where: \.isChecking) + let isWaitingOnly = !results.isEmpty && results.allSatisfy(\.isWaiting) + + switch kind { + case .webRTC: + return evaluateWebRTC( + results: results, + endpoints: endpoints, + snapshot: snapshot, + isWaitingOnly: isWaitingOnly, + hasChecking: hasChecking, + failureCount: failureCount + ) + case .dns: + return evaluateDNS( + results: results, + endpoints: endpoints, + snapshot: snapshot, + isWaitingOnly: isWaitingOnly, + hasChecking: hasChecking, + failureCount: failureCount + ) + } + } + + private static func evaluateWebRTC( + results: [IPOverviewLeakTestResult], + endpoints: [IPOverviewLeakEndpoint], + snapshot: IPOverviewSnapshot, + isWaitingOnly: Bool, + hasChecking: Bool, + failureCount: Int + ) -> IPOverviewLeakAssessment { + let publicIPs = snapshot.publicIPSet + + if !endpoints.isEmpty, !publicIPs.isEmpty, + let issue = endpoints.first(where: { !publicIPs.contains($0.ip) }) { + return assessment( + kind: .webRTC, + state: .warning, + reason: .webRTCDifferentIP, + results: results, + observedCount: endpoints.count, + failureCount: failureCount, + issueEndpoint: issue + ) + } + + if hasChecking { + return assessment( + kind: .webRTC, + state: .checking, + reason: .checking, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + if isWaitingOnly { + return assessment( + kind: .webRTC, + state: .waiting, + reason: .waiting, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + if endpoints.isEmpty { + return assessment( + kind: .webRTC, + state: .clear, + reason: .webRTCNoVisibleEndpoint, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + if publicIPs.isEmpty { + return assessment( + kind: .webRTC, + state: .unknown, + reason: .noPublicIP, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + return assessment( + kind: .webRTC, + state: .clear, + reason: .webRTCMatchesPublicIP, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + private static func evaluateDNS( + results: [IPOverviewLeakTestResult], + endpoints: [IPOverviewLeakEndpoint], + snapshot: IPOverviewSnapshot, + isWaitingOnly: Bool, + hasChecking: Bool, + failureCount: Int + ) -> IPOverviewLeakAssessment { + let baselineRegions = snapshot.publicRegionKeys + let observedRegions = endpoints.compactMap(\.regionKey) + + if !baselineRegions.isEmpty, !observedRegions.isEmpty, + let issue = endpoints.first(where: { endpoint in + guard let regionKey = endpoint.regionKey else { + return false + } + return !baselineRegions.contains(regionKey) + }) { + return assessment( + kind: .dns, + state: .warning, + reason: .dnsDifferentEgressRegion, + results: results, + observedCount: endpoints.count, + failureCount: failureCount, + issueEndpoint: issue + ) + } + + if hasChecking { + return assessment( + kind: .dns, + state: .checking, + reason: .checking, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + if isWaitingOnly { + return assessment( + kind: .dns, + state: .waiting, + reason: .waiting, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + if endpoints.isEmpty { + return assessment( + kind: .dns, + state: .unknown, + reason: .noDNSEndpoint, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + if baselineRegions.isEmpty || observedRegions.isEmpty { + return assessment( + kind: .dns, + state: .unknown, + reason: .dnsObservedWithoutBaselineRegion, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + return assessment( + kind: .dns, + state: .clear, + reason: .dnsMatchesEgressRegion, + results: results, + observedCount: endpoints.count, + failureCount: failureCount + ) + } + + private static func assessment( + kind: IPOverviewLeakAssessmentKind, + state: IPOverviewLeakAssessmentState, + reason: IPOverviewLeakAssessmentReason, + results: [IPOverviewLeakTestResult], + observedCount: Int, + failureCount: Int, + issueEndpoint: IPOverviewLeakEndpoint? = nil + ) -> IPOverviewLeakAssessment { + IPOverviewLeakAssessment( + kind: kind, + state: state, + reason: reason, + totalCount: results.count, + observedCount: observedCount, + failureCount: failureCount, + issueEndpoint: issueEndpoint + ) + } +} + +struct IPOverviewWebRTCTarget: Identifiable, Equatable, Sendable { + let id: String + let name: String + let host: String + let port: UInt16 + + var displayAddress: String { + "\(host):\(port)" + } + + static let defaults: [IPOverviewWebRTCTarget] = [ + IPOverviewWebRTCTarget(id: "google", name: "Google", host: "stun.l.google.com", port: 19302), + IPOverviewWebRTCTarget(id: "blackberry", name: "BlackBerry", host: "stun.voip.blackberry.com", port: 3478), + IPOverviewWebRTCTarget(id: "twilio", name: "Twilio", host: "global.stun.twilio.com", port: 3478), + IPOverviewWebRTCTarget(id: "cloudflare", name: "Cloudflare", host: "stun.cloudflare.com", port: 3478) + ] +} + +struct IPOverviewSnapshot: Codable, Equatable, Sendable { + var domesticIPv4: IPOverviewPublicIPResult? + var domesticIPv6: IPOverviewPublicIPResult? + var internationalIPv4: IPOverviewPublicIPResult? + var internationalIPv6: IPOverviewPublicIPResult? + var localAddresses: [IPOverviewLocalAddress] + var geoInfoByIP: [String: IPOverviewGeoInfo] + var sourceResults: [IPOverviewSourceResult] + var lastUpdated: Date? + var errorMessage: String? + var isRefreshing: Bool + + static let empty = IPOverviewSnapshot( + domesticIPv4: nil, + domesticIPv6: nil, + internationalIPv4: nil, + internationalIPv6: nil, + localAddresses: [], + geoInfoByIP: [:], + sourceResults: [], + lastUpdated: nil, + errorMessage: nil, + isRefreshing: false + ) + + init( + domesticIPv4: IPOverviewPublicIPResult? = nil, + domesticIPv6: IPOverviewPublicIPResult? = nil, + internationalIPv4: IPOverviewPublicIPResult? = nil, + internationalIPv6: IPOverviewPublicIPResult? = nil, + localAddresses: [IPOverviewLocalAddress], + geoInfoByIP: [String: IPOverviewGeoInfo], + sourceResults: [IPOverviewSourceResult], + lastUpdated: Date?, + errorMessage: String?, + isRefreshing: Bool + ) { + self.domesticIPv4 = domesticIPv4 + self.domesticIPv6 = domesticIPv6 + self.internationalIPv4 = internationalIPv4 + self.internationalIPv6 = internationalIPv6 + self.localAddresses = localAddresses + self.geoInfoByIP = geoInfoByIP + self.sourceResults = sourceResults + self.lastUpdated = lastUpdated + self.errorMessage = errorMessage + self.isRefreshing = isRefreshing + } + + init( + publicIPv4: IPOverviewPublicIPResult?, + publicIPv6: IPOverviewPublicIPResult?, + localAddresses: [IPOverviewLocalAddress], + geoInfoByIP: [String: IPOverviewGeoInfo], + sourceResults: [IPOverviewSourceResult], + lastUpdated: Date?, + errorMessage: String?, + isRefreshing: Bool + ) { + self.init( + internationalIPv4: publicIPv4, + internationalIPv6: publicIPv6, + localAddresses: localAddresses, + geoInfoByIP: geoInfoByIP, + sourceResults: sourceResults, + lastUpdated: lastUpdated, + errorMessage: errorMessage, + isRefreshing: isRefreshing + ) + } + + var publicIPv4: IPOverviewPublicIPResult? { + internationalIPv4 + } + + var publicIPv6: IPOverviewPublicIPResult? { + internationalIPv6 + } + + var preferredPublicIP: IPOverviewPublicIPResult? { + internationalIPv4 ?? internationalIPv6 ?? domesticIPv4 ?? domesticIPv6 + } + + var preferredGeoInfo: IPOverviewGeoInfo? { + guard let ip = preferredPublicIP?.ip else { + return nil + } + + return geoInfoByIP[ip] + } + + var publicIPSet: Set { + Set([internationalIPv4?.ip, internationalIPv6?.ip].compactMap { $0 }) + } + + var publicRegionKeys: Set { + Set(publicIPSet.compactMap { geoInfoByIP[$0]?.regionKey }) + } + + var reportText: String { + reportText() + } + + func reportText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + var lines: [String] = [] + lines.append(localization.string("report.title", defaultValue: "IP 检测结果")) + if let lastUpdated { + lines.append(localization.format( + "report.updatedAt", + defaultValue: "更新时间:%@", + IPOverviewFormatter.dateTime(lastUpdated) + )) + } + let notDetected = localization.string("common.notDetected", defaultValue: "未检测到") + lines.append(localization.format("report.domesticIPv4", defaultValue: "国内出口 IPv4:%@", domesticIPv4?.ip ?? notDetected)) + lines.append(localization.format("report.domesticIPv6", defaultValue: "国内出口 IPv6:%@", domesticIPv6?.ip ?? notDetected)) + lines.append(localization.format("report.publicIPv4", defaultValue: "国际出口 IPv4:%@", internationalIPv4?.ip ?? notDetected)) + lines.append(localization.format("report.publicIPv6", defaultValue: "国际出口 IPv6:%@", internationalIPv6?.ip ?? notDetected)) + + if !localAddresses.isEmpty { + lines.append("") + lines.append(localization.string("report.localAddresses", defaultValue: "本地地址:")) + for address in localAddresses { + lines.append(localization.format( + "report.localAddress", + defaultValue: "- %@ %@:%@", + address.interfaceName, + address.family.rawValue, + address.address + )) + } + } + + if !geoInfoByIP.isEmpty { + lines.append("") + lines.append(localization.string("report.geo", defaultValue: "归属地:")) + for info in geoInfoByIP.values.sorted(by: { $0.ip < $1.ip }) { + lines.append("- \(info.ip)") + if let country = info.countryDisplayText { + lines.append(localization.format("report.region", defaultValue: " 地区:%@", country)) + } + if let region = info.region, !region.isEmpty { + lines.append(localization.format("report.province", defaultValue: " 省份:%@", region)) + } + if let city = info.city, !city.isEmpty { + lines.append(localization.format("report.city", defaultValue: " 城市:%@", city)) + } + if let network = info.networkText { + lines.append(localization.format("report.network", defaultValue: " 网络:%@", network)) + } + if let timezone = info.timezone, !timezone.isEmpty { + lines.append(localization.format("report.timezone", defaultValue: " 时区:%@", timezone)) + } + lines.append(localization.format("report.source", defaultValue: " 来源:%@", info.source)) + } + } + + if !sourceResults.isEmpty { + lines.append("") + lines.append(localization.string("report.sources", defaultValue: "检测源:")) + for result in sourceResults { + switch result.status { + case .success(let ip): + lines.append(localization.format( + "report.source.success", + defaultValue: "- %@ %@:%@", + result.route.title(localization: localization), + result.source, + ip + )) + case .failure(let message): + lines.append(localization.format( + "report.source.failure", + defaultValue: "- %@ %@:失败(%@)", + result.route.title(localization: localization), + result.source, + message + )) + } + } + } + + if let errorMessage, !errorMessage.isEmpty { + lines.append("") + lines.append(localization.format("report.error", defaultValue: "错误:%@", errorMessage)) + } + + return lines.joined(separator: "\n") + } +} + +enum IPOverviewFormatter { + static func dateTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = PluginRuntimeLocalization.locale + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter.string(from: date) + } + + static func flagEmoji(countryCode: String) -> String? { + let code = countryCode + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + + guard code.count == 2 else { + return nil + } + + var scalars = String.UnicodeScalarView() + for scalar in code.unicodeScalars { + guard let regionalIndicator = UnicodeScalar(127397 + scalar.value) else { + return nil + } + scalars.append(regionalIndicator) + } + + return String(scalars) + } +} + +enum IPOverviewSensitiveValueMask { + static func maskedIP(_ value: String) -> String { + if value.contains(":") { + return maskedIPv6(value) + } + + return maskedIPv4(value) + } + + private static func maskedIPv4(_ value: String) -> String { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { + return "••••" + } + + return "\(parts[0]).\(parts[1]).•••.•••" + } + + private static func maskedIPv6(_ value: String) -> String { + let visibleParts = value + .split(separator: ":", omittingEmptySubsequences: true) + .prefix(2) + guard !visibleParts.isEmpty else { + return "••••:••••" + } + + return "\(visibleParts.joined(separator: ":")):••••:••••" + } +} + +extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewNetworkQualityService.swift b/Plugins/IPOverview/Sources/IPOverviewNetworkQualityService.swift new file mode 100644 index 0000000..10bb76e --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewNetworkQualityService.swift @@ -0,0 +1,428 @@ +import Foundation +import MacToolsPluginKit + +protocol IPOverviewNetworkQualityMeasuring: Sendable { + func measure( + onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void + ) async -> IPOverviewNetworkQualityMeasurementResult +} + +enum IPOverviewNetworkQualityMeasurementResult: Equatable, Sendable { + case success(IPOverviewNetworkQualityMeasurement) + case failure(String) +} + +enum IPOverviewNetworkQualityProgressEvent: Equatable, Sendable { + case phase(IPOverviewNetworkQualityPhase) + case download(Double) + case upload(Double) +} + +struct IPOverviewNetworkQualityService: IPOverviewNetworkQualityMeasuring { + private let executableURL: URL + private let pseudoTerminalExecutableURL: URL? + private let maximumRuntimeSeconds: Int + private let localization: PluginLocalization + + init( + executableURL: URL = URL(fileURLWithPath: "/usr/bin/networkQuality"), + pseudoTerminalExecutableURL: URL? = URL(fileURLWithPath: "/usr/bin/script"), + maximumRuntimeSeconds: Int = 12, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.executableURL = executableURL + self.pseudoTerminalExecutableURL = pseudoTerminalExecutableURL + self.maximumRuntimeSeconds = maximumRuntimeSeconds + self.localization = localization + } + + func measure( + onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void = { _ in } + ) async -> IPOverviewNetworkQualityMeasurementResult { + guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { + return .failure(localization.string( + "speed.error.unavailable", + defaultValue: "当前系统不可用 networkQuality" + )) + } + + do { + let data = try await runNetworkQuality(onProgress: onProgress) + guard let measurement = IPOverviewNetworkQualityParser.measurement(from: data) else { + return .failure(localization.string( + "speed.error.invalidOutput", + defaultValue: "测速结果格式不可读" + )) + } + + return .success(measurement) + } catch is CancellationError { + return .failure(localization.string("speed.error.cancelled", defaultValue: "测速已取消")) + } catch { + return .failure(Self.userFacingMessage(for: error, localization: localization)) + } + } + + private func runNetworkQuality( + onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void + ) async throws -> Data { + let processBox = IPOverviewNetworkQualityProcessBox() + return try await withTaskCancellationHandler { + let process = Process() + let pipe = Pipe() + let errorPipe = Pipe() + let outputBuffer = IPOverviewNetworkQualityOutputBuffer() + let launch = launchConfiguration() + process.executableURL = launch.executableURL + process.arguments = launch.arguments + process.standardOutput = pipe + process.standardError = errorPipe + processBox.process = process + + return try await withCheckedThrowingContinuation { continuation in + let state = IPOverviewNetworkQualityResumeState() + pipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { + return + } + + outputBuffer.append(data) + if let text = String(data: data, encoding: .utf8) { + let events = IPOverviewNetworkQualityParser.progressEvents(from: text) + for event in events { + Task { + await onProgress(event) + } + } + } + } + process.terminationHandler = { terminatedProcess in + pipe.fileHandleForReading.readabilityHandler = nil + let outputData = outputBuffer.data + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + processBox.process = nil + state.resume { + if terminatedProcess.terminationStatus == 0, !outputData.isEmpty { + continuation.resume(returning: outputData) + } else { + continuation.resume(throwing: IPOverviewNetworkQualityServiceError.failed(errorData)) + } + } + } + + do { + try process.run() + } catch { + state.resume { + continuation.resume(throwing: error) + } + } + + if Task.isCancelled { + process.terminate() + processBox.process = nil + state.resume { + continuation.resume(throwing: CancellationError()) + } + } + } + } onCancel: { + processBox.terminate() + } + } + + private func launchConfiguration() -> (executableURL: URL, arguments: [String]) { + let arguments = ["-v", "-s", "-M", "\(maximumRuntimeSeconds)"] + if let pseudoTerminalExecutableURL, + FileManager.default.isExecutableFile(atPath: pseudoTerminalExecutableURL.path) { + return ( + pseudoTerminalExecutableURL, + ["-q", "/dev/null", executableURL.path] + arguments + ) + } + + return (executableURL, arguments) + } + + private static func userFacingMessage( + for error: Error, + localization: PluginLocalization + ) -> String { + if let serviceError = error as? IPOverviewNetworkQualityServiceError { + return serviceError.localizedDescription(localization: localization) + } + + return error.localizedDescription + } +} + +private final class IPOverviewNetworkQualityProcessBox: @unchecked Sendable { + private let lock = NSLock() + private var storedProcess: Process? + + var process: Process? { + get { + lock.lock() + defer { lock.unlock() } + return storedProcess + } + set { + lock.lock() + defer { lock.unlock() } + storedProcess = newValue + } + } + + func terminate() { + let process = process + process?.terminate() + } +} + +private final class IPOverviewNetworkQualityResumeState: @unchecked Sendable { + private let lock = NSLock() + private var didResume = false + + func resume(_ block: () -> Void) { + lock.lock() + defer { lock.unlock() } + guard !didResume else { + return + } + + didResume = true + block() + } +} + +private final class IPOverviewNetworkQualityOutputBuffer: @unchecked Sendable { + private let lock = NSLock() + private var storage = Data() + + var data: Data { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ data: Data) { + lock.lock() + defer { lock.unlock() } + storage.append(data) + } +} + +enum IPOverviewNetworkQualityServiceError: Error { + case failed(Data) + + func localizedDescription(localization: PluginLocalization) -> String { + switch self { + case .failed(let data): + if let message = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty { + return message + } + + return localization.string("speed.error.failed", defaultValue: "测速失败") + } + } +} + +enum IPOverviewNetworkQualityParser { + static func progressEvents(from text: String) -> [IPOverviewNetworkQualityProgressEvent] { + let ttyEvents = ttyProgressEvents(from: text) + if !ttyEvents.isEmpty { + return ttyEvents + } + + var events: [IPOverviewNetworkQualityProgressEvent] = [] + let lowercasedText = text.lowercased() + if lowercasedText.contains("downlink capacity") + || lowercasedText.contains("downlink: capacity") + || lowercasedText.contains("downlink bytes") { + events.append(.phase(.measuringDownload)) + } + if lowercasedText.contains("uplink capacity") + || lowercasedText.contains("uplink: capacity") + || lowercasedText.contains("uplink bytes") { + events.append(.phase(.measuringUpload)) + } + if lowercasedText.contains("latency") || lowercasedText.contains("responsiveness") { + events.append(.phase(.measuringLatency)) + } + events.append(contentsOf: allDoubles(pattern: #"Downlink capacity:\s*([0-9.]+)\s*Mbps"#, in: text).map { + .download($0) + }) + events.append(contentsOf: allDoubles(pattern: #"Downlink:\s*capacity\s*([0-9.]+)\s*Mbps"#, in: text).map { + .download($0) + }) + events.append(contentsOf: allDoubles(pattern: #"Uplink capacity:\s*([0-9.]+)\s*Mbps"#, in: text).map { + .upload($0) + }) + events.append(contentsOf: allDoubles(pattern: #"Uplink:\s*capacity\s*([0-9.]+)\s*Mbps"#, in: text).map { + .upload($0) + }) + return events + } + + private static func ttyProgressEvents(from text: String) -> [IPOverviewNetworkQualityProgressEvent] { + let matches = allCapturePairs( + pattern: #"Downlink:\s*capacity\s*([0-9.]+)\s*Mbps[\s\S]*?Uplink:\s*capacity\s*([0-9.]+)\s*Mbps"#, + in: text + ) + + return matches.flatMap { downloadText, uploadText -> [IPOverviewNetworkQualityProgressEvent] in + let download = Double(downloadText) ?? 0 + let upload = Double(uploadText) ?? 0 + if upload > 0 { + return [.phase(.measuringUpload), .upload(upload)] + } + if download > 0 { + return [.phase(.measuringDownload), .download(download)] + } + + return [.phase(.initializing)] + } + } + + static func measurement(from data: Data) -> IPOverviewNetworkQualityMeasurement? { + if let jsonMeasurement = jsonMeasurement(from: data) { + return jsonMeasurement + } + + guard let text = String(data: data, encoding: .utf8) else { + return nil + } + + return verboseMeasurement(from: text) + } + + private static func jsonMeasurement(from data: Data) -> IPOverviewNetworkQualityMeasurement? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + return IPOverviewNetworkQualityMeasurement( + baseRTTMilliseconds: doubleValue(for: "base_rtt", in: object), + downloadThroughputBitsPerSecond: doubleValue(for: "dl_throughput", in: object), + uploadThroughputBitsPerSecond: doubleValue(for: "ul_throughput", in: object), + downloadResponsivenessRPM: doubleValue(for: "dl_responsiveness", in: object), + uploadResponsivenessRPM: doubleValue(for: "ul_responsiveness", in: object), + downloadPhaseDuration: doubleValue(for: "dl_phase_duration", in: object), + uploadPhaseDuration: doubleValue(for: "ul_phase_duration", in: object), + interfaceName: object["interface_name"] as? String, + testEndpoint: object["test_endpoint"] as? String, + startDate: object["start_date"] as? String, + endDate: object["end_date"] as? String + ) + } + + private static func verboseMeasurement(from text: String) -> IPOverviewNetworkQualityMeasurement? { + let downloadMbps = firstDouble(pattern: #"Downlink capacity:\s*([0-9.]+)\s*Mbps"#, in: text) + let uploadMbps = firstDouble(pattern: #"Uplink capacity:\s*([0-9.]+)\s*Mbps"#, in: text) + let baseRTT = firstDouble(pattern: #"Idle Latency:\s*(?:[0-9.]+\s*RPM\s*\()?([0-9.]+)\s*milliseconds"#, in: text) + ?? firstDouble(pattern: #"Idle Latency:\s*([0-9.]+)\s*milliseconds"#, in: text) + let uploadResponsiveness = firstDouble(pattern: #"Uplink Responsiveness:[^\n]*\([0-9.]+\s*milliseconds\s*\|\s*([0-9.]+)\s*RPM\)"#, in: text) + ?? firstDouble(pattern: #"Uplink Responsiveness:[\s\S]*?\n\s*([0-9.]+)\s*RPM"#, in: text) + let downloadResponsiveness = firstDouble(pattern: #"Downlink Responsiveness:[^\n]*\([0-9.]+\s*milliseconds\s*\|\s*([0-9.]+)\s*RPM\)"#, in: text) + ?? firstDouble(pattern: #"Downlink Responsiveness:[\s\S]*?\n\s*([0-9.]+)\s*RPM"#, in: text) + + guard downloadMbps != nil || uploadMbps != nil || baseRTT != nil else { + return nil + } + + return IPOverviewNetworkQualityMeasurement( + baseRTTMilliseconds: baseRTT, + downloadThroughputBitsPerSecond: downloadMbps.map { $0 * 1_000_000 }, + uploadThroughputBitsPerSecond: uploadMbps.map { $0 * 1_000_000 }, + downloadResponsivenessRPM: downloadResponsiveness, + uploadResponsivenessRPM: uploadResponsiveness, + downloadPhaseDuration: firstDouble(pattern: #"Downlink Phase Length:\s*([0-9.]+)s"#, in: text), + uploadPhaseDuration: firstDouble(pattern: #"Uplink Phase Length:\s*([0-9.]+)s"#, in: text), + interfaceName: firstCapture(pattern: #"Interface:\s*(\S+)"#, in: text), + testEndpoint: firstCapture(pattern: #"Test Endpoint:\s*(\S+)"#, in: text), + startDate: firstCapture(pattern: #"Start:\s*([^\n]+)"#, in: text), + endDate: firstCapture(pattern: #"End:\s*([^\n]+)"#, in: text) + ) + } + + private static func doubleValue(for key: String, in object: [String: Any]) -> Double? { + switch object[key] { + case let value as Double: + return value + case let value as Int: + return Double(value) + case let value as NSNumber: + return value.doubleValue + default: + return nil + } + } + + private static func firstDouble(pattern: String, in text: String) -> Double? { + guard let value = firstCapture(pattern: pattern, in: text) else { + return nil + } + + return Double(value) + } + + private static func allDoubles(pattern: String, in text: String) -> [Double] { + allCaptures(pattern: pattern, in: text).compactMap(Double.init) + } + + private static func allCaptures(pattern: String, in text: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let matches = regex.matches(in: text, range: NSRange(text.startIndex.. String? in + guard match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: text) + else { + return nil + } + + return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + } + + private static func allCapturePairs(pattern: String, in text: String) -> [(String, String)] { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return [] + } + + let matches = regex.matches(in: text, range: NSRange(text.startIndex.. (String, String)? in + guard match.numberOfRanges > 2, + let firstRange = Range(match.range(at: 1), in: text), + let secondRange = Range(match.range(at: 2), in: text) + else { + return nil + } + + return ( + String(text[firstRange]).trimmingCharacters(in: .whitespacesAndNewlines), + String(text[secondRange]).trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + } + + private static func firstCapture(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: text, + range: NSRange(text.startIndex.. 1, + let range = Range(match.range(at: 1), in: text) + else { + return nil + } + + return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewPlugin.swift b/Plugins/IPOverview/Sources/IPOverviewPlugin.swift new file mode 100644 index 0000000..701c3d5 --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewPlugin.swift @@ -0,0 +1,148 @@ +import AppKit +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class IPOverviewPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + IPOverviewPluginProvider(context: context) + } +} + +@MainActor +private struct IPOverviewPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [IPOverviewPlugin(context: context, localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class IPOverviewPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginConfigurationPresenting { + enum ControlID { + static let openSettings = "execute" + static let copyIP = "ip-overview-copy-ip" + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor: PluginPrimaryPanelDescriptor + + private let viewModel: IPOverviewViewModel + private let localization: PluginLocalization + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + var requestConfigurationPresentation: (() -> Void)? + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "ip-overview"), + viewModel: IPOverviewViewModel? = nil, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.localization = localization + self.viewModel = viewModel ?? IPOverviewViewModel(storage: context.storage, localization: localization) + self.metadata = PluginMetadata( + id: "ip-overview", + title: localization.string("metadata.title", defaultValue: "IP 检测"), + iconName: "network", + iconTint: Color(nsColor: .systemBlue), + order: 12, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "查看公网 IP、本地地址和归属地" + ) + ) + self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .button, + menuActionBehavior: .dismissBeforeHandling, + buttonTitleProvider: { localization.string("panel.button.check", defaultValue: "检测") } + ) + self.viewModel.onSnapshotChange = { [weak self] in + self?.onStateChange?() + } + } + + func activate(context: PluginRuntimeContext) { + viewModel.refreshIfNeeded() + } + + func deactivate(reason: PluginDeactivationReason) { + viewModel.cancel() + } + + func refresh() { + viewModel.refreshPublicIP() + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: panelSubtitle, + isOn: viewModel.snapshot.isRefreshing, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: nil, + errorMessage: viewModel.snapshot.errorMessage + ) + } + + var configuration: PluginConfiguration? { + PluginConfiguration( + description: metadata.defaultDescription, + prefersFullHeight: true + ) { _ in + IPOverviewComponentView( + viewModel: self.viewModel, + localization: self.localization, + startsInDetails: true, + showsBackButton: false + ) + } + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .invokeAction(controlID): + handleInvoke(controlID: controlID) + case .setDisclosureExpanded, + .setSwitch, + .setSelection, + .setNavigationSelection, + .clearNavigationSelection, + .setDate, + .setSlider: + break + } + } + + private var panelSubtitle: String { + let snapshot = viewModel.snapshot + if snapshot.isRefreshing { + return localization.string("panel.subtitle.refreshing", defaultValue: "正在检测公网 IP...") + } + + if let ip = snapshot.preferredPublicIP?.ip { + return viewModel.hidesSensitiveInfo ? IPOverviewSensitiveValueMask.maskedIP(ip) : ip + } + + if let errorMessage = snapshot.errorMessage { + return errorMessage + } + + return metadata.defaultDescription + } + + private func handleInvoke(controlID: String) { + switch controlID { + case ControlID.openSettings: + requestConfigurationPresentation?() + case ControlID.copyIP: + viewModel.copy(viewModel.snapshot.preferredPublicIP?.ip) + default: + break + } + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewService.swift b/Plugins/IPOverview/Sources/IPOverviewService.swift new file mode 100644 index 0000000..37802b6 --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewService.swift @@ -0,0 +1,752 @@ +import Darwin +import Foundation +import MacToolsPluginKit + +protocol IPOverviewProviding: Sendable { + func collectSnapshot() async -> IPOverviewSnapshot + func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot +} + +protocol IPOverviewHTTPClient: Sendable { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +extension URLSession: IPOverviewHTTPClient { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await data(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + throw IPOverviewServiceError.invalidResponse + } + + return (data, httpResponse) + } +} + +enum IPOverviewServiceError: LocalizedError { + case invalidResponse + case badStatus(Int) + case invalidPayload + case invalidIP(String) + + var errorDescription: String? { + localizedDescription() + } + + func localizedDescription(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .invalidResponse: + return localization.string("service.error.invalidResponse", defaultValue: "响应无效") + case .badStatus(let statusCode): + return "HTTP \(statusCode)" + case .invalidPayload: + return localization.string("service.error.invalidPayload", defaultValue: "数据格式不正确") + case .invalidIP(let value): + return localization.format("service.error.invalidIP", defaultValue: "IP 无效:%@", value) + } + } +} + +struct IPOverviewService: IPOverviewProviding { + private let httpClient: any IPOverviewHTTPClient + private let timeout: TimeInterval + private let localization: PluginLocalization + + init( + httpClient: any IPOverviewHTTPClient = URLSession.shared, + timeout: TimeInterval = 4, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.httpClient = httpClient + self.timeout = timeout + self.localization = localization + } + + func collectSnapshot() async -> IPOverviewSnapshot { + async let localAddresses = Self.localAddresses() + async let domesticIPv4Results = fetchPublicIP(route: .domestic, family: .ipv4) + async let domesticIPv6Results = fetchPublicIP(route: .domestic, family: .ipv6) + async let internationalIPv4Results = fetchPublicIP(route: .international, family: .ipv4) + async let internationalIPv6Results = fetchPublicIP(route: .international, family: .ipv6) + + let local = await localAddresses + let domesticIPv4 = await domesticIPv4Results + let domesticIPv6 = await domesticIPv6Results + let internationalIPv4 = await internationalIPv4Results + let internationalIPv6 = await internationalIPv6Results + let sourceResults = domesticIPv4.results + + domesticIPv6.results + + internationalIPv4.results + + internationalIPv6.results + let domesticIPv4Result = domesticIPv4.best + let domesticIPv6Result = domesticIPv6.best + let internationalIPv4Result = internationalIPv4.best + let internationalIPv6Result = internationalIPv6.best + + var geoInfoByIP: [String: IPOverviewGeoInfo] = [:] + for result in [ + domesticIPv4Result, + domesticIPv6Result, + internationalIPv4Result, + internationalIPv6Result + ].compactMap({ $0 }) { + if let geoInfo = await fetchGeoInfo(ip: result.ip) { + geoInfoByIP[result.ip] = geoInfo + } + } + + let errorMessage: String? + if domesticIPv4Result == nil + && domesticIPv6Result == nil + && internationalIPv4Result == nil + && internationalIPv6Result == nil { + errorMessage = localization.string( + "service.error.noPublicIP", + defaultValue: "未能从外部检测源获取公网 IP" + ) + } else { + errorMessage = nil + } + + return IPOverviewSnapshot( + domesticIPv4: domesticIPv4Result, + domesticIPv6: domesticIPv6Result, + internationalIPv4: internationalIPv4Result, + internationalIPv6: internationalIPv6Result, + localAddresses: local, + geoInfoByIP: geoInfoByIP, + sourceResults: sourceResults, + lastUpdated: Date(), + errorMessage: errorMessage, + isRefreshing: false + ) + } + + func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot { + async let domesticIPv4Results = fetchPublicIP(route: .domestic, family: .ipv4) + async let domesticIPv6Results = fetchPublicIP(route: .domestic, family: .ipv6) + async let internationalIPv4Results = fetchPublicIP(route: .international, family: .ipv4) + async let internationalIPv6Results = fetchPublicIP(route: .international, family: .ipv6) + + let domesticIPv4 = await domesticIPv4Results + let domesticIPv6 = await domesticIPv6Results + let internationalIPv4 = await internationalIPv4Results + let internationalIPv6 = await internationalIPv6Results + let sourceResults = domesticIPv4.results + + domesticIPv6.results + + internationalIPv4.results + + internationalIPv6.results + let domesticIPv4Result = domesticIPv4.best + let domesticIPv6Result = domesticIPv6.best + let internationalIPv4Result = internationalIPv4.best + let internationalIPv6Result = internationalIPv6.best + let publicIPs = Set([ + domesticIPv4Result?.ip, + domesticIPv6Result?.ip, + internationalIPv4Result?.ip, + internationalIPv6Result?.ip + ].compactMap(\.self)) + + let errorMessage: String? + if domesticIPv4Result == nil + && domesticIPv6Result == nil + && internationalIPv4Result == nil + && internationalIPv6Result == nil { + errorMessage = localization.string( + "service.error.noPublicIP", + defaultValue: "未能从外部检测源获取公网 IP" + ) + } else { + errorMessage = nil + } + + return IPOverviewSnapshot( + domesticIPv4: domesticIPv4Result, + domesticIPv6: domesticIPv6Result, + internationalIPv4: internationalIPv4Result, + internationalIPv6: internationalIPv6Result, + localAddresses: snapshot.localAddresses, + geoInfoByIP: snapshot.geoInfoByIP.filter { publicIPs.contains($0.key) }, + sourceResults: sourceResults, + lastUpdated: Date(), + errorMessage: errorMessage, + isRefreshing: false + ) + } + + private func fetchPublicIP( + route: IPOverviewEgressRoute, + family: IPOverviewAddressFamily + ) async -> (best: IPOverviewPublicIPResult?, results: [IPOverviewSourceResult]) { + let sources = IPOverviewPublicIPSource.sources(route: route, family: family) + let orderByID = Dictionary(uniqueKeysWithValues: sources.enumerated().map { ($0.element.id, $0.offset) }) + + let unorderedResults = await withTaskGroup(of: IPOverviewSourceResult.self) { group in + for source in sources { + group.addTask { + do { + let ip = try await fetchIP(from: source) + return IPOverviewSourceResult( + id: source.id, + family: family, + route: route, + source: source.name, + status: .success(ip) + ) + } catch { + let message = (error as? IPOverviewServiceError)? + .localizedDescription(localization: localization) + ?? error.localizedDescription + return IPOverviewSourceResult( + id: source.id, + family: family, + route: route, + source: source.name, + status: .failure(message) + ) + } + } + } + + var results: [IPOverviewSourceResult] = [] + for await result in group { + results.append(result) + } + return results + } + + let results = unorderedResults.sorted { + (orderByID[$0.id] ?? Int.max) < (orderByID[$1.id] ?? Int.max) + } + let best = results.lazy.compactMap { sourceResult -> IPOverviewPublicIPResult? in + guard case .success(let ip) = sourceResult.status else { + return nil + } + + return IPOverviewPublicIPResult( + family: family, + route: route, + ip: ip, + source: sourceResult.source + ) + }.first + + return (best, results) + } + + private func fetchIP(from source: IPOverviewPublicIPSource) async throws -> String { + let (data, response) = try await fetch(source.url) + guard (200..<300).contains(response.statusCode) else { + throw IPOverviewServiceError.badStatus(response.statusCode) + } + + let ip: String? + switch source.parser { + case .jsonIP: + ip = IPOverviewParser.ipFromJSON(data) + case .cloudflareTrace: + ip = IPOverviewParser.ipFromCloudflareTrace(data) + case .plainText: + ip = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + case .bilibiliIPService: + ip = IPOverviewParser.ipFromBilibiliIPService(data) + case .cipText: + ip = IPOverviewParser.ipFromCIPText(data) + case .ipipText: + ip = IPOverviewParser.ipFromIPIPText(data) + } + + guard let ip else { + throw IPOverviewServiceError.invalidPayload + } + + guard Self.isValidIPAddress(ip, family: source.family) else { + throw IPOverviewServiceError.invalidIP(ip) + } + + return ip + } + + private func fetchGeoInfo(ip: String) async -> IPOverviewGeoInfo? { + for source in IPOverviewGeoSource.allCases { + do { + let (data, response) = try await fetch(source.url(ip: ip)) + guard (200..<300).contains(response.statusCode) else { + continue + } + + switch source { + case .ipwhois: + if let info = IPOverviewParser.geoInfoFromIPWhois(data, ip: ip) { + return info + } + case .ipapi: + if let info = IPOverviewParser.geoInfoFromIPAPI(data, ip: ip) { + return info + } + } + } catch { + continue + } + } + + return nil + } + + private func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.setValue("MacTools IP Overview", forHTTPHeaderField: "User-Agent") + return try await httpClient.data(for: request) + } + + static func localAddresses() -> [IPOverviewLocalAddress] { + var interfaceAddresses: UnsafeMutablePointer? + guard getifaddrs(&interfaceAddresses) == 0, let firstAddress = interfaceAddresses else { + return [] + } + defer { freeifaddrs(interfaceAddresses) } + + var addresses: [IPOverviewLocalAddress] = [] + var pointer: UnsafeMutablePointer? = firstAddress + + while let currentPointer = pointer { + defer { pointer = currentPointer.pointee.ifa_next } + + let name = String(cString: currentPointer.pointee.ifa_name) + guard !isNoiseInterface(name), + (currentPointer.pointee.ifa_flags & UInt32(IFF_UP)) != 0, + let socketAddress = currentPointer.pointee.ifa_addr + else { + continue + } + + let family: IPOverviewAddressFamily + switch Int32(socketAddress.pointee.sa_family) { + case AF_INET: + family = .ipv4 + case AF_INET6: + family = .ipv6 + default: + continue + } + + guard let address = numericAddress(from: socketAddress), + isUsableLocalAddress(address, family: family) + else { + continue + } + + addresses.append(IPOverviewLocalAddress( + id: "\(name)-\(address)", + interfaceName: name, + address: address, + family: family + )) + } + + return addresses.sorted { + if $0.interfaceName == $1.interfaceName { + return $0.address < $1.address + } + + return $0.interfaceName < $1.interfaceName + } + } + + static func isValidIPAddress(_ value: String, family: IPOverviewAddressFamily? = nil) -> Bool { + var ipv4 = in_addr() + var ipv6 = in6_addr() + + switch family { + case .ipv4: + return inet_pton(AF_INET, value, &ipv4) == 1 + case .ipv6: + return inet_pton(AF_INET6, value, &ipv6) == 1 + case nil: + return inet_pton(AF_INET, value, &ipv4) == 1 + || inet_pton(AF_INET6, value, &ipv6) == 1 + } + } + + private static func numericAddress(from pointer: UnsafePointer) -> String? { + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + pointer, + socklen_t(pointer.pointee.sa_len), + &host, + socklen_t(host.count), + nil, + 0, + NI_NUMERICHOST + ) + + guard result == 0 else { + return nil + } + + let nullIndex = host.firstIndex(of: 0) ?? host.endIndex + let bytes = host[.. Bool { + switch family { + case .ipv4: + return !address.hasPrefix("127.") && address != "0.0.0.0" + case .ipv6: + return !address.hasPrefix("::1") + && !address.lowercased().hasPrefix("fe80") + } + } + + private static func isNoiseInterface(_ name: String) -> Bool { + let lowercasedName = name.lowercased() + let noisePrefixes = ["lo", "awdl", "utun", "llw", "bridge", "gif", "stf", "xhc", "anpi"] + return noisePrefixes.contains { lowercasedName.hasPrefix($0) } + } +} + +struct IPOverviewPublicIPSource: Sendable { + enum Parser: Sendable { + case jsonIP + case cloudflareTrace + case plainText + case bilibiliIPService + case cipText + case ipipText + } + + let id: String + let name: String + let route: IPOverviewEgressRoute + let family: IPOverviewAddressFamily + let url: URL + let parser: Parser + + static func sources( + route: IPOverviewEgressRoute, + family: IPOverviewAddressFamily + ) -> [IPOverviewPublicIPSource] { + switch (route, family) { + case (.domestic, .ipv4): + return [ + IPOverviewPublicIPSource( + id: "bilibili-v4", + name: "Bilibili IPv4", + route: .domestic, + family: .ipv4, + url: URL(string: "https://api.live.bilibili.com/ip_service/v1/ip_service/get_ip_addr")!, + parser: .bilibiliIPService + ), + IPOverviewPublicIPSource( + id: "cip-v4", + name: "cip.cc IPv4", + route: .domestic, + family: .ipv4, + url: URL(string: "https://cip.cc")!, + parser: .cipText + ), + IPOverviewPublicIPSource( + id: "ipip-v4", + name: "IPIP IPv4", + route: .domestic, + family: .ipv4, + url: URL(string: "https://myip.ipip.net")!, + parser: .ipipText + ), + IPOverviewPublicIPSource( + id: "netart-v4", + name: "NetArt IPv4", + route: .domestic, + family: .ipv4, + url: URL(string: "https://ipv4.netart.cn")!, + parser: .jsonIP + ) + ] + case (.domestic, .ipv6): + return [ + IPOverviewPublicIPSource( + id: "ddnspod-v6", + name: "DNSPod IPv6", + route: .domestic, + family: .ipv6, + url: URL(string: "https://ipv6.ddnspod.com")!, + parser: .plainText + ), + IPOverviewPublicIPSource( + id: "netart-v6", + name: "NetArt IPv6", + route: .domestic, + family: .ipv6, + url: URL(string: "https://ipv6.netart.cn")!, + parser: .jsonIP + ) + ] + case (.international, .ipv4): + return [ + IPOverviewPublicIPSource( + id: "ipcheck-v4-json", + name: "IPCheck.ing IPv4", + route: .international, + family: .ipv4, + url: URL(string: "https://4.ipcheck.ing")!, + parser: .jsonIP + ), + IPOverviewPublicIPSource( + id: "ipcheck-v4-trace", + name: "IPCheck.ing Trace IPv4", + route: .international, + family: .ipv4, + url: URL(string: "https://4.ipcheck.ing/cdn-cgi/trace")!, + parser: .cloudflareTrace + ), + IPOverviewPublicIPSource( + id: "ipify-v4", + name: "IPify IPv4", + route: .international, + family: .ipv4, + url: URL(string: "https://api4.ipify.org?format=json")!, + parser: .jsonIP + ), + IPOverviewPublicIPSource( + id: "ifconfig-v4", + name: "ifconfig.me IPv4", + route: .international, + family: .ipv4, + url: URL(string: "https://ifconfig.me/ip")!, + parser: .plainText + ) + ] + case (.international, .ipv6): + return [ + IPOverviewPublicIPSource( + id: "ipcheck-v6-json", + name: "IPCheck.ing IPv6", + route: .international, + family: .ipv6, + url: URL(string: "https://6.ipcheck.ing")!, + parser: .jsonIP + ), + IPOverviewPublicIPSource( + id: "ipcheck-v6-trace", + name: "IPCheck.ing Trace IPv6", + route: .international, + family: .ipv6, + url: URL(string: "https://6.ipcheck.ing/cdn-cgi/trace")!, + parser: .cloudflareTrace + ), + IPOverviewPublicIPSource( + id: "ipify-v6", + name: "IPify IPv6", + route: .international, + family: .ipv6, + url: URL(string: "https://api6.ipify.org?format=json")!, + parser: .jsonIP + ) + ] + } + } +} + +enum IPOverviewGeoSource: CaseIterable { + case ipwhois + case ipapi + + func url(ip: String) -> URL { + switch self { + case .ipwhois: + return url(ip: ip, languageCode: IPOverviewLocale.geoLanguageCode()) + case .ipapi: + return URL(string: "https://ipapi.co/\(ip)/json/")! + } + } + + func url(ip: String, languageCode: String?) -> URL { + switch self { + case .ipwhois: + var components = URLComponents(string: "https://ipwho.is/\(ip)")! + if let languageCode { + components.queryItems = [URLQueryItem(name: "lang", value: languageCode)] + } + return components.url! + case .ipapi: + return URL(string: "https://ipapi.co/\(ip)/json/")! + } + } +} + +enum IPOverviewLocale { + static func geoLanguageCode() -> String? { + let preferredLocalization = PluginRuntimeLocalization.preferredLanguages.first + ?? Locale.current.identifier + return geoLanguageCode(preferredLocalization: preferredLocalization) + } + + static func geoLanguageCode(preferredLocalization: String) -> String? { + let normalizedLocalization = preferredLocalization + .replacingOccurrences(of: "_", with: "-") + .lowercased() + + if normalizedLocalization.hasPrefix("zh") { + return "zh-CN" + } + + if normalizedLocalization.hasPrefix("en") { + return "en" + } + + return nil + } +} + +enum IPOverviewParser { + static func ipFromJSON(_ data: Data) -> String? { + guard + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ip = object["ip"] as? String + else { + return nil + } + + return ip.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + static func ipFromCloudflareTrace(_ data: Data) -> String? { + guard let text = String(data: data, encoding: .utf8) else { + return nil + } + + return text + .split(separator: "\n") + .first { $0.hasPrefix("ip=") } + .map { String($0.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) }? + .nilIfEmpty + } + + static func ipFromBilibiliIPService(_ data: Data) -> String? { + guard + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let data = object["data"] as? [String: Any], + let ip = data["addr"] as? String + else { + return nil + } + + return ip.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + static func ipFromCIPText(_ data: Data) -> String? { + guard let text = String(data: data, encoding: .utf8) else { + return nil + } + + return text + .split(separator: "\n") + .lazy + .compactMap { line -> String? in + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "ip" + else { + return nil + } + return String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + .first + } + + static func ipFromIPIPText(_ data: Data) -> String? { + guard let text = String(data: data, encoding: .utf8) else { + return nil + } + + let pattern = #"(?:(?:当前\s*)?IP|Ip)\s*[::]\s*([0-9A-Fa-f:.]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: text, + range: NSRange(text.startIndex.. IPOverviewGeoInfo? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + if let success = object["success"] as? Bool, success == false { + return nil + } + + let connection = object["connection"] as? [String: Any] + let timezone = object["timezone"] as? [String: Any] + let asnValue = connection?["asn"].map { value in + if let intValue = value as? Int { + return "AS\(intValue)" + } + return "\(value)" + } + + return IPOverviewGeoInfo( + ip: ip, + country: object["country"] as? String, + countryCode: object["country_code"] as? String, + region: object["region"] as? String, + city: object["city"] as? String, + isp: connection?["isp"] as? String, + organization: connection?["org"] as? String, + asn: asnValue, + timezone: timezone?["id"] as? String, + networkType: IPOverviewNetworkType.infer( + organization: connection?["org"] as? String, + isp: connection?["isp"] as? String, + isHosting: nil + ), + isProxy: nil, + isHosting: nil, + source: "ipwho.is" + ) + } + + static func geoInfoFromIPAPI(_ data: Data, ip: String) -> IPOverviewGeoInfo? { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + if object["error"] as? Bool == true { + return nil + } + + let asn = object["asn"] as? String + let organization = object["org"] as? String + return IPOverviewGeoInfo( + ip: ip, + country: object["country_name"] as? String, + countryCode: object["country_code"] as? String, + region: object["region"] as? String, + city: object["city"] as? String, + isp: organization, + organization: organization, + asn: asn?.hasPrefix("AS") == true ? asn : asn.map { "AS\($0)" }, + timezone: object["timezone"] as? String, + networkType: IPOverviewNetworkType.infer( + organization: organization, + isp: organization, + isHosting: nil + ), + isProxy: nil, + isHosting: nil, + source: "ipapi.co" + ) + } +} diff --git a/Plugins/IPOverview/Sources/IPOverviewViewModel.swift b/Plugins/IPOverview/Sources/IPOverviewViewModel.swift new file mode 100644 index 0000000..6299f8f --- /dev/null +++ b/Plugins/IPOverview/Sources/IPOverviewViewModel.swift @@ -0,0 +1,689 @@ +import AppKit +import Foundation +import MacToolsPluginKit + +@MainActor +final class IPOverviewViewModel: ObservableObject { + private enum RefreshMode { + case full + case publicIP + } + + private enum StorageKey { + static let cachedState = "cachedState" + static let hidesSensitiveInfo = "hidesSensitiveInfo" + } + + private struct CachedState: Codable { + let cachedAt: Date + let fullSnapshotCachedAt: Date? + let snapshot: IPOverviewSnapshot + let connectivityResults: [IPOverviewConnectivityResult] + let webRTCResults: [IPOverviewLeakTestResult] + let dnsLeakResults: [IPOverviewLeakTestResult] + let networkQualityState: IPOverviewNetworkQualityRunState + } + + private static let cacheTimeToLive: TimeInterval = 24 * 60 * 60 + + @Published private(set) var snapshot: IPOverviewSnapshot = .empty { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var connectivityResults: [IPOverviewConnectivityResult] = [] { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var isCheckingConnectivity = false { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var webRTCResults: [IPOverviewLeakTestResult] = [] { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var dnsLeakResults: [IPOverviewLeakTestResult] = [] { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var isCheckingWebRTC = false { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var isCheckingDNSLeak = false { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var networkQualityState: IPOverviewNetworkQualityRunState = .waiting { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var isShowingDetails = false { + didSet { + onSnapshotChange?() + } + } + @Published private(set) var hidesSensitiveInfo = false { + didSet { + onSnapshotChange?() + } + } + private let provider: any IPOverviewProviding + private let connectivityChecker: any IPOverviewConnectivityChecking + private let leakTester: any IPOverviewLeakTesting + private let networkQualityMeasurer: any IPOverviewNetworkQualityMeasuring + private let storage: PluginStorage + private let localization: PluginLocalization + private let currentDate: @MainActor () -> Date + private var refreshTask: Task? + private var refreshMode: RefreshMode? + private var refreshGeneration = 0 + private var connectivityTask: Task? + private var webRTCTask: Task? + private var dnsLeakTask: Task? + private var networkQualityTask: Task? + private var stateCachedAt: Date? + private var fullSnapshotCachedAt: Date? + var onSnapshotChange: (() -> Void)? + + var isRefreshingAll: Bool { + snapshot.isRefreshing + || isCheckingConnectivity + || isCheckingWebRTC + || isCheckingDNSLeak + || isMeasuringNetworkQuality + } + + var isMeasuringNetworkQuality: Bool { + if case .running = networkQualityState { + return true + } + + return false + } + + init( + provider: (any IPOverviewProviding)? = nil, + connectivityChecker: (any IPOverviewConnectivityChecking)? = nil, + leakTester: (any IPOverviewLeakTesting)? = nil, + networkQualityMeasurer: (any IPOverviewNetworkQualityMeasuring)? = nil, + storage: PluginStorage = UserDefaultsPluginStorage(pluginID: "ip-overview"), + localization: PluginLocalization = PluginLocalization(bundle: .main), + currentDate: @escaping @MainActor () -> Date = { Date() } + ) { + self.localization = localization + self.provider = provider ?? IPOverviewService(localization: localization) + self.connectivityChecker = connectivityChecker ?? IPOverviewConnectivityService(localization: localization) + self.leakTester = leakTester ?? IPOverviewLeakTestService(localization: localization) + self.networkQualityMeasurer = networkQualityMeasurer ?? IPOverviewNetworkQualityService(localization: localization) + self.storage = storage + self.currentDate = currentDate + self.hidesSensitiveInfo = storage.bool(forKey: StorageKey.hidesSensitiveInfo) + self.connectivityResults = Self.loadConnectivityTargets(storage: storage).map { + IPOverviewConnectivityResult(id: $0.id, target: $0, status: .waiting) + } + self.webRTCResults = IPOverviewWebRTCTarget.defaults.map { + IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .waiting) + } + self.dnsLeakResults = Self.dnsLeakProbes(localization: localization).map { + IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .waiting) + } + restoreCachedStateIfFresh() + } + + deinit { + refreshTask?.cancel() + connectivityTask?.cancel() + webRTCTask?.cancel() + dnsLeakTask?.cancel() + networkQualityTask?.cancel() + } + + @discardableResult + func refreshIfNeeded() -> Task? { + guard !hasFreshSnapshot else { + return nil + } + + return refreshPublicIP() + } + + @discardableResult + func refresh() -> Task? { + guard refreshTask == nil else { + return nil + } + + snapshot.isRefreshing = true + snapshot.errorMessage = nil + refreshMode = .full + refreshGeneration += 1 + let generation = refreshGeneration + let task = Task { @MainActor [weak self] in + guard let self else { return } + let nextSnapshot = await provider.collectSnapshot() + defer { + clearRefreshTask(generation: generation) + } + guard !Task.isCancelled, refreshGeneration == generation else { return } + snapshot = nextSnapshot + cacheCurrentState(includeFullSnapshot: true) + } + refreshTask = task + return task + } + + @discardableResult + func refreshPublicIP() -> Task? { + guard refreshTask == nil else { + return nil + } + + snapshot.isRefreshing = true + snapshot.errorMessage = nil + refreshMode = .publicIP + refreshGeneration += 1 + let generation = refreshGeneration + let currentSnapshot = snapshot + let task = Task { @MainActor [weak self] in + guard let self else { return } + let nextSnapshot = await provider.collectPublicIPSnapshot(preserving: currentSnapshot) + defer { + clearRefreshTask(generation: generation) + } + guard !Task.isCancelled, refreshGeneration == generation else { return } + snapshot = nextSnapshot + cacheCurrentState() + } + refreshTask = task + return task + } + + func refreshAllIfNeeded() { + guard !hasFreshDetailState else { + checkDiagnosticsIfNeeded() + measureNetworkQualityIfNeeded() + return + } + + refreshAll() + } + + func refreshAll() { + if refreshMode == .publicIP { + cancelRefresh() + } + + refresh() + checkConnectivity() + checkWebRTCLeak() + checkDNSLeak() + measureNetworkQuality() + } + + func showDetails() { + isShowingDetails = true + } + + func showSummary() { + isShowingDetails = false + } + + func toggleSensitiveInfoVisibility() { + hidesSensitiveInfo.toggle() + storage.set(hidesSensitiveInfo, forKey: StorageKey.hidesSensitiveInfo) + } + + func cancel() { + cancelRefresh() + connectivityTask?.cancel() + connectivityTask = nil + isCheckingConnectivity = false + webRTCTask?.cancel() + webRTCTask = nil + isCheckingWebRTC = false + dnsLeakTask?.cancel() + dnsLeakTask = nil + isCheckingDNSLeak = false + networkQualityTask?.cancel() + networkQualityTask = nil + if isMeasuringNetworkQuality { + networkQualityState = .failed(localization.string("speed.error.cancelled", defaultValue: "测速已取消")) + } + } + + func copy(_ value: String?) { + guard let value, !value.isEmpty else { + return + } + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + func checkConnectivity() { + guard connectivityTask == nil else { + return + } + + isCheckingConnectivity = true + connectivityResults = connectivityResults.map { + IPOverviewConnectivityResult(id: $0.id, target: $0.target, status: .checking) + } + + let targets = connectivityResults.map(\.target) + connectivityTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + connectivityTask = nil + isCheckingConnectivity = false + } + + var nextResults: [IPOverviewConnectivityResult] = [] + for target in targets { + guard !Task.isCancelled else { return } + let result = await connectivityChecker.check(target: target) + nextResults.append(result) + connectivityResults = targets.map { candidate in + nextResults.first(where: { $0.id == candidate.id }) + ?? IPOverviewConnectivityResult( + id: candidate.id, + target: candidate, + status: .checking + ) + } + } + cacheCurrentState() + } + } + + func addConnectivityTarget(name: String, urlString: String) -> String? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedURLString = Self.normalizedURLString(urlString) + guard !trimmedName.isEmpty else { + return localization.string("customTarget.error.emptyName", defaultValue: "请输入名称") + } + guard let normalizedURLString, URL(string: normalizedURLString) != nil else { + return localization.string("customTarget.error.invalidURL", defaultValue: "请输入有效 URL") + } + + let target = IPOverviewConnectivityTarget( + id: "custom-\(UUID().uuidString)", + name: trimmedName, + urlString: normalizedURLString, + isCustom: true + ) + let targets = connectivityResults.map(\.target) + [target] + saveConnectivityTargets(targets) + connectivityResults.append(IPOverviewConnectivityResult( + id: target.id, + target: target, + status: .waiting + )) + cacheCurrentState() + return nil + } + + func removeConnectivityTarget(id: String) { + guard connectivityResults.contains(where: { $0.id == id && $0.target.isCustom }) else { + return + } + + let targets = connectivityResults + .map(\.target) + .filter { $0.id != id } + saveConnectivityTargets(targets) + connectivityResults.removeAll { $0.id == id } + cacheCurrentState() + } + + func checkWebRTCLeak() { + guard webRTCTask == nil else { + return + } + + isCheckingWebRTC = true + let targets = IPOverviewWebRTCTarget.defaults + webRTCResults = targets.map { + IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .checking) + } + + webRTCTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + webRTCTask = nil + isCheckingWebRTC = false + } + + var nextResults: [IPOverviewLeakTestResult] = [] + for target in targets { + guard !Task.isCancelled else { return } + let result = await leakTester.checkWebRTC(target: target) + nextResults.append(result) + webRTCResults = targets.map { candidate in + nextResults.first(where: { $0.id == candidate.id }) + ?? IPOverviewLeakTestResult(id: candidate.id, name: candidate.name, status: .checking) + } + } + cacheCurrentState() + } + } + + func checkDNSLeak() { + guard dnsLeakTask == nil else { + return + } + + isCheckingDNSLeak = true + let probes = Self.dnsLeakProbes(localization: localization) + dnsLeakResults = probes.map { + IPOverviewLeakTestResult(id: $0.id, name: $0.name, status: .checking) + } + + dnsLeakTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { + dnsLeakTask = nil + isCheckingDNSLeak = false + } + + var nextResults: [IPOverviewLeakTestResult] = [] + for probe in probes { + guard !Task.isCancelled else { return } + let result = await leakTester.checkDNS(id: probe.id, name: probe.name) + nextResults.append(result) + dnsLeakResults = probes.map { candidate in + nextResults.first(where: { $0.id == candidate.id }) + ?? IPOverviewLeakTestResult(id: candidate.id, name: candidate.name, status: .checking) + } + } + cacheCurrentState() + } + } + + func measureNetworkQuality() { + guard networkQualityTask == nil else { + return + } + + networkQualityState = .running(.started()) + networkQualityTask = Task { @MainActor [weak self] in + guard let self else { return } + let result = await networkQualityMeasurer.measure { [weak self] event in + await MainActor.run { + self?.handleNetworkQualityProgress(event) + } + } + defer { + networkQualityTask = nil + } + guard !Task.isCancelled else { return } + + switch result { + case .success(let measurement): + networkQualityState = .completed( + measurement, + completedNetworkQualityProgress(for: measurement) + ) + case .failure(let message): + networkQualityState = .failed(message) + } + cacheCurrentState() + } + } + + private func completedNetworkQualityProgress( + for measurement: IPOverviewNetworkQualityMeasurement + ) -> IPOverviewNetworkQualityProgress { + let existingProgress: IPOverviewNetworkQualityProgress + if case .running(let progress) = networkQualityState { + existingProgress = progress + } else { + existingProgress = .started() + } + + return IPOverviewNetworkQualityProgress( + startedAt: existingProgress.startedAt, + phase: .measuringLatency, + downloadSamples: Self.completedNetworkQualitySamples( + existingProgress.downloadSamples, + fallback: measurement.downloadMbps + ), + uploadSamples: Self.completedNetworkQualitySamples( + existingProgress.uploadSamples, + fallback: measurement.uploadMbps + ) + ) + } + + private func handleNetworkQualityProgress(_ event: IPOverviewNetworkQualityProgressEvent) { + guard case .running(var progress) = networkQualityState else { + return + } + + switch event { + case .phase(let phase): + progress.phase = phase + case .download(let value): + progress.phase = .measuringDownload + progress.downloadSamples = Self.appendingNetworkQualitySample( + value, + to: progress.downloadSamples + ) + case .upload(let value): + progress.phase = .measuringUpload + progress.uploadSamples = Self.appendingNetworkQualitySample( + value, + to: progress.uploadSamples + ) + } + + networkQualityState = .running(progress) + } + + func checkDiagnosticsIfNeeded() { + if connectivityResults.contains(where: { $0.status == .waiting }) { + checkConnectivity() + } + + if webRTCResults.contains(where: { $0.status == .waiting }) { + checkWebRTCLeak() + } + + if dnsLeakResults.contains(where: { $0.status == .waiting }) { + checkDNSLeak() + } + } + + private var hasFreshSnapshot: Bool { + guard let freshnessDate = snapshot.lastUpdated else { + return false + } + + return Self.isFresh(freshnessDate, now: currentDate()) + } + + private var hasFreshDetailState: Bool { + guard let fullSnapshotCachedAt else { + return false + } + + return Self.isFresh(fullSnapshotCachedAt, now: currentDate()) + } + + private func measureNetworkQualityIfNeeded() { + switch networkQualityState { + case .waiting, .failed: + measureNetworkQuality() + case .running, .completed: + return + } + } + + @discardableResult + private func restoreCachedStateIfFresh() -> Bool { + guard + let data = storage.data(forKey: StorageKey.cachedState), + let cachedState = try? JSONDecoder().decode(CachedState.self, from: data), + Self.isFresh(cachedState.cachedAt, now: currentDate()) + else { + return false + } + + var restoredSnapshot = cachedState.snapshot + restoredSnapshot.isRefreshing = false + snapshot = restoredSnapshot + connectivityResults = Self.restoredConnectivityResults(cachedState.connectivityResults) + webRTCResults = Self.restoredLeakResults(cachedState.webRTCResults) + dnsLeakResults = Self.restoredLeakResults(cachedState.dnsLeakResults) + networkQualityState = Self.restoredNetworkQualityState(cachedState.networkQualityState) + stateCachedAt = cachedState.cachedAt + fullSnapshotCachedAt = cachedState.fullSnapshotCachedAt + return true + } + + private func cacheCurrentState(includeFullSnapshot: Bool = false) { + let cachedAt = currentDate() + var cachedSnapshot = snapshot + cachedSnapshot.isRefreshing = false + let nextFullSnapshotCachedAt = includeFullSnapshot ? cachedAt : fullSnapshotCachedAt + + let cachedState = CachedState( + cachedAt: cachedAt, + fullSnapshotCachedAt: nextFullSnapshotCachedAt, + snapshot: cachedSnapshot, + connectivityResults: Self.restoredConnectivityResults(connectivityResults), + webRTCResults: Self.restoredLeakResults(webRTCResults), + dnsLeakResults: Self.restoredLeakResults(dnsLeakResults), + networkQualityState: Self.restoredNetworkQualityState(networkQualityState) + ) + + guard let data = try? JSONEncoder().encode(cachedState) else { + return + } + + storage.set(data, forKey: StorageKey.cachedState) + stateCachedAt = cachedAt + fullSnapshotCachedAt = nextFullSnapshotCachedAt + } + + private func saveConnectivityTargets(_ targets: [IPOverviewConnectivityTarget]) { + guard let data = try? JSONEncoder().encode(targets.filter(\.isCustom)) else { + return + } + + storage.set(data, forKey: "customConnectivityTargets") + } + + private static func loadConnectivityTargets(storage: PluginStorage) -> [IPOverviewConnectivityTarget] { + guard + let data = storage.data(forKey: "customConnectivityTargets"), + let customTargets = try? JSONDecoder().decode([IPOverviewConnectivityTarget].self, from: data) + else { + return IPOverviewConnectivityTarget.defaults + } + + return IPOverviewConnectivityTarget.defaults + customTargets + } + + private static func normalizedURLString(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") { + return trimmed + } + + return "https://\(trimmed)" + } + + private static func appendingNetworkQualitySample(_ value: Double, to samples: [Double]) -> [Double] { + let maximumSampleCount = 28 + let nextSamples = samples + [value] + if nextSamples.count <= maximumSampleCount { + return nextSamples + } + + return Array(nextSamples.suffix(maximumSampleCount)) + } + + private static func completedNetworkQualitySamples(_ samples: [Double], fallback: Double?) -> [Double] { + if !samples.isEmpty { + return samples + } + + return fallback.map { [$0] } ?? [] + } + + private static func isFresh(_ date: Date, now: Date) -> Bool { + date >= now || now.timeIntervalSince(date) < cacheTimeToLive + } + + private static func restoredConnectivityResults( + _ results: [IPOverviewConnectivityResult] + ) -> [IPOverviewConnectivityResult] { + results.map { result in + if case .checking = result.status { + return IPOverviewConnectivityResult(id: result.id, target: result.target, status: .waiting) + } + + return result + } + } + + private static func restoredLeakResults(_ results: [IPOverviewLeakTestResult]) -> [IPOverviewLeakTestResult] { + results.map { result in + if case .checking = result.status { + return IPOverviewLeakTestResult(id: result.id, name: result.name, status: .waiting) + } + + return result + } + } + + private static func restoredNetworkQualityState( + _ state: IPOverviewNetworkQualityRunState + ) -> IPOverviewNetworkQualityRunState { + if case .running = state { + return .waiting + } + + return state + } + + private func cancelRefresh() { + refreshGeneration += 1 + refreshTask?.cancel() + refreshTask = nil + refreshMode = nil + snapshot.isRefreshing = false + } + + private func clearRefreshTask(generation: Int) { + guard refreshGeneration == generation else { + return + } + + refreshTask = nil + refreshMode = nil + } + + private static func dnsLeakProbes(localization: PluginLocalization) -> [(id: String, name: String)] { + (1...4).map { + ( + id: "dns-\($0)", + name: localization.format("dns.probe.name", defaultValue: "DNS 出口检测 #%d", $0) + ) + } + } +} diff --git a/Plugins/IPOverview/Tests/IPOverviewParserTests.swift b/Plugins/IPOverview/Tests/IPOverviewParserTests.swift new file mode 100644 index 0000000..ec6f436 --- /dev/null +++ b/Plugins/IPOverview/Tests/IPOverviewParserTests.swift @@ -0,0 +1,229 @@ +import XCTest +@testable import IPOverviewPlugin + +final class IPOverviewParserTests: XCTestCase { + func testParsesIPAddressFromJSONPayload() { + let data = #"{"ip":"203.0.113.8"}"#.data(using: .utf8)! + + XCTAssertEqual(IPOverviewParser.ipFromJSON(data), "203.0.113.8") + } + + func testParsesIPAddressFromCloudflareTracePayload() { + let data = """ + fl=123 + h=4.ipcheck.ing + ip=2001:db8::1 + ts=1710000000 + """.data(using: .utf8)! + + XCTAssertEqual(IPOverviewParser.ipFromCloudflareTrace(data), "2001:db8::1") + } + + func testParsesIPAddressFromBilibiliPayload() { + let data = """ + {"code":0,"data":{"addr":"203.0.113.8","country":"中国"}} + """.data(using: .utf8)! + + XCTAssertEqual(IPOverviewParser.ipFromBilibiliIPService(data), "203.0.113.8") + } + + func testParsesIPAddressFromCIPText() { + let data = """ + IP\t: 203.0.113.8 + 地址\t: 中国 北京 北京 + """.data(using: .utf8)! + + XCTAssertEqual(IPOverviewParser.ipFromCIPText(data), "203.0.113.8") + } + + func testParsesIPAddressFromIPIPText() { + let data = "当前 IP:203.0.113.8 来自于:中国 北京 北京 电信".data(using: .utf8)! + + XCTAssertEqual(IPOverviewParser.ipFromIPIPText(data), "203.0.113.8") + } + + func testParsesIPWhoisGeoPayload() { + let data = """ + { + "success": true, + "country": "United States", + "region": "California", + "city": "Los Angeles", + "connection": { + "asn": 15169, + "org": "Google LLC", + "isp": "Google" + }, + "timezone": { + "id": "America/Los_Angeles" + } + } + """.data(using: .utf8)! + + let info = IPOverviewParser.geoInfoFromIPWhois(data, ip: "8.8.8.8") + + XCTAssertEqual(info?.locationText, "United States / California / Los Angeles") + XCTAssertEqual(info?.asn, "AS15169") + XCTAssertEqual(info?.organization, "Google LLC") + XCTAssertEqual(info?.timezone, "America/Los_Angeles") + } + + func testMapsPreferredLocalizationToGeoLanguageCode() { + XCTAssertEqual(IPOverviewLocale.geoLanguageCode(preferredLocalization: "zh-Hans"), "zh-CN") + XCTAssertEqual(IPOverviewLocale.geoLanguageCode(preferredLocalization: "en"), "en") + XCTAssertNil(IPOverviewLocale.geoLanguageCode(preferredLocalization: "ja")) + } + + func testIPWhoisGeoURLUsesLanguageCode() { + let url = IPOverviewGeoSource.ipwhois.url(ip: "8.8.8.8", languageCode: "zh-CN") + + XCTAssertEqual(url.absoluteString, "https://ipwho.is/8.8.8.8?lang=zh-CN") + } + + func testParsesIPAPIGeoPayload() { + let data = """ + { + "country_name": "United States", + "region": "California", + "city": "Mountain View", + "org": "GOOGLE", + "asn": "AS15169", + "timezone": "America/Los_Angeles" + } + """.data(using: .utf8)! + + let info = IPOverviewParser.geoInfoFromIPAPI(data, ip: "8.8.8.8") + + XCTAssertEqual(info?.locationText, "United States / California / Mountain View") + XCTAssertEqual(info?.networkText, "AS15169 GOOGLE") + XCTAssertEqual(info?.source, "ipapi.co") + } + + func testParsesNetworkQualityPayload() { + let data = """ + { + "base_rtt": 42.5, + "dl_throughput": 125000000, + "ul_throughput": 32000000, + "dl_responsiveness": 80, + "ul_responsiveness": 91.5, + "dl_phase_duration": 4.2, + "ul_phase_duration": 3.8, + "interface_name": "en0", + "test_endpoint": "example.apple.com", + "start_date": "2026-06-24 18:02:05.338", + "end_date": "2026-06-24 18:02:11.095" + } + """.data(using: .utf8)! + + let measurement = IPOverviewNetworkQualityParser.measurement(from: data) + + XCTAssertEqual(measurement?.baseRTTMilliseconds, 42.5) + XCTAssertEqual(measurement?.downloadMbps, 125) + XCTAssertEqual(measurement?.uploadMbps, 32) + XCTAssertEqual(measurement?.uploadResponsivenessRPM, 91.5) + XCTAssertEqual(measurement?.totalPhaseDuration, 8) + XCTAssertEqual(measurement?.interfaceName, "en0") + XCTAssertEqual(measurement?.testEndpoint, "example.apple.com") + } + + func testParsesNetworkQualityVerbosePayload() { + let data = """ + ==== Verbose Results ==== + Uplink capacity: 29.192 Mbps + Downlink capacity: 21.801 Mbps + Idle Latency: 200.707 milliseconds | 298 RPM + Uplink Responsiveness: Low (304.435 milliseconds | 197 RPM) + Downlink Responsiveness: Low (356.107 milliseconds | 168 RPM) + Test Endpoint: example.apple.com + Interface: en0 + Start: 2026-06-24 19:10:11.260 + End: 2026-06-24 19:10:19.946 + Downlink Phase Length: 3.60s + Uplink Phase Length: 3.54s + """.data(using: .utf8)! + + let measurement = IPOverviewNetworkQualityParser.measurement(from: data) + + XCTAssertEqual(measurement?.downloadMbps, 21.801) + XCTAssertEqual(measurement?.uploadMbps, 29.192) + XCTAssertEqual(measurement?.baseRTTMilliseconds, 200.707) + XCTAssertEqual(measurement?.uploadResponsivenessRPM, 197) + XCTAssertEqual(measurement?.downloadResponsivenessRPM, 168) + XCTAssertEqual(measurement?.downloadPhaseDuration, 3.60) + XCTAssertEqual(measurement?.uploadPhaseDuration, 3.54) + XCTAssertEqual(measurement?.interfaceName, "en0") + XCTAssertEqual(measurement?.testEndpoint, "example.apple.com") + } + + func testParsesNetworkQualityProgressEvents() { + let events = IPOverviewNetworkQualityParser.progressEvents(from: """ + Downlink capacity: 21.801 Mbps + Uplink capacity: 29.192 Mbps + Idle Latency: 200.707 milliseconds | 298 RPM + """) + + XCTAssertTrue(events.contains(.phase(.measuringDownload))) + XCTAssertTrue(events.contains(.phase(.measuringUpload))) + XCTAssertTrue(events.contains(.phase(.measuringLatency))) + XCTAssertTrue(events.contains(.download(21.801))) + XCTAssertTrue(events.contains(.upload(29.192))) + } + + func testParsesNetworkQualityTTYProgressEvents() { + let events = IPOverviewNetworkQualityParser.progressEvents(from: """ + \u{1B}[2K + Downlink: capacity 26.079 Mbps, responsiveness 147 RPM (17.608 MB, 6 flows) - Uplink: capacity 0.000 Mbps, responsiveness 0 RPM (0 B, 0 flows) + """) + + XCTAssertTrue(events.contains(.phase(.measuringDownload))) + XCTAssertTrue(events.contains(.download(26.079))) + XCTAssertFalse(events.contains(.phase(.measuringUpload))) + XCTAssertFalse(events.contains(.upload(0))) + } + + func testParsesNetworkQualityTTYUploadProgressEvents() { + let events = IPOverviewNetworkQualityParser.progressEvents(from: """ + \u{1B}[2K + Downlink: capacity 26.079 Mbps, responsiveness 147 RPM (17.608 MB, 6 flows) - Uplink: capacity 24.039 Mbps, responsiveness 173 RPM (9.562 MB, 5 flows) + """) + + XCTAssertTrue(events.contains(.phase(.measuringUpload))) + XCTAssertTrue(events.contains(.upload(24.039))) + XCTAssertFalse(events.contains(.download(26.079))) + } + + func testGradesNetworkQualityMeasurement() { + XCTAssertEqual( + IPOverviewNetworkQualityGrade.evaluate( + baseRTTMilliseconds: 45, + downloadMbps: 150, + uploadMbps: 50 + ), + .excellent + ) + XCTAssertEqual( + IPOverviewNetworkQualityGrade.evaluate( + baseRTTMilliseconds: 120, + downloadMbps: 8, + uploadMbps: 4 + ), + .fair + ) + XCTAssertEqual( + IPOverviewNetworkQualityGrade.evaluate( + baseRTTMilliseconds: 240, + downloadMbps: 2, + uploadMbps: 0.5 + ), + .poor + ) + } + + func testValidatesIPAddressFamily() { + XCTAssertTrue(IPOverviewService.isValidIPAddress("203.0.113.8", family: .ipv4)) + XCTAssertFalse(IPOverviewService.isValidIPAddress("203.0.113.8", family: .ipv6)) + XCTAssertTrue(IPOverviewService.isValidIPAddress("2001:db8::1", family: .ipv6)) + XCTAssertFalse(IPOverviewService.isValidIPAddress("not-an-ip")) + } +} diff --git a/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift b/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift new file mode 100644 index 0000000..12f3f30 --- /dev/null +++ b/Plugins/IPOverview/Tests/IPOverviewPluginTests.swift @@ -0,0 +1,698 @@ +import XCTest +import MacToolsPluginKit +@testable import IPOverviewPlugin + +@MainActor +final class IPOverviewPluginTests: XCTestCase { + func testMetadataUsesStableIdentifier() { + let plugin = IPOverviewPlugin() + + XCTAssertEqual(plugin.metadata.id, "ip-overview") + XCTAssertEqual(plugin.metadata.title, "IP 检测") + } + + func testPluginUsesPrimaryPanelAndConfiguration() { + let plugin = IPOverviewPlugin() + + XCTAssertNotNil(plugin.primaryPanel) + XCTAssertNil(plugin.componentPanel) + XCTAssertNotNil(plugin.configuration) + } + + func testPrimaryPanelUsesButtonEntry() { + let plugin = IPOverviewPlugin() + + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .button) + XCTAssertEqual(plugin.primaryPanelDescriptor.buttonTitle, "检测") + XCTAssertEqual(plugin.primaryPanelDescriptor.menuActionBehavior, .dismissBeforeHandling) + XCTAssertFalse(plugin.primaryPanelState.isExpanded) + XCTAssertNil(plugin.primaryPanelState.detail) + } + + func testPrimaryPanelButtonRequestsConfigurationPresentation() { + let viewModel = IPOverviewViewModel(storage: IPOverviewPluginTestStorage()) + let plugin = IPOverviewPlugin(viewModel: viewModel) + var didRequestConfigurationPresentation = false + plugin.requestConfigurationPresentation = { + didRequestConfigurationPresentation = true + } + + plugin.handleAction(.invokeAction(controlID: IPOverviewPlugin.ControlID.openSettings)) + + XCTAssertTrue(didRequestConfigurationPresentation) + } + + func testRefreshIfNeededOnlyCollectsPublicIP() async throws { + let provider = IPOverviewProviderSpy( + publicSnapshot: IPOverviewSnapshot( + publicIPv4: IPOverviewPublicIPResult( + family: .ipv4, + ip: "203.0.113.8", + source: "Test" + ), + publicIPv6: nil, + localAddresses: [], + geoInfoByIP: [:], + sourceResults: [], + lastUpdated: Date(), + errorMessage: nil, + isRefreshing: false + ) + ) + let viewModel = IPOverviewViewModel( + provider: provider, + storage: IPOverviewPluginTestStorage() + ) + + let refreshTask = try XCTUnwrap(viewModel.refreshIfNeeded()) + await refreshTask.value + + XCTAssertEqual(viewModel.snapshot.publicIPv4?.ip, "203.0.113.8") + let callCounts = await provider.callCounts() + XCTAssertEqual(callCounts.publicIP, 1) + XCTAssertEqual(callCounts.full, 0) + } + + func testRefreshIfNeededUsesFreshCachedSnapshot() async throws { + let now = Date(timeIntervalSinceReferenceDate: 10_000) + let storage = IPOverviewPluginTestStorage() + let cachedSnapshot = testSnapshot(ip: "203.0.113.8", lastUpdated: now) + let cacheWriter = IPOverviewViewModel( + provider: IPOverviewProviderSpy(publicSnapshot: .empty, fullSnapshot: cachedSnapshot), + storage: storage, + currentDate: { now } + ) + + let cacheWriteTask = try XCTUnwrap(cacheWriter.refresh()) + await cacheWriteTask.value + + let provider = IPOverviewProviderSpy( + publicSnapshot: testSnapshot(ip: "198.51.100.24", lastUpdated: now) + ) + let cachedViewModel = IPOverviewViewModel( + provider: provider, + storage: storage, + currentDate: { now.addingTimeInterval(60) } + ) + + XCTAssertEqual(cachedViewModel.snapshot.publicIPv4?.ip, "203.0.113.8") + XCTAssertNil(cachedViewModel.refreshIfNeeded()) + let callCounts = await provider.callCounts() + XCTAssertEqual(callCounts.publicIP, 0) + XCTAssertEqual(callCounts.full, 0) + } + + func testRefreshIfNeededIgnoresExpiredCache() async throws { + let now = Date(timeIntervalSinceReferenceDate: 10_000) + let storage = IPOverviewPluginTestStorage() + let cachedSnapshot = testSnapshot(ip: "203.0.113.8", lastUpdated: now) + let cacheWriter = IPOverviewViewModel( + provider: IPOverviewProviderSpy(publicSnapshot: .empty, fullSnapshot: cachedSnapshot), + storage: storage, + currentDate: { now } + ) + + let cacheWriteTask = try XCTUnwrap(cacheWriter.refresh()) + await cacheWriteTask.value + + let refreshedSnapshot = testSnapshot( + ip: "198.51.100.24", + lastUpdated: now.addingTimeInterval(25 * 60 * 60) + ) + let provider = IPOverviewProviderSpy(publicSnapshot: refreshedSnapshot) + let cachedViewModel = IPOverviewViewModel( + provider: provider, + storage: storage, + currentDate: { now.addingTimeInterval(25 * 60 * 60) } + ) + + let refreshTask = try XCTUnwrap(cachedViewModel.refreshIfNeeded()) + await refreshTask.value + + XCTAssertEqual(cachedViewModel.snapshot.publicIPv4?.ip, "198.51.100.24") + let callCounts = await provider.callCounts() + XCTAssertEqual(callCounts.publicIP, 1) + XCTAssertEqual(callCounts.full, 0) + } + + func testManualRefreshOverridesFreshCache() async throws { + let now = Date(timeIntervalSinceReferenceDate: 10_000) + let storage = IPOverviewPluginTestStorage() + let cachedSnapshot = testSnapshot(ip: "203.0.113.8", lastUpdated: now) + let cacheWriter = IPOverviewViewModel( + provider: IPOverviewProviderSpy(publicSnapshot: .empty, fullSnapshot: cachedSnapshot), + storage: storage, + currentDate: { now } + ) + + let cacheWriteTask = try XCTUnwrap(cacheWriter.refresh()) + await cacheWriteTask.value + + let provider = IPOverviewProviderSpy( + publicSnapshot: .empty, + fullSnapshot: testSnapshot(ip: "198.51.100.24", lastUpdated: now.addingTimeInterval(60)) + ) + let cachedViewModel = IPOverviewViewModel( + provider: provider, + storage: storage, + currentDate: { now.addingTimeInterval(60) } + ) + + let refreshTask = try XCTUnwrap(cachedViewModel.refresh()) + await refreshTask.value + + let callCounts = await provider.callCounts() + XCTAssertEqual(callCounts.publicIP, 0) + XCTAssertEqual(callCounts.full, 1) + } + + func testPublicIPSourcesAreGroupedByEgressRoute() { + let domesticIPv4 = IPOverviewPublicIPSource.sources(route: .domestic, family: .ipv4) + let internationalIPv4 = IPOverviewPublicIPSource.sources(route: .international, family: .ipv4) + + XCTAssertTrue(domesticIPv4.allSatisfy { $0.route == .domestic && $0.family == .ipv4 }) + XCTAssertTrue(internationalIPv4.allSatisfy { $0.route == .international && $0.family == .ipv4 }) + XCTAssertTrue(domesticIPv4.map(\.id).contains("bilibili-v4")) + XCTAssertTrue(internationalIPv4.map(\.id).contains("ipcheck-v4-json")) + } + + func testReportTextLabelsDomesticAndInternationalEgress() { + let snapshot = IPOverviewSnapshot( + domesticIPv4: IPOverviewPublicIPResult( + family: .ipv4, + route: .domestic, + ip: "198.51.100.8", + source: "Domestic Test" + ), + domesticIPv6: nil, + internationalIPv4: IPOverviewPublicIPResult( + family: .ipv4, + route: .international, + ip: "203.0.113.8", + source: "International Test" + ), + internationalIPv6: nil, + localAddresses: [], + geoInfoByIP: [:], + sourceResults: [ + IPOverviewSourceResult( + id: "domestic-test", + family: .ipv4, + route: .domestic, + source: "Domestic Test IPv4", + status: .success("198.51.100.8") + ), + IPOverviewSourceResult( + id: "international-test", + family: .ipv4, + route: .international, + source: "International Test IPv4", + status: .success("203.0.113.8") + ) + ], + lastUpdated: nil, + errorMessage: nil, + isRefreshing: false + ) + let report = snapshot.reportText + + XCTAssertTrue(report.contains("国内出口 IPv4:198.51.100.8")) + XCTAssertTrue(report.contains("国际出口 IPv4:203.0.113.8")) + XCTAssertTrue(report.contains("- 国内出口 Domestic Test IPv4:198.51.100.8")) + XCTAssertTrue(report.contains("- 国际出口 International Test IPv4:203.0.113.8")) + } + + func testMeasureNetworkQualityUpdatesState() async throws { + let measurement = IPOverviewNetworkQualityMeasurement( + baseRTTMilliseconds: 50, + downloadThroughputBitsPerSecond: 100_000_000, + uploadThroughputBitsPerSecond: 20_000_000, + downloadResponsivenessRPM: 80, + uploadResponsivenessRPM: 90, + downloadPhaseDuration: 3, + uploadPhaseDuration: 4, + interfaceName: "en0", + testEndpoint: "example.apple.com", + startDate: nil, + endDate: nil + ) + let viewModel = IPOverviewViewModel( + provider: IPOverviewProviderSpy(publicSnapshot: .empty), + networkQualityMeasurer: IPOverviewNetworkQualityMeasurerSpy(result: .success(measurement)), + storage: IPOverviewPluginTestStorage() + ) + + viewModel.measureNetworkQuality() + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(viewModel.networkQualityState.measurement, measurement) + XCTAssertFalse(viewModel.isMeasuringNetworkQuality) + } + + func testRefreshAllStartsNetworkQualityMeasurement() async throws { + let measurement = IPOverviewNetworkQualityMeasurement( + baseRTTMilliseconds: 42, + downloadThroughputBitsPerSecond: 120_000_000, + uploadThroughputBitsPerSecond: 35_000_000, + downloadResponsivenessRPM: 120, + uploadResponsivenessRPM: 130, + downloadPhaseDuration: 3, + uploadPhaseDuration: 4, + interfaceName: "en0", + testEndpoint: "example.apple.com", + startDate: nil, + endDate: nil + ) + let viewModel = IPOverviewViewModel( + provider: IPOverviewProviderSpy( + publicSnapshot: .empty, + fullSnapshot: testSnapshot(ip: "203.0.113.8", lastUpdated: Date()) + ), + connectivityChecker: IPOverviewConnectivityCheckerSpy(), + leakTester: IPOverviewLeakTesterSpy(), + networkQualityMeasurer: IPOverviewNetworkQualityMeasurerSpy(result: .success(measurement)), + storage: IPOverviewPluginTestStorage() + ) + + viewModel.refreshAll() + + try await waitUntil { + viewModel.networkQualityState.measurement == measurement + } + } + + func testSensitiveInfoVisibilityPersists() { + let storage = IPOverviewPluginTestStorage() + let viewModel = IPOverviewViewModel(storage: storage) + + XCTAssertFalse(viewModel.hidesSensitiveInfo) + + viewModel.toggleSensitiveInfoVisibility() + + XCTAssertTrue(viewModel.hidesSensitiveInfo) + XCTAssertTrue(storage.bool(forKey: "hidesSensitiveInfo")) + } + + func testMeasureNetworkQualityTracksProgressEvents() async throws { + let measurement = IPOverviewNetworkQualityMeasurement( + baseRTTMilliseconds: 50, + downloadThroughputBitsPerSecond: 90_000_000, + uploadThroughputBitsPerSecond: 30_000_000, + downloadResponsivenessRPM: 80, + uploadResponsivenessRPM: 90, + downloadPhaseDuration: 3, + uploadPhaseDuration: 4, + interfaceName: "en0", + testEndpoint: "example.apple.com", + startDate: nil, + endDate: nil + ) + let measurer = IPOverviewNetworkQualityBlockingMeasurerSpy( + result: .success(measurement), + events: [.download(12), .download(24), .upload(8)] + ) + let viewModel = IPOverviewViewModel( + provider: IPOverviewProviderSpy(publicSnapshot: .empty), + networkQualityMeasurer: measurer, + storage: IPOverviewPluginTestStorage() + ) + + viewModel.measureNetworkQuality() + await measurer.waitForProgressEvents() + + guard case .running(let runningProgress) = viewModel.networkQualityState else { + XCTFail("Expected running state") + return + } + XCTAssertEqual(runningProgress.downloadSamples, [12, 24]) + XCTAssertEqual(runningProgress.uploadSamples, [8]) + XCTAssertEqual(runningProgress.phase, .measuringUpload) + + await measurer.finish() + + try await waitUntil { + if case .completed(let completedMeasurement, let progress) = viewModel.networkQualityState { + return completedMeasurement == measurement + && progress.downloadSamples == [12, 24] + && progress.uploadSamples == [8] + && progress.phase == .measuringLatency + } + + return false + } + + guard case .completed = viewModel.networkQualityState else { + XCTFail("Expected completed state") + return + } + } + + func testWebRTCWarnsWhenStunEndpointDiffersFromPublicIP() { + let snapshot = leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US") + let assessment = IPOverviewLeakAssessment.evaluate( + kind: .webRTC, + results: [ + leakResult( + id: "google", + ip: "198.51.100.24", + country: "Japan", + countryCode: "JP" + ) + ], + snapshot: snapshot, + isRunning: false + ) + + XCTAssertEqual(assessment.state, .warning) + XCTAssertEqual(assessment.reason, .webRTCDifferentIP) + XCTAssertEqual(assessment.issueEndpoint?.ip, "198.51.100.24") + } + + func testWebRTCClearsWhenStunEndpointMatchesPublicIP() { + let snapshot = leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US") + let assessment = IPOverviewLeakAssessment.evaluate( + kind: .webRTC, + results: [ + leakResult( + id: "google", + ip: "203.0.113.8", + country: "United States", + countryCode: "US" + ) + ], + snapshot: snapshot, + isRunning: false + ) + + XCTAssertEqual(assessment.state, .clear) + XCTAssertEqual(assessment.reason, .webRTCMatchesPublicIP) + } + + func testWebRTCClearsWhenNoVisibleEndpointIsReturned() { + let assessment = IPOverviewLeakAssessment.evaluate( + kind: .webRTC, + results: [ + IPOverviewLeakTestResult(id: "google", name: "Google", status: .failure("超时")), + IPOverviewLeakTestResult(id: "twilio", name: "Twilio", status: .failure("超时")) + ], + snapshot: leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US"), + isRunning: false + ) + + XCTAssertEqual(assessment.state, .clear) + XCTAssertEqual(assessment.reason, .webRTCNoVisibleEndpoint) + } + + func testDNSWarnsWhenResolverRegionDiffersFromPublicRegion() { + let assessment = IPOverviewLeakAssessment.evaluate( + kind: .dns, + results: [ + leakResult( + id: "dns-1", + ip: "198.51.100.24", + country: "Japan", + countryCode: "JP" + ) + ], + snapshot: leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US"), + isRunning: false + ) + + XCTAssertEqual(assessment.state, .warning) + XCTAssertEqual(assessment.reason, .dnsDifferentEgressRegion) + XCTAssertEqual(assessment.issueEndpoint?.countryCode, "JP") + } + + func testDNSClearsWhenResolverRegionMatchesPublicRegion() { + let assessment = IPOverviewLeakAssessment.evaluate( + kind: .dns, + results: [ + leakResult( + id: "dns-1", + ip: "198.51.100.24", + country: "United States", + countryCode: "US" + ) + ], + snapshot: leakAssessmentSnapshot(publicIPv4: "203.0.113.8", country: "United States", countryCode: "US"), + isRunning: false + ) + + XCTAssertEqual(assessment.state, .clear) + XCTAssertEqual(assessment.reason, .dnsMatchesEgressRegion) + } + +} + +private func leakResult( + id: String, + ip: String, + country: String?, + countryCode: String? +) -> IPOverviewLeakTestResult { + IPOverviewLeakTestResult( + id: id, + name: id, + status: .success(IPOverviewLeakEndpoint( + ip: ip, + natType: nil, + country: country, + countryCode: countryCode, + organization: "Example Network" + )) + ) +} + +private func leakAssessmentSnapshot( + publicIPv4: String, + country: String?, + countryCode: String? +) -> IPOverviewSnapshot { + IPOverviewSnapshot( + publicIPv4: IPOverviewPublicIPResult(family: .ipv4, ip: publicIPv4, source: "Test"), + publicIPv6: nil, + localAddresses: [], + geoInfoByIP: [ + publicIPv4: IPOverviewGeoInfo( + ip: publicIPv4, + country: country, + countryCode: countryCode, + region: nil, + city: nil, + isp: "Example ISP", + organization: "Example ISP", + asn: "AS64500", + timezone: nil, + networkType: .residential, + isProxy: nil, + isHosting: nil, + source: "Test" + ) + ], + sourceResults: [], + lastUpdated: Date(), + errorMessage: nil, + isRefreshing: false + ) +} + +private func testSnapshot(ip: String, lastUpdated: Date) -> IPOverviewSnapshot { + IPOverviewSnapshot( + publicIPv4: IPOverviewPublicIPResult(family: .ipv4, ip: ip, source: "Test"), + publicIPv6: nil, + localAddresses: [ + IPOverviewLocalAddress( + id: "en0-192.168.1.10", + interfaceName: "en0", + address: "192.168.1.10", + family: .ipv4 + ) + ], + geoInfoByIP: [ + ip: IPOverviewGeoInfo( + ip: ip, + country: "United States", + countryCode: "US", + region: "California", + city: "Los Angeles", + isp: "Example ISP", + organization: "Example ISP", + asn: "AS64500", + timezone: "America/Los_Angeles", + networkType: .residential, + isProxy: false, + isHosting: false, + source: "Test" + ) + ], + sourceResults: [ + IPOverviewSourceResult( + id: "test-source", + family: .ipv4, + route: .international, + source: "Test IPv4", + status: .success(ip) + ) + ], + lastUpdated: lastUpdated, + errorMessage: nil, + isRefreshing: false + ) +} + +private actor IPOverviewProviderSpy: IPOverviewProviding { + private(set) var collectSnapshotCallCount = 0 + private(set) var collectPublicIPSnapshotCallCount = 0 + private let publicSnapshot: IPOverviewSnapshot + private let fullSnapshot: IPOverviewSnapshot + + init(publicSnapshot: IPOverviewSnapshot, fullSnapshot: IPOverviewSnapshot = .empty) { + self.publicSnapshot = publicSnapshot + self.fullSnapshot = fullSnapshot + } + + func collectSnapshot() async -> IPOverviewSnapshot { + collectSnapshotCallCount += 1 + return fullSnapshot + } + + func collectPublicIPSnapshot(preserving snapshot: IPOverviewSnapshot) async -> IPOverviewSnapshot { + collectPublicIPSnapshotCallCount += 1 + return publicSnapshot + } + + func callCounts() -> (full: Int, publicIP: Int) { + (collectSnapshotCallCount, collectPublicIPSnapshotCallCount) + } +} + +private struct IPOverviewConnectivityCheckerSpy: IPOverviewConnectivityChecking { + func check(target: IPOverviewConnectivityTarget) async -> IPOverviewConnectivityResult { + IPOverviewConnectivityResult(id: target.id, target: target, status: .reachable(milliseconds: 12)) + } +} + +private struct IPOverviewLeakTesterSpy: IPOverviewLeakTesting { + func checkWebRTC(target: IPOverviewWebRTCTarget) async -> IPOverviewLeakTestResult { + IPOverviewLeakTestResult(id: target.id, name: target.name, status: .failure("未获取到出口")) + } + + func checkDNS(id: String, name: String) async -> IPOverviewLeakTestResult { + IPOverviewLeakTestResult( + id: id, + name: name, + status: .success(IPOverviewLeakEndpoint( + ip: "203.0.113.8", + natType: nil, + country: "United States", + countryCode: "US", + organization: "Example ISP" + )) + ) + } +} + +private struct IPOverviewNetworkQualityMeasurerSpy: IPOverviewNetworkQualityMeasuring { + let result: IPOverviewNetworkQualityMeasurementResult + var events: [IPOverviewNetworkQualityProgressEvent] = [] + + func measure( + onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void + ) async -> IPOverviewNetworkQualityMeasurementResult { + for event in events { + await onProgress(event) + } + return result + } +} + +private final class IPOverviewNetworkQualityBlockingMeasurerSpy: IPOverviewNetworkQualityMeasuring, @unchecked Sendable { + let result: IPOverviewNetworkQualityMeasurementResult + let events: [IPOverviewNetworkQualityProgressEvent] + private let progressGate = IPOverviewNetworkQualityCompletionGate() + private let completionGate = IPOverviewNetworkQualityCompletionGate() + + init( + result: IPOverviewNetworkQualityMeasurementResult, + events: [IPOverviewNetworkQualityProgressEvent] + ) { + self.result = result + self.events = events + } + + func measure( + onProgress: @escaping @Sendable (IPOverviewNetworkQualityProgressEvent) async -> Void + ) async -> IPOverviewNetworkQualityMeasurementResult { + for event in events { + await onProgress(event) + } + await progressGate.open() + + await completionGate.wait() + + return result + } + + func waitForProgressEvents() async { + await progressGate.wait() + } + + func finish() async { + await completionGate.open() + } +} + +private actor IPOverviewNetworkQualityCompletionGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + if isOpen { + return + } + + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func open() { + isOpen = true + let waitingContinuations = continuations + continuations.removeAll() + for continuation in waitingContinuations { + continuation.resume() + } + } +} + +@MainActor +private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @MainActor () -> Bool +) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now >= deadline { + XCTFail("Timed out waiting for condition") + return + } + + try await Task.sleep(for: .milliseconds(10)) + } +} + +@MainActor +private final class IPOverviewPluginTestStorage: 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) {} +} diff --git a/Plugins/IPOverview/plugin.json b/Plugins/IPOverview/plugin.json new file mode 100644 index 0000000..e87c261 --- /dev/null +++ b/Plugins/IPOverview/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "ip-overview", + "displayName": "IP 检测", + "summary": "查看出口 IP、本地 IP、归属地与网络测速摘要", + "localizedMetadata": { + "ar": { + "displayName": "فحص IP", + "summary": "عرض IP الخروج وIP المحلي والموقع وجودة الشبكة." + }, + "de": { + "displayName": "IP-Prüfung", + "summary": "Zeigt Ausgangs-IP, lokale IP, Standort und Netzwerkqualität an." + }, + "en": { + "displayName": "IP Check", + "summary": "View egress IP, local IP, location, and network quality." + }, + "es": { + "displayName": "Comprobación de IP", + "summary": "Consulta la IP de salida, la IP local, la ubicación y la calidad de red." + }, + "fr": { + "displayName": "Vérification IP", + "summary": "Afficher l’IP de sortie, l’IP locale, la localisation et la qualité du réseau." + }, + "ja": { + "displayName": "IPチェック", + "summary": "出口 IP、ローカル IP、所在地、ネットワーク品質を表示します。" + }, + "ko": { + "displayName": "IP 확인", + "summary": "외부 IP, 로컬 IP, 위치 및 네트워크 품질을 확인합니다." + }, + "pt": { + "displayName": "Verificação de IP", + "summary": "Veja IP de saída, IP local, localização e qualidade da rede." + }, + "ru": { + "displayName": "Проверка IP", + "summary": "Просмотр исходящего IP, локального IP, местоположения и качества сети." + }, + "zh-Hans": { + "displayName": "IP 检测", + "summary": "查看出口 IP、本地 IP、归属地与网络测速摘要" + }, + "zh-Hant": { + "displayName": "IP 檢測", + "summary": "查看出口 IP、本地 IP、歸屬地與網路測速摘要" + } + }, + "version": "1.0.12", + "minHostVersion": "1.0.10", + "pluginKitVersion": 3, + "bundleRelativePath": "IPOverview.bundle", + "factoryClass": "IPOverviewPlugin.IPOverviewPluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "IpOverviewPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "monitoring" +} diff --git a/Plugins/KeepAwake/Bundle/KeepAwakePluginBundleEntrypoint.swift b/Plugins/KeepAwake/Bundle/KeepAwakePluginBundleEntrypoint.swift new file mode 100644 index 0000000..c2d338d --- /dev/null +++ b/Plugins/KeepAwake/Bundle/KeepAwakePluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import KeepAwakePlugin + +private let keepAwakePluginFactoryAnchor: Any.Type = KeepAwakePluginFactory.self diff --git a/Plugins/KeepAwake/Resources/Localizable.xcstrings b/Plugins/KeepAwake/Resources/Localizable.xcstrings new file mode 100644 index 0000000..a44fba4 --- /dev/null +++ b/Plugins/KeepAwake/Resources/Localizable.xcstrings @@ -0,0 +1,1426 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "البقاء مستيقظًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wach bleiben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Awake" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantenerse despierto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rester éveillé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起きていてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "깨어 있으십시오" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mantenha-se acordado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не спать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阻止休眠" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阻止休眠" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يمنع سكون النظام؛ ويمكن اختياريًا إبقاء الشاشة قيد التشغيل." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhindert den Ruhezustand des Systems; optional bleibt das Display an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Prevent idle system sleep; optionally keep the display on." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Evita la suspensión del sistema; opcionalmente mantiene la pantalla encendida." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Empêche la mise en veille du système ; option pour garder l’écran allumé." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムのアイドルスリープを防止。必要に応じてディスプレイもオンのままにできます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 유휴 절전을 방지하며, 선택적으로 디스플레이를 켜 둡니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Impede a suspensão ociosa do sistema; opcionalmente mantém a tela ligada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предотвращает спящий режим системы; по желанию не даёт экрану гаснуть." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阻止系统空闲休眠;可选保持屏幕常亮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "阻止系統空閒休眠;可選保持螢幕常亮" + } + } + } + }, + "panel.duration.forever": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أبدًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Niemals" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Never" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nunca" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Jamais" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "決して" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "절대로" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nunca" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Никогда" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "永不" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永不" + } + } + } + }, + "panel.duration.noAutomaticStop": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا إيقاف تلقائي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein automatischer Stopp" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No automatic stop" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin parada automática" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas d’arrêt automatique" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動停止なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 중지 안 함" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sem parada automática" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Без автоостановки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不会自动停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不會自動停止" + } + } + } + }, + "panel.duration.remainingMinutesFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متبقٍ %d د" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch %d Min." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d min remaining" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quedan %d min" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Encore %d min" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残り%d分" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d분 남음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Restam %d min" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Осталось %d мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剩余 %d 分钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剩餘 %d 分鐘" + } + } + } + }, + "panel.duration.remainingHoursFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متبقٍ %d س" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch %d Std." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d hr remaining" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quedan %d h" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Encore %d h" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残り%d時間" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d시간 남음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Restam %d h" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Осталось %d ч" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剩余 %d 小时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剩餘 %d 小時" + } + } + } + }, + "panel.duration.remainingHoursMinutesFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "متبقٍ %d س %d د" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch %d Std. %d Min." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d hr %d min remaining" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quedan %d h %d min" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Encore %d h %d min" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残り%d時間%d分" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d시간 %d분 남음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Restam %d h %d min" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Осталось %d ч %d мин" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "剩余 %d 小时 %d 分钟" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "剩餘 %d 小時 %d 分鐘" + } + } + } + }, + "panel.display.indicator": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشاشة قيد التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Display an" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display on" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pantalla encendida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écran allumé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画面をオン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "화면 켜짐" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tela ligada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экран включён" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕常亮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "螢幕常亮" + } + } + } + }, + "settings.display.section": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشاشة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Display" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Display" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pantalla" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Écran" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tela" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Экран" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "屏幕" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "螢幕" + } + } + } + }, + "panel.display.allowSleep": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح بالإطفاء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausschalten erlauben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow turning off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir apagado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser l’extinction" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "消灯を許可" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "끄기 허용" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permitir desligar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить выключение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "允許關閉" + } + } + } + }, + "panel.display.allowSleep.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يبقى الـ Mac مستيقظًا؛ ويمكن للشاشة أن تنام وفق إعدادات النظام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mac bleibt wach; Display kann laut Systemeinstellungen schlafen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac stays awake; display can sleep per System Settings" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El Mac sigue activo; la pantalla puede apagarse según Ajustes del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le Mac reste éveillé ; l’écran peut s’éteindre selon Réglages Système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac は起動したまま。ディスプレイはシステム設定どおりスリープ可" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mac은 깨어 있고, 디스플레이는 시스템 설정에 따라 꺼질 수 있음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O Mac permanece ativo; a tela pode hibernar conforme Ajustes do Sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Mac не засыпает; экран может гаснуть по настройкам системы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统保持唤醒,屏幕可按系统设置关闭" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統保持喚醒,螢幕可依系統設定關閉" + } + } + } + }, + "settings.display.keepOn": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إبقاء الشاشة مضاءة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Display an lassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep display on" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener pantalla encendida" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Garder l’écran allumé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイをオンのまま" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이 켜 두기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manter tela ligada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не гасить экран" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保持常亮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保持常亮" + } + } + } + }, + "settings.display.keepOn.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يدخل الـ Mac ولا الشاشة سكون الخمول؛ ولا يتجاوز القفل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weder Mac noch Display im Leerlauf; sperrt die Sperre nicht aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Neither Mac nor display idle-sleep; does not bypass lock" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ni el Mac ni la pantalla duermen por inactividad; no evita el bloqueo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ni le Mac ni l’écran ne s’endorment en inactivité ; n’empêche pas le verrouillage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac もディスプレイもアイドルスリープしません。ロックは回避しません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mac과 디스플레이 모두 유휴 절전하지 않음. 잠금은 우회하지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nem o Mac nem a tela hibernam por ociosidade; não ignora o bloqueio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ни Mac, ни экран не засыпают от бездействия; блокировку не отключает" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统与屏幕均不因空闲关闭;不会解除锁屏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統與螢幕均不因空閒關閉;不會解除鎖定畫面" + } + } + } + }, + "error.invalidEndDate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يجب أن يكون وقت التوقف التلقائي متأخرًا عن الآن." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die automatische Stoppzeit muss später als jetzt liegen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The automatic stop time must be later than now." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La hora de parada automática debe ser posterior a la actual." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'heure d'arrêt automatique doit être postérieure à celle actuelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動停止時刻は現在より遅くする必要があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 정지 시간은 지금보다 이후여야 합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O horário de parada automática deve ser posterior ao atual." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Время автоматической остановки должно быть позже, чем сейчас." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动停止时间必须晚于当前时间。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動停止時間必須晚於當前時間。" + } + } + } + }, + "error.assertionCreationFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير قادر على إبقاء النظام نشطًا. خطأ في النظام %d." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das System kann nicht wach gehalten werden. Systemfehler %d." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to keep the system awake. System error %d." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede mantener el sistema despierto. Error del sistema %d." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de maintenir le système éveillé. Erreur système %d." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムを起動状態に保つことができません。システム エラー %d。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템을 활성 상태로 유지할 수 없습니다. 시스템 오류 %d." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível manter o sistema ativado. Erro de sistema %d." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно поддерживать бодрствование системы. Системная ошибка %d." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法启用阻止休眠,系统返回错误 %d。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法啟用阻止休眠,系統返回錯誤 %d。" + } + } + } + }, + "error.displayAssertionCreationFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إبقاء الشاشة مضاءة. خطأ النظام %d." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Display konnte nicht wach gehalten werden. Systemfehler %d." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not keep the display on. System error %d." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo mantener la pantalla encendida. Error del sistema %d." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de garder l’écran allumé. Erreur système %d." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイをオンのままにできませんでした。システムエラー %d。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이를 켠 상태로 유지할 수 없습니다. 시스템 오류 %d." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível manter a tela ligada. Erro do sistema %d." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось оставить экран включённым. Системная ошибка %d." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法保持屏幕常亮,系统返回错误 %d。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法保持螢幕常亮,系統返回錯誤 %d。" + } + } + } + }, + "error.displayAssertionReleaseFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر السماح بإيقاف تشغيل الشاشة. خطأ النظام %d." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ausschalten des Displays konnte nicht wieder zugelassen werden. Systemfehler %d." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not allow the display to turn off. System error %d." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo permitir que la pantalla se apagara. Error del sistema %d." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d’autoriser l’extinction de l’écran. Erreur système %d." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスプレイの消灯を許可できませんでした。システムエラー %d。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스플레이가 꺼지도록 허용할 수 없습니다. 시스템 오류 %d." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível permitir que a tela desligasse. Erro do sistema %d." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось разрешить отключение экрана. Системная ошибка %d." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法允许屏幕关闭,系统返回错误 %d。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法允許螢幕關閉,系統返回錯誤 %d。" + } + } + } + }, + "panel.subtitle.stopTomorrowAtTimeFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غدًا %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Morgen %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tomorrow %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mañana %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Demain %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "明日 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내일 %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Amanhã %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завтра %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "明天 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "明天 %@" + } + } + } + }, + "panel.subtitle.stopAtDateTimeFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + } + } + }, + "panel.subtitle.timedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift b/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift new file mode 100644 index 0000000..898e3c6 --- /dev/null +++ b/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift @@ -0,0 +1,553 @@ +import Foundation +import OSLog +import SwiftUI +import MacToolsPluginKit + +public final class KeepAwakePluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + KeepAwakePluginProvider(context: context) + } +} + +@MainActor +private struct KeepAwakePluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [KeepAwakePlugin(localization: PluginLocalization(bundle: context.resourceBundle))] + } +} + +@MainActor +final class KeepAwakePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPrimaryPanelIndicatorProviding { + typealias SessionFactory = ( + PluginLocalization, + @escaping (KeepAwakeSession.EndReason) -> Void + ) -> any KeepAwakeSessionManaging + + private enum Timing { + static let secondsPerMinute: TimeInterval = 60 + } + + private enum StorageKey { + static let persistentEnabled = "persistent-enabled" + static let keepDisplayOn = "keep-display-on" + } + + private enum ControlID { + static let duration = "duration" + } + + private enum DurationPreset: String { + case forever + case thirtyMinutes + case oneHour + case twoHours + case fiveHours + + var timeInterval: TimeInterval? { + switch self { + case .forever: + return nil + case .thirtyMinutes: + return 30 * 60 + case .oneHour: + return 60 * 60 + case .twoHours: + return 2 * 60 * 60 + case .fiveHours: + return 5 * 60 * 60 + } + } + } + + private enum DurationOptionID { + static let forever = DurationPreset.forever.rawValue + static let thirtyMinutes = DurationPreset.thirtyMinutes.rawValue + static let oneHour = DurationPreset.oneHour.rawValue + static let twoHours = DurationPreset.twoHours.rawValue + static let fiveHours = DurationPreset.fiveHours.rawValue + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .switch, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "KeepAwakePlugin") + private let localization: PluginLocalization + private let sessionFactory: SessionFactory + private var storage: PluginStorage + private var lastErrorMessage: String? + private var session: (any KeepAwakeSessionManaging)? + private var selectedDurationPreset: DurationPreset = .forever + private var keepDisplayOn = false + private var scheduledEndDate: Date? + private var timedStateRefreshTimer: Timer? + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "keep-awake"), + localization: PluginLocalization = PluginLocalization(bundle: .main), + sessionFactory: @escaping SessionFactory = { localization, onEnd in + KeepAwakeSession(localization: localization, onEnd: onEnd) + } + ) { + self.localization = localization + self.storage = context.storage + self.sessionFactory = sessionFactory + self.keepDisplayOn = context.storage.bool(forKey: StorageKey.keepDisplayOn) + self.metadata = PluginMetadata( + id: "keep-awake", + title: localization.string("metadata.title", defaultValue: "阻止休眠"), + iconName: "moon", + iconTint: Color(nsColor: .systemOrange), + order: 50, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "阻止系统空闲休眠;可选保持屏幕常亮" + ) + ) + } + + var primaryPanelState: PluginPanelState { + PluginPanelState( + subtitle: panelSubtitle, + isOn: session != nil, + isExpanded: false, + isEnabled: true, + isVisible: true, + detail: panelDetail, + errorMessage: lastErrorMessage + ) + } + + var primaryPanelIndicator: PluginPrimaryPanelIndicator? { + guard keepDisplayOn, session != nil else { + return nil + } + + return PluginPrimaryPanelIndicator( + text: localization.string("panel.display.indicator", defaultValue: "屏幕常亮"), + systemImage: "display" + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + + var settingsSections: [PluginSettingsSection] { [] } + + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + var configuration: PluginConfiguration? { + PluginConfiguration(description: metadata.defaultDescription) { [weak self, localization] _ in + KeepAwakeSettingsView( + keepDisplayOn: Binding( + get: { self?.keepDisplayOn ?? false }, + set: { [weak self] in self?.setKeepDisplayOn($0) } + ), + localization: localization + ) + } + } + + func activate(context: PluginRuntimeContext) { + storage = context.storage + keepDisplayOn = storage.bool(forKey: StorageKey.keepDisplayOn) + + guard storage.bool(forKey: StorageKey.persistentEnabled) else { + return + } + + selectedDurationPreset = .forever + scheduledEndDate = nil + applyKeepAwakeConfiguration() + } + + func refresh() { + scheduleTimedStateRefreshIfNeeded() + } + + func deactivate(reason: PluginDeactivationReason) { + guard reason.requiresStateCleanup else { return } + session?.requestStop(reason: .userRequested) + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setSwitch(isEnabled): + setKeepAwakeEnabled(isEnabled) + case .setDisclosureExpanded, .setNavigationSelection, .clearNavigationSelection: + return + case let .setSelection(controlID, optionID): + guard controlID == ControlID.duration else { + return + } + updateDurationPreset(using: optionID) + case .setDate, .setSlider, .invokeAction: + return + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + + func handleSettingsAction(id: String) {} + + func handleShortcutAction(id: String) {} + + private var panelSubtitle: String { + guard session != nil else { + return metadata.defaultDescription + } + + if let scheduledEndDate { + let referenceDate = Date() + let remaining = remainingTimeDescription( + until: scheduledEndDate, + referenceDate: referenceDate + ) + let stopAt = KeepAwakeStopScheduleFormatting.absoluteStopLabel( + until: scheduledEndDate, + referenceDate: referenceDate, + localization: localization + ) + return localization.format( + "panel.subtitle.timedFormat", + defaultValue: "%@ · %@", + remaining, + stopAt + ) + } + + return localization.string( + "panel.duration.noAutomaticStop", + defaultValue: "不会自动停止" + ) + } + + private var panelDetail: PluginPanelDetail? { + guard session != nil else { + return nil + } + + return PluginPanelDetail( + primaryControls: [ + PluginPanelControl( + id: ControlID.duration, + kind: .segmented, + options: [ + PluginPanelControlOption( + id: DurationOptionID.forever, + title: localization.string("panel.duration.forever", defaultValue: "永不") + ), + PluginPanelControlOption(id: DurationOptionID.thirtyMinutes, title: "30min"), + PluginPanelControlOption(id: DurationOptionID.oneHour, title: "1h"), + PluginPanelControlOption(id: DurationOptionID.twoHours, title: "2h"), + PluginPanelControlOption(id: DurationOptionID.fiveHours, title: "5h") + ], + selectedOptionID: selectedDurationPreset.rawValue, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + isEnabled: true + ) + ], + secondaryPanel: nil + ) + } + + private func setKeepAwakeEnabled(_ isEnabled: Bool) { + guard isEnabled else { + lastErrorMessage = nil + clearPersistentEnabled() + session?.requestStop(reason: .userRequested) + + if session == nil { + resetSelectionToDefaults() + notifyChange() + } + + return + } + + selectedDurationPreset = .forever + lastErrorMessage = nil + applyKeepAwakeConfiguration() + } + + private func updateDurationPreset(using optionID: String) { + guard let preset = DurationPreset(rawValue: optionID) else { + return + } + + selectedDurationPreset = preset + lastErrorMessage = nil + persistCurrentSelectionIfRunning() + + guard session != nil else { + notifyChange() + return + } + + applyKeepAwakeConfiguration() + } + + func setKeepDisplayOn(_ shouldKeepDisplayOn: Bool) { + guard keepDisplayOn != shouldKeepDisplayOn else { + return + } + + lastErrorMessage = nil + + guard let session else { + keepDisplayOn = shouldKeepDisplayOn + persistKeepDisplayOnPreference() + notifyChange() + return + } + + do { + try session.setPreventDisplaySleep(shouldKeepDisplayOn) + keepDisplayOn = shouldKeepDisplayOn + persistKeepDisplayOnPreference() + notifyChange() + } catch { + logger.error("keep-awake display update failed: \(error.localizedDescription, privacy: .public)") + lastErrorMessage = error.localizedDescription + notifyChange() + } + } + + private func applyKeepAwakeConfiguration() { + let session = session ?? sessionFactory(localization) { [weak self] reason in + self?.handleSessionEnd(reason) + } + let endDate = resolvedScheduledEndDate(referenceDate: Date()) + + do { + try session.start(until: endDate, preventDisplaySleep: keepDisplayOn) + self.session = session + scheduledEndDate = endDate + persistCurrentSelectionIfRunning() + scheduleTimedStateRefreshIfNeeded() + lastErrorMessage = nil + notifyChange() + } catch { + logger.error("keep-awake session update failed: \(error.localizedDescription, privacy: .public)") + lastErrorMessage = error.localizedDescription + notifyChange() + } + } + + private func resolvedScheduledEndDate(referenceDate: Date) -> Date? { + selectedDurationPreset.timeInterval.map(referenceDate.addingTimeInterval) + } + + private func remainingTimeDescription( + until endDate: Date, + referenceDate: Date + ) -> String { + let remainingDuration = max(endDate.timeIntervalSince(referenceDate), 0) + let remainingMinutes = max( + Int(ceil(remainingDuration / Timing.secondsPerMinute)), + 1 + ) + + let hours = remainingMinutes / 60 + let minutes = remainingMinutes % 60 + + if hours == 0 { + return localization.format( + "panel.duration.remainingMinutesFormat", + defaultValue: "剩余 %d 分钟", + remainingMinutes + ) + } + + if minutes == 0 { + return localization.format( + "panel.duration.remainingHoursFormat", + defaultValue: "剩余 %d 小时", + hours + ) + } + + return localization.format( + "panel.duration.remainingHoursMinutesFormat", + defaultValue: "剩余 %d 小时 %d 分钟", + hours, + minutes + ) + } + + private func scheduleTimedStateRefreshIfNeeded() { + invalidateTimedStateRefreshTimer() + + guard session != nil, let scheduledEndDate else { + return + } + + let remainingDuration = scheduledEndDate.timeIntervalSinceNow + + guard remainingDuration > 0 else { + return + } + + let remainder = remainingDuration.truncatingRemainder(dividingBy: Timing.secondsPerMinute) + let nextRefreshInterval = remainder > 0 ? remainder : Timing.secondsPerMinute + + let timer = Timer( + timeInterval: nextRefreshInterval, + repeats: false + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleTimedStateRefreshTimerFired() + } + } + timer.tolerance = min(1, nextRefreshInterval * 0.1) + RunLoop.main.add(timer, forMode: .common) + timedStateRefreshTimer = timer + } + + private func handleTimedStateRefreshTimerFired() { + guard session != nil, scheduledEndDate != nil else { + invalidateTimedStateRefreshTimer() + return + } + + notifyChange() + scheduleTimedStateRefreshIfNeeded() + } + + private func invalidateTimedStateRefreshTimer() { + timedStateRefreshTimer?.invalidate() + timedStateRefreshTimer = nil + } + + private func handleSessionEnd(_ reason: KeepAwakeSession.EndReason) { + session = nil + resetSelectionToDefaults() + + switch reason { + case .userRequested, .completed: + lastErrorMessage = nil + } + + notifyChange() + } + + private func persistCurrentSelectionIfRunning() { + guard session != nil, selectedDurationPreset == .forever else { + clearPersistentEnabled() + return + } + + storage.set(true, forKey: StorageKey.persistentEnabled) + } + + private func clearPersistentEnabled() { + storage.removeObject(forKey: StorageKey.persistentEnabled) + } + + private func persistKeepDisplayOnPreference() { + if keepDisplayOn { + storage.set(true, forKey: StorageKey.keepDisplayOn) + } else { + storage.removeObject(forKey: StorageKey.keepDisplayOn) + } + } + + private func resetSelectionToDefaults() { + selectedDurationPreset = .forever + scheduledEndDate = nil + invalidateTimedStateRefreshTimer() + } + + private func notifyChange() { + onStateChange?() + } +} + +/// Formats the scheduled stop moment for Keep Awake subtitles. +enum KeepAwakeStopScheduleFormatting { + static func absoluteStopLabel( + until endDate: Date, + referenceDate: Date, + calendar: Calendar = .current, + localization: PluginLocalization, + locale: Locale = .current + ) -> String { + let timeZone = calendar.timeZone + let timeText = timeString(from: endDate, locale: locale, timeZone: timeZone) + + if calendar.isDate(endDate, inSameDayAs: referenceDate) { + return timeText + } + + if let tomorrowStart = calendar.date( + byAdding: .day, + value: 1, + to: calendar.startOfDay(for: referenceDate) + ), calendar.isDate(endDate, inSameDayAs: tomorrowStart) { + return localization.format( + "panel.subtitle.stopTomorrowAtTimeFormat", + defaultValue: "明天 %@", + timeText + ) + } + + let dateText = dateString( + from: endDate, + referenceDate: referenceDate, + calendar: calendar, + locale: locale, + timeZone: timeZone + ) + return localization.format( + "panel.subtitle.stopAtDateTimeFormat", + defaultValue: "%@ %@", + dateText, + timeText + ) + } + + private static func timeString(from date: Date, locale: Locale, timeZone: TimeZone) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + // Follow the user's locale and preferred 12/24-hour clock while keeping minutes explicit. + if let format = DateFormatter.dateFormat(fromTemplate: "jm", options: 0, locale: locale) { + formatter.dateFormat = format + } else { + formatter.timeStyle = .short + formatter.dateStyle = .none + } + return formatter.string(from: date) + } + + private static func dateString( + from date: Date, + referenceDate: Date, + calendar: Calendar, + locale: Locale, + timeZone: TimeZone + ) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: referenceDate) + formatter.setLocalizedDateFormatFromTemplate(sameYear ? "MMMd" : "yMMMd") + return formatter.string(from: date) + } +} diff --git a/Plugins/KeepAwake/Sources/KeepAwakeSession.swift b/Plugins/KeepAwake/Sources/KeepAwakeSession.swift new file mode 100644 index 0000000..e0af0fb --- /dev/null +++ b/Plugins/KeepAwake/Sources/KeepAwakeSession.swift @@ -0,0 +1,314 @@ +import AppKit +import Foundation +import IOKit.pwr_mgt +import OSLog +import MacToolsPluginKit + +@MainActor +protocol KeepAwakeSessionManaging: AnyObject { + func start(until endDate: Date?, preventDisplaySleep: Bool) throws + func setPreventDisplaySleep(_ preventDisplaySleep: Bool) throws + func requestStop(reason: KeepAwakeSession.EndReason) +} + +@MainActor +final class KeepAwakeSession: KeepAwakeSessionManaging { + enum AssertionKind: Equatable { + case system + case display + + var type: CFString { + switch self { + case .system: + return kIOPMAssertionTypePreventUserIdleSystemSleep as CFString + case .display: + return kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString + } + } + + var name: CFString { + switch self { + case .system: + return "MacTools Keep Awake" as CFString + case .display: + return "MacTools Keep Awake Display" as CFString + } + } + } + + typealias AssertionCreator = (AssertionKind) -> (result: IOReturn, assertionID: IOPMAssertionID) + typealias AssertionReleaser = (IOPMAssertionID) -> IOReturn + + // IOKit releases are safe during nonisolated teardown; tests inject equivalent deterministic operations. + private final class AssertionOperations: @unchecked Sendable { + private let creator: AssertionCreator + private let releaser: AssertionReleaser + + init(creator: @escaping AssertionCreator, releaser: @escaping AssertionReleaser) { + self.creator = creator + self.releaser = releaser + } + + func create(_ kind: AssertionKind) -> (result: IOReturn, assertionID: IOPMAssertionID) { + creator(kind) + } + + func release(_ assertionID: IOPMAssertionID) -> IOReturn { + releaser(assertionID) + } + } + + enum EndReason { + case userRequested + case completed + } + + private enum SessionError: LocalizedError { + case invalidEndDate(PluginLocalization) + case systemAssertionCreationFailed(IOReturn, PluginLocalization) + case displayAssertionCreationFailed(IOReturn, PluginLocalization) + case displayAssertionReleaseFailed(IOReturn, PluginLocalization) + + var errorDescription: String? { + switch self { + case let .invalidEndDate(localization): + return localization.string( + "error.invalidEndDate", + defaultValue: "自动停止时间必须晚于当前时间。" + ) + case let .systemAssertionCreationFailed(result, localization): + return localization.format( + "error.assertionCreationFailedFormat", + defaultValue: "无法启用阻止休眠,系统返回错误 %d。", + result + ) + case let .displayAssertionCreationFailed(result, localization): + return localization.format( + "error.displayAssertionCreationFailedFormat", + defaultValue: "无法保持屏幕常亮,系统返回错误 %d。", + result + ) + case let .displayAssertionReleaseFailed(result, localization): + return localization.format( + "error.displayAssertionReleaseFailedFormat", + defaultValue: "无法允许屏幕关闭,系统返回错误 %d。", + result + ) + } + } + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "KeepAwakeSession") + private let localization: PluginLocalization + private let onEnd: (EndReason) -> Void + private let assertionOperations: AssertionOperations + + private var systemAssertionID = IOPMAssertionID(0) + private var displayAssertionID = IOPMAssertionID(0) + private var autoStopTask: Task? + private var isStopping = false + private var isObservingTermination = false + + init( + localization: PluginLocalization = PluginLocalization(bundle: .main), + onEnd: @escaping (EndReason) -> Void, + assertionCreator: AssertionCreator? = nil, + assertionReleaser: AssertionReleaser? = nil + ) { + self.localization = localization + self.onEnd = onEnd + let resolvedCreator: AssertionCreator = assertionCreator ?? { kind in + var assertionID = IOPMAssertionID(0) + let result = IOPMAssertionCreateWithName( + kind.type, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + kind.name, + &assertionID + ) + return (result, assertionID) + } + let resolvedReleaser: AssertionReleaser = assertionReleaser ?? { assertionID in + IOPMAssertionRelease(assertionID) + } + self.assertionOperations = AssertionOperations( + creator: resolvedCreator, + releaser: resolvedReleaser + ) + } + + deinit { + autoStopTask?.cancel() + + if systemAssertionID != IOPMAssertionID(0) { + _ = assertionOperations.release(systemAssertionID) + } + + if displayAssertionID != IOPMAssertionID(0) { + _ = assertionOperations.release(displayAssertionID) + } + + if isObservingTermination { + NotificationCenter.default.removeObserver( + self, + name: NSApplication.willTerminateNotification, + object: nil + ) + } + } + + func start(until endDate: Date?, preventDisplaySleep: Bool) throws { + if systemAssertionID == IOPMAssertionID(0) { + try createSystemAssertionIfNeeded() + } + + do { + try updateDisplayAssertion(preventDisplaySleep: preventDisplaySleep) + } catch { + logger.error("failed to update keep-awake display assertion at session start: \(error.localizedDescription, privacy: .public)") + } + try scheduleAutoStop(until: endDate) + installTerminationObserverIfNeeded() + } + + func setPreventDisplaySleep(_ preventDisplaySleep: Bool) throws { + try updateDisplayAssertion(preventDisplaySleep: preventDisplaySleep) + } + + func requestStop(reason: EndReason) { + finish(reason: reason) + } + + private func installTerminationObserverIfNeeded() { + guard !isObservingTermination else { + return + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillTerminate), + name: NSApplication.willTerminateNotification, + object: nil + ) + isObservingTermination = true + } + + private func createSystemAssertionIfNeeded() throws { + let creation = assertionOperations.create(.system) + + guard creation.result == kIOReturnSuccess else { + logger.error("failed to create keep-awake system assertion result=\(creation.result, privacy: .public)") + throw SessionError.systemAssertionCreationFailed(creation.result, localization) + } + + systemAssertionID = creation.assertionID + } + + private func updateDisplayAssertion(preventDisplaySleep: Bool) throws { + if preventDisplaySleep { + if displayAssertionID == IOPMAssertionID(0) { + try createDisplayAssertionIfNeeded() + } + return + } + + try releaseDisplayAssertionIfNeeded() + } + + private func createDisplayAssertionIfNeeded() throws { + let creation = assertionOperations.create(.display) + + guard creation.result == kIOReturnSuccess else { + logger.error("failed to create keep-awake display assertion result=\(creation.result, privacy: .public)") + throw SessionError.displayAssertionCreationFailed(creation.result, localization) + } + + displayAssertionID = creation.assertionID + } + + private func releaseDisplayAssertionIfNeeded() throws { + guard displayAssertionID != IOPMAssertionID(0) else { + return + } + + let existingAssertionID = displayAssertionID + let result = assertionOperations.release(existingAssertionID) + guard result == kIOReturnSuccess else { + logger.error("failed to release keep-awake display assertion result=\(result, privacy: .public)") + throw SessionError.displayAssertionReleaseFailed(result, localization) + } + + displayAssertionID = IOPMAssertionID(0) + } + + private func scheduleAutoStop(until endDate: Date?) throws { + autoStopTask?.cancel() + autoStopTask = nil + + guard let endDate else { + return + } + + let remainingDuration = endDate.timeIntervalSinceNow + + guard remainingDuration > 0 else { + throw SessionError.invalidEndDate(localization) + } + + autoStopTask = Task { [weak self] in + let duration = UInt64(remainingDuration * 1_000_000_000) + + do { + try await Task.sleep(nanoseconds: duration) + } catch { + return + } + + self?.finish(reason: .completed) + } + } + + private func finish(reason: EndReason) { + guard !isStopping else { + return + } + + isStopping = true + invalidateTerminationObserver() + + autoStopTask?.cancel() + autoStopTask = nil + + try? releaseDisplayAssertionIfNeeded() + + if systemAssertionID != IOPMAssertionID(0) { + let existingAssertionID = systemAssertionID + systemAssertionID = IOPMAssertionID(0) + + let result = assertionOperations.release(existingAssertionID) + + if result != kIOReturnSuccess { + logger.error("failed to release keep-awake system assertion result=\(result, privacy: .public)") + } + } + + onEnd(reason) + } + + private func invalidateTerminationObserver() { + guard isObservingTermination else { + return + } + + NotificationCenter.default.removeObserver( + self, + name: NSApplication.willTerminateNotification, + object: nil + ) + isObservingTermination = false + } + + @objc + private func handleAppWillTerminate() { + requestStop(reason: .userRequested) + } +} diff --git a/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift b/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift new file mode 100644 index 0000000..e9bc8a8 --- /dev/null +++ b/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import MacToolsPluginKit + +struct KeepAwakeSettingsView: View { + @Binding var keepDisplayOn: Bool + let localization: PluginLocalization + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + Label( + localization.string("settings.display.section", defaultValue: "屏幕"), + systemImage: "display" + ) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string( + "settings.display.keepOn", + defaultValue: "保持屏幕常亮" + )) + .font(PluginSettingsTheme.Typography.rowTitle) + + Text(localization.string( + "settings.display.keepOn.description", + defaultValue: "阻止休眠运行时,防止屏幕因空闲而关闭。" + )) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Toggle("", isOn: $keepDisplayOn) + .labelsHidden() + .toggleStyle(.switch) + } + .padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal) + .padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical) + .pluginSettingsCardBackground(.plugin) + } + } + } +} diff --git a/Plugins/KeepAwake/Tests/KeepAwakePluginTests.swift b/Plugins/KeepAwake/Tests/KeepAwakePluginTests.swift new file mode 100644 index 0000000..5bad2c0 --- /dev/null +++ b/Plugins/KeepAwake/Tests/KeepAwakePluginTests.swift @@ -0,0 +1,536 @@ +import XCTest +import IOKit.pwr_mgt +import MacToolsPluginKit +@testable import KeepAwakePlugin + +@MainActor +final class KeepAwakePluginTests: XCTestCase { + func testPermanentSessionPersistsAndRestoresAfterHostShutdown() { + let storage = KeepAwakeMemoryStorage() + let firstFactory = KeepAwakeSessionFactory() + let firstPlugin = firstFactory.makePlugin(storage: storage) + + firstPlugin.handleAction(.setSwitch(true)) + + XCTAssertTrue(firstPlugin.primaryPanelState.isOn) + XCTAssertEqual(storage.values["persistent-enabled"] as? Bool, true) + XCTAssertEqual(firstFactory.sessions.count, 1) + XCTAssertEqual(firstFactory.sessions[0].startedConfigurations.count, 1) + XCTAssertNil(firstFactory.sessions[0].startedConfigurations[0].endDate) + XCTAssertFalse(firstFactory.sessions[0].startedConfigurations[0].preventDisplaySleep) + + firstPlugin.deactivate(reason: .hostShutdown) + + XCTAssertFalse(firstPlugin.primaryPanelState.isOn) + XCTAssertEqual(storage.values["persistent-enabled"] as? Bool, true) + XCTAssertEqual(firstFactory.sessions[0].stopRequestCount, 1) + + let secondFactory = KeepAwakeSessionFactory() + let secondPlugin = secondFactory.makePlugin(storage: storage) + secondPlugin.activate(context: Self.context(storage: storage)) + + XCTAssertTrue(secondPlugin.primaryPanelState.isOn) + XCTAssertEqual(storage.values["persistent-enabled"] as? Bool, true) + XCTAssertEqual(secondFactory.sessions.count, 1) + XCTAssertEqual(secondFactory.sessions[0].startedConfigurations.count, 1) + XCTAssertNil(secondFactory.sessions[0].startedConfigurations[0].endDate) + XCTAssertFalse(secondFactory.sessions[0].startedConfigurations[0].preventDisplaySleep) + } + + func testTemporarySessionDoesNotRestoreAfterHostShutdown() { + let storage = KeepAwakeMemoryStorage() + let firstFactory = KeepAwakeSessionFactory() + let firstPlugin = firstFactory.makePlugin(storage: storage) + + firstPlugin.handleAction(.setSwitch(true)) + firstPlugin.handleAction(.setSelection(controlID: "duration", optionID: "oneHour")) + + XCTAssertTrue(firstPlugin.primaryPanelState.isOn) + XCTAssertNil(storage.values["persistent-enabled"]) + XCTAssertEqual(firstFactory.sessions.count, 1) + XCTAssertEqual(firstFactory.sessions[0].startedConfigurations.count, 2) + XCTAssertNotNil(firstFactory.sessions[0].startedConfigurations[1].endDate) + + firstPlugin.deactivate(reason: .hostShutdown) + + XCTAssertFalse(firstPlugin.primaryPanelState.isOn) + XCTAssertNil(storage.values["persistent-enabled"]) + + let secondFactory = KeepAwakeSessionFactory() + let secondPlugin = secondFactory.makePlugin(storage: storage) + secondPlugin.activate(context: Self.context(storage: storage)) + + XCTAssertFalse(secondPlugin.primaryPanelState.isOn) + XCTAssertTrue(secondFactory.sessions.isEmpty) + } + + func testManualSwitchOffClearsPermanentRestoreState() { + let storage = KeepAwakeMemoryStorage() + let firstFactory = KeepAwakeSessionFactory() + let firstPlugin = firstFactory.makePlugin(storage: storage) + + firstPlugin.handleAction(.setSwitch(true)) + XCTAssertEqual(storage.values["persistent-enabled"] as? Bool, true) + + firstPlugin.handleAction(.setSwitch(false)) + + XCTAssertFalse(firstPlugin.primaryPanelState.isOn) + XCTAssertNil(storage.values["persistent-enabled"]) + XCTAssertEqual(firstFactory.sessions[0].stopRequestCount, 1) + + let secondFactory = KeepAwakeSessionFactory() + let secondPlugin = secondFactory.makePlugin(storage: storage) + secondPlugin.activate(context: Self.context(storage: storage)) + + XCTAssertFalse(secondPlugin.primaryPanelState.isOn) + XCTAssertTrue(secondFactory.sessions.isEmpty) + } + + func testKeepDisplayOnSettingDefaultsToOffAndUpdatesRunningSession() { + let storage = KeepAwakeMemoryStorage() + let factory = KeepAwakeSessionFactory() + let plugin = factory.makePlugin(storage: storage) + + plugin.handleAction(.setSwitch(true)) + XCTAssertEqual(factory.sessions[0].startedConfigurations.last?.preventDisplaySleep, false) + XCTAssertNil(storage.values["keep-display-on"]) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "不会自动停止") + XCTAssertEqual(plugin.primaryPanelState.detail?.primaryControls.map(\.id), ["duration"]) + XCTAssertNotNil(plugin.configuration) + XCTAssertNil(plugin.primaryPanelIndicator) + + plugin.setKeepDisplayOn(true) + + XCTAssertEqual(factory.sessions[0].startedConfigurations.count, 1) + XCTAssertEqual(factory.sessions[0].displaySleepPreventionUpdates, [true]) + XCTAssertEqual(storage.values["keep-display-on"] as? Bool, true) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "不会自动停止") + XCTAssertEqual( + plugin.primaryPanelIndicator, + PluginPrimaryPanelIndicator(text: "屏幕常亮", systemImage: "display") + ) + + plugin.setKeepDisplayOn(false) + + XCTAssertEqual(factory.sessions[0].displaySleepPreventionUpdates, [true, false]) + XCTAssertNil(storage.values["keep-display-on"]) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "不会自动停止") + XCTAssertNil(plugin.primaryPanelIndicator) + } + + func testKeepDisplayOnSettingAppliesToFutureSession() { + let storage = KeepAwakeMemoryStorage() + let factory = KeepAwakeSessionFactory() + let plugin = factory.makePlugin(storage: storage) + + plugin.setKeepDisplayOn(true) + + XCTAssertEqual(storage.values["keep-display-on"] as? Bool, true) + XCTAssertTrue(factory.sessions.isEmpty) + XCTAssertFalse(plugin.primaryPanelState.isOn) + XCTAssertNil(plugin.primaryPanelIndicator) + + plugin.handleAction(.setSwitch(true)) + + XCTAssertEqual(factory.sessions.count, 1) + XCTAssertEqual(factory.sessions[0].startedConfigurations.last?.preventDisplaySleep, true) + XCTAssertNotNil(plugin.primaryPanelIndicator) + } + + func testKeepDisplayOnUpdatePreservesTimedSessionEndDate() { + let storage = KeepAwakeMemoryStorage() + let factory = KeepAwakeSessionFactory() + let plugin = factory.makePlugin(storage: storage) + + plugin.handleAction(.setSwitch(true)) + plugin.handleAction(.setSelection(controlID: "duration", optionID: "oneHour")) + + let session = factory.sessions[0] + let scheduledEndDate = session.startedConfigurations.last?.endDate + XCTAssertNotNil(scheduledEndDate) + + plugin.setKeepDisplayOn(true) + + XCTAssertEqual(session.startedConfigurations.count, 2) + XCTAssertEqual(session.startedConfigurations.last?.endDate, scheduledEndDate) + XCTAssertEqual(session.displaySleepPreventionUpdates, [true]) + } + + func testFailedKeepDisplayOnUpdateKeepsAppliedPreferenceAndCanRetry() { + let storage = KeepAwakeMemoryStorage() + let factory = KeepAwakeSessionFactory() + let plugin = factory.makePlugin(storage: storage) + + plugin.handleAction(.setSwitch(true)) + let session = factory.sessions[0] + session.displayUpdateError = MockKeepAwakeSessionError.displayUpdateFailed + + plugin.setKeepDisplayOn(true) + + XCTAssertEqual(session.displaySleepPreventionUpdates, [true]) + XCTAssertNil(storage.values["keep-display-on"]) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "不会自动停止") + XCTAssertNotNil(plugin.primaryPanelState.errorMessage) + + session.displayUpdateError = nil + plugin.setKeepDisplayOn(true) + + XCTAssertEqual(session.displaySleepPreventionUpdates, [true, true]) + XCTAssertEqual(storage.values["keep-display-on"] as? Bool, true) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "不会自动停止") + XCTAssertNil(plugin.primaryPanelState.errorMessage) + } + + func testKeepDisplayOnPreferenceRestoresWithPermanentSession() { + let storage = KeepAwakeMemoryStorage() + let firstFactory = KeepAwakeSessionFactory() + let firstPlugin = firstFactory.makePlugin(storage: storage) + + firstPlugin.handleAction(.setSwitch(true)) + firstPlugin.setKeepDisplayOn(true) + firstPlugin.deactivate(reason: .hostShutdown) + + let secondFactory = KeepAwakeSessionFactory() + let secondPlugin = secondFactory.makePlugin(storage: storage) + secondPlugin.activate(context: Self.context(storage: storage)) + + XCTAssertTrue(secondPlugin.primaryPanelState.isOn) + XCTAssertEqual(secondFactory.sessions[0].startedConfigurations.last?.preventDisplaySleep, true) + XCTAssertEqual(storage.values["keep-display-on"] as? Bool, true) + } + + func testKeepDisplayOnPreferenceSurvivesSessionOff() { + let storage = KeepAwakeMemoryStorage() + let factory = KeepAwakeSessionFactory() + let plugin = factory.makePlugin(storage: storage) + + plugin.handleAction(.setSwitch(true)) + plugin.setKeepDisplayOn(true) + plugin.handleAction(.setSwitch(false)) + + XCTAssertEqual(storage.values["keep-display-on"] as? Bool, true) + + plugin.handleAction(.setSwitch(true)) + XCTAssertEqual(factory.sessions.last?.startedConfigurations.last?.preventDisplaySleep, true) + } + + func testTimedSessionSubtitleKeepsRemainingAndAbsoluteStopCompact() { + let storage = KeepAwakeMemoryStorage() + let factory = KeepAwakeSessionFactory() + let plugin = factory.makePlugin(storage: storage) + + plugin.handleAction(.setSwitch(true)) + plugin.handleAction(.setSelection(controlID: "duration", optionID: "twoHours")) + + let subtitle = plugin.primaryPanelState.subtitle + XCTAssertTrue(subtitle.contains(":"), subtitle) + XCTAssertTrue(subtitle.contains("·"), subtitle) + XCTAssertTrue(subtitle.contains("剩余"), subtitle) + XCTAssertEqual(subtitle.filter { $0 == "·" }.count, 1, subtitle) + } + + private static func context(storage: KeepAwakeMemoryStorage) -> PluginRuntimeContext { + PluginRuntimeContext(pluginID: "keep-awake", storage: storage) + } +} + +final class KeepAwakeStopScheduleFormattingTests: XCTestCase { + private let localization = PluginLocalization(bundle: .main) + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private var locale: Locale { Locale(identifier: "en_US_POSIX") } + + func testSameDayStopUsesTimeOnly() { + let reference = date(year: 2026, month: 7, day: 10, hour: 14, minute: 0) + let end = date(year: 2026, month: 7, day: 10, hour: 16, minute: 30) + + let label = KeepAwakeStopScheduleFormatting.absoluteStopLabel( + until: end, + referenceDate: reference, + calendar: calendar, + localization: localization, + locale: locale + ) + + XCTAssertTrue(label.contains("4:30"), label) + XCTAssertFalse(label.contains("16:30"), label) + } + + func testSameDayStopUsesLocalePreferred24HourClock() { + let reference = date(year: 2026, month: 7, day: 10, hour: 14, minute: 0) + let end = date(year: 2026, month: 7, day: 10, hour: 16, minute: 30) + + let label = KeepAwakeStopScheduleFormatting.absoluteStopLabel( + until: end, + referenceDate: reference, + calendar: calendar, + localization: localization, + locale: Locale(identifier: "en_GB") + ) + + XCTAssertTrue(label.contains("16:30"), label) + } + + func testNextDayStopUsesTomorrowPrefix() { + let reference = date(year: 2026, month: 7, day: 10, hour: 22, minute: 0) + let end = date(year: 2026, month: 7, day: 11, hour: 1, minute: 15) + + let label = KeepAwakeStopScheduleFormatting.absoluteStopLabel( + until: end, + referenceDate: reference, + calendar: calendar, + localization: localization, + locale: locale + ) + + // PluginLocalization falls back to default Chinese copy when the key is not in the test host bundle. + XCTAssertTrue(label.contains("1:15"), label) + XCTAssertTrue( + label.contains("Tomorrow") || label.contains("明天"), + label + ) + } + + func testLaterDayStopIncludesDate() { + let reference = date(year: 2026, month: 7, day: 10, hour: 12, minute: 0) + let end = date(year: 2026, month: 7, day: 12, hour: 9, minute: 5) + + let label = KeepAwakeStopScheduleFormatting.absoluteStopLabel( + until: end, + referenceDate: reference, + calendar: calendar, + localization: localization, + locale: locale + ) + + XCTAssertTrue(label.contains("9:05"), label) + XCTAssertFalse(label.contains("Tomorrow") || label.contains("明天"), label) + XCTAssertTrue(label.contains("Jul") || label.contains("7") || label.contains("12"), label) + } + + private func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + return calendar.date(from: components)! + } +} + +@MainActor +final class KeepAwakeSessionTests: XCTestCase { + func testDisplayAssertionFailureDoesNotPreventSessionStart() throws { + var createdKinds: [KeepAwakeSession.AssertionKind] = [] + var releasedAssertionIDs: [IOPMAssertionID] = [] + let session = KeepAwakeSession( + onEnd: { _ in }, + assertionCreator: { kind in + createdKinds.append(kind) + switch kind { + case .system: + return (kIOReturnSuccess, 1) + case .display: + return (kIOReturnError, 0) + } + }, + assertionReleaser: { assertionID in + releasedAssertionIDs.append(assertionID) + return kIOReturnSuccess + } + ) + + try session.start(until: nil, preventDisplaySleep: true) + session.requestStop(reason: .userRequested) + + XCTAssertEqual(createdKinds, [.system, .display]) + XCTAssertEqual(releasedAssertionIDs, [1]) + } + + func testDisplayAssertionCanBeUpdatedWithoutRestartingSystemAssertion() throws { + var createdKinds: [KeepAwakeSession.AssertionKind] = [] + var releasedAssertionIDs: [IOPMAssertionID] = [] + var nextAssertionID = IOPMAssertionID(1) + let session = KeepAwakeSession( + onEnd: { _ in }, + assertionCreator: { kind in + createdKinds.append(kind) + defer { nextAssertionID += 1 } + return (kIOReturnSuccess, nextAssertionID) + }, + assertionReleaser: { assertionID in + releasedAssertionIDs.append(assertionID) + return kIOReturnSuccess + } + ) + + try session.start(until: nil, preventDisplaySleep: false) + try session.setPreventDisplaySleep(true) + try session.setPreventDisplaySleep(false) + session.requestStop(reason: .userRequested) + + XCTAssertEqual(createdKinds, [.system, .display]) + XCTAssertEqual(releasedAssertionIDs, [2, 1]) + } + + func testFailedDisplayAssertionCreationCanBeRetried() throws { + var displayCreationAttempts = 0 + var releasedAssertionIDs: [IOPMAssertionID] = [] + let session = KeepAwakeSession( + onEnd: { _ in }, + assertionCreator: { kind in + switch kind { + case .system: + return (kIOReturnSuccess, 1) + case .display: + displayCreationAttempts += 1 + return displayCreationAttempts == 1 + ? (kIOReturnError, 0) + : (kIOReturnSuccess, 2) + } + }, + assertionReleaser: { assertionID in + releasedAssertionIDs.append(assertionID) + return kIOReturnSuccess + } + ) + + try session.start(until: nil, preventDisplaySleep: false) + XCTAssertThrowsError(try session.setPreventDisplaySleep(true)) + try session.setPreventDisplaySleep(true) + session.requestStop(reason: .userRequested) + + XCTAssertEqual(displayCreationAttempts, 2) + XCTAssertEqual(releasedAssertionIDs, [2, 1]) + } + + func testFailedDisplayAssertionReleaseRetainsAssertionForRetry() throws { + var shouldFailDisplayRelease = true + var releasedAssertionIDs: [IOPMAssertionID] = [] + let session = KeepAwakeSession( + onEnd: { _ in }, + assertionCreator: { kind in + switch kind { + case .system: + return (kIOReturnSuccess, 1) + case .display: + return (kIOReturnSuccess, 2) + } + }, + assertionReleaser: { assertionID in + releasedAssertionIDs.append(assertionID) + if assertionID == 2, shouldFailDisplayRelease { + return kIOReturnError + } + return kIOReturnSuccess + } + ) + + try session.start(until: nil, preventDisplaySleep: true) + XCTAssertThrowsError(try session.setPreventDisplaySleep(false)) + shouldFailDisplayRelease = false + try session.setPreventDisplaySleep(false) + session.requestStop(reason: .userRequested) + + XCTAssertEqual(releasedAssertionIDs, [2, 2, 1]) + } +} + +@MainActor +private final class KeepAwakeSessionFactory { + private(set) var sessions: [MockKeepAwakeSession] = [] + + func makePlugin(storage: KeepAwakeMemoryStorage) -> KeepAwakePlugin { + KeepAwakePlugin( + context: PluginRuntimeContext(pluginID: "keep-awake", storage: storage), + sessionFactory: { [weak self] _, onEnd in + let session = MockKeepAwakeSession(onEnd: onEnd) + self?.sessions.append(session) + return session + } + ) + } +} + +@MainActor +private final class MockKeepAwakeSession: KeepAwakeSessionManaging { + struct Configuration: Equatable { + let endDate: Date? + let preventDisplaySleep: Bool + } + + private let onEnd: (KeepAwakeSession.EndReason) -> Void + private(set) var startedConfigurations: [Configuration] = [] + private(set) var displaySleepPreventionUpdates: [Bool] = [] + private(set) var stopRequestCount = 0 + var displayUpdateError: Error? + + init(onEnd: @escaping (KeepAwakeSession.EndReason) -> Void) { + self.onEnd = onEnd + } + + func start(until endDate: Date?, preventDisplaySleep: Bool) throws { + startedConfigurations.append( + Configuration(endDate: endDate, preventDisplaySleep: preventDisplaySleep) + ) + } + + func setPreventDisplaySleep(_ preventDisplaySleep: Bool) throws { + displaySleepPreventionUpdates.append(preventDisplaySleep) + if let displayUpdateError { + throw displayUpdateError + } + } + + func requestStop(reason: KeepAwakeSession.EndReason) { + stopRequestCount += 1 + onEnd(reason) + } +} + +private enum MockKeepAwakeSessionError: LocalizedError { + case displayUpdateFailed + + var errorDescription: String? { + "无法更新屏幕状态。" + } +} + +@MainActor +private final class KeepAwakeMemoryStorage: PluginStorage { + 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) { + guard let value else { + values.removeValue(forKey: key) + return + } + + 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[legacyKey] else { + return + } + + values[key] = value + values.removeValue(forKey: legacyKey) + } +} diff --git a/Plugins/KeepAwake/plugin.json b/Plugins/KeepAwake/plugin.json new file mode 100644 index 0000000..d667bcb --- /dev/null +++ b/Plugins/KeepAwake/plugin.json @@ -0,0 +1,67 @@ +{ + "id": "keep-awake", + "displayName": "阻止休眠", + "summary": "阻止系统空闲休眠;可选保持屏幕常亮", + "localizedMetadata": { + "ar": { + "displayName": "منع السكون", + "summary": "يمنع سكون النظام؛ ويمكن اختياريًا إبقاء الشاشة قيد التشغيل." + }, + "de": { + "displayName": "Wach halten", + "summary": "Verhindert den Leerlauf des Systems; optional bleibt das Display an." + }, + "en": { + "displayName": "Keep Awake", + "summary": "Prevent idle system sleep; optionally keep the display on." + }, + "es": { + "displayName": "Mantener activo", + "summary": "Evita la suspensión del sistema; opcionalmente mantiene la pantalla encendida." + }, + "fr": { + "displayName": "Empêcher la suspension", + "summary": "Empêche la mise en veille du système ; option pour garder l’écran allumé." + }, + "ja": { + "displayName": "スリープを防止", + "summary": "システムのアイドルスリープを防止。必要に応じてディスプレイもオンのままにできます。" + }, + "ko": { + "displayName": "잠자기 방지", + "summary": "시스템 유휴 절전을 방지하며, 선택적으로 디스플레이를 켜 둡니다." + }, + "pt": { + "displayName": "Manter ativo", + "summary": "Impede a suspensão ociosa do sistema; opcionalmente mantém a tela ligada." + }, + "ru": { + "displayName": "Не давать уснуть", + "summary": "Предотвращает спящий режим системы; по желанию не даёт экрану гаснуть." + }, + "zh-Hans": { + "displayName": "阻止休眠", + "summary": "阻止系统空闲休眠;可选保持屏幕常亮" + }, + "zh-Hant": { + "displayName": "防止睡眠", + "summary": "阻止系統空閒休眠;可選保持螢幕常亮" + } + }, + "version": "1.0.8", + "minHostVersion": "1.0.2", + "pluginKitVersion": 3, + "bundleRelativePath": "KeepAwake.bundle", + "factoryClass": "KeepAwakePlugin.KeepAwakePluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "KeepAwakePlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": true + }, + "permissions": [], + "category": "system" +} diff --git a/Plugins/KeepAwake/project.yml b/Plugins/KeepAwake/project.yml new file mode 100644 index 0000000..3fd34d1 --- /dev/null +++ b/Plugins/KeepAwake/project.yml @@ -0,0 +1,3 @@ +settings: + base: + OTHER_LDFLAGS: -framework IOKit diff --git a/Plugins/LaunchControl/Bundle/LaunchControlPluginBundleEntrypoint.swift b/Plugins/LaunchControl/Bundle/LaunchControlPluginBundleEntrypoint.swift new file mode 100644 index 0000000..37254d8 --- /dev/null +++ b/Plugins/LaunchControl/Bundle/LaunchControlPluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import LaunchControlPlugin + +private let launchControlPluginFactoryAnchor: Any.Type = LaunchControlPluginFactory.self diff --git a/Plugins/LaunchControl/Resources/Localizable.xcstrings b/Plugins/LaunchControl/Resources/Localizable.xcstrings new file mode 100644 index 0000000..6a378bc --- /dev/null +++ b/Plugins/LaunchControl/Resources/Localizable.xcstrings @@ -0,0 +1,7816 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "controller.favorite.added": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت الإضافة إلى المفضلة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Favorit markiert: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorited %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadido a favoritos: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouté aux favoris : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りに追加: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기에 추가됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionado aos favoritos: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавлено в избранное: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关注 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關注 %@" + } + } + } + }, + "controller.favorite.removed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت الإزالة من المفضلة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus Favoriten entfernt: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unfavorited %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminado de favoritos: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retiré des favoris : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りから削除: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기에서 제거됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Removido dos favoritos: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено из избранного: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已取消关注 %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已取消關注 %@" + } + } + } + }, + "controller.note.cleared": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم مسح الملاحظة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Notiz gelöscht: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleared note: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nota borrada: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Note effacée : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メモをクリアしました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메모 삭제됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nota apagada: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалена заметка: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已清除备注:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已清除備註:%@" + } + } + } + }, + "controller.note.saved": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم حفظ الملاحظة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gespeicherte Notiz: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved note: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nota guardada: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remarque enregistrée : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存されたメモ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메모 저장됨: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nota salva: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Заметка сохранена: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已保存备注:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已保存備註:%@" + } + } + } + }, + "controller.operation.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ اكتمل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ abgeschlossen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ completed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ completado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ terminé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ 完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 완료" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ concluído" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ завершено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@完成" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@完成" + } + } + } + }, + "controller.operation.exitCode": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "launchctl تم إرجاع رمز الخروج %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "launchctl hat den Exit-Code %d zurückgegeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "launchctl returned exit code %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "launchctl devolvió el código de salida %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "launchctl a renvoyé le code de sortie %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "launchctl が終了コード %d を返しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "launchctl이(가) 종료 코드 %d을(를) 반환했습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "launchctl retornou o código de saída %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "launchctl вернул код выхода %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "launchctl 返回退出码 %d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "launchctl 返回退出碼 %d" + } + } + } + }, + "controller.operation.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ فشل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ falló" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ falhou" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ не удалось" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@失敗" + } + } + } + }, + "controller.operation.readOnly": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكون عناصر التشغيل العامة والنظام للقراءة فقط بشكل افتراضي لمنع التغييرات غير المقصودة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System- und globale Startelemente sind standardmäßig schreibgeschützt, um versehentliche Änderungen zu verhindern." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System and global launch items are read-only by default to prevent accidental changes." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los elementos de inicio global y del sistema son de solo lectura de forma predeterminada para evitar cambios accidentales." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les éléments de lancement système et global sont en lecture seule par défaut pour éviter toute modification accidentelle." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムおよびグローバル起動項目は、誤って変更されることを防ぐため、デフォルトでは読み取り専用です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 및 전역 실행 항목은 실수로 인한 변경을 방지하기 위해 기본적으로 읽기 전용입니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Os itens de lançamento global e do sistema são somente leitura por padrão para evitar alterações acidentais." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системные и глобальные элементы запуска по умолчанию доступны только для чтения, чтобы предотвратить случайные изменения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统或全局启动项默认只读,避免误操作。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統或全局啟動項預設只讀,避免誤操作。" + } + } + } + }, + "controller.operation.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ %@..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ %@…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ %@..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ %@..." + } + } + } + }, + "controller.scanLog.completed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتمل الفحص: %d من العناصر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan abgeschlossen: %d Elemente" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan complete: %d items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo completo: %d elementos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse terminée : %d éléments" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンが完了しました: %d 個のアイテム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 완료: %d 항목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação concluída: %d itens" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование завершено: %d элементов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描完成:%d 项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描完成:%d 項" + } + } + } + }, + "controller.scanLog.directory": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الدليل: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verzeichnis: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Directory: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Directorio: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répertoire : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディレクトリ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디렉터리: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Diretório: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каталог: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "目录:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "目錄:%@" + } + } + } + }, + "controller.scanLog.file": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ القراءة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird gelesen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Leyendo: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み込み中: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽는 중: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lendo: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取:%@" + } + } + } + }, + "controller.scanLog.started": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدأ فحص LaunchAgents / LaunchDaemons" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Begonnen mit dem Scannen von LaunchAgents / LaunchDaemons" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Started scanning LaunchAgents / LaunchDaemons" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se inició el escaneo de LaunchAgents / LaunchDaemons" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrage de l'analyse de LaunchAgents / LaunchDaemons" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LaunchAgents / LaunchDaemons のスキャンを開始しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LaunchAgents / LaunchDaemons 스캔 시작됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação iniciada de LaunchAgents / LaunchDaemons" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начато сканирование LaunchAgents / LaunchDaemons" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开始扫描 LaunchAgents / LaunchDaemons" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開始掃描 LaunchAgents / LaunchDaemons" + } + } + } + }, + "controller.scanLog.stopped": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "توقف الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan gestoppt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan stopped" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo detenido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse arrêtée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンが停止しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "검사가 중지되었습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificação interrompida" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование остановлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描已停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描已停止" + } + } + } + }, + "item.commandText.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم الإعلان عن وسيطات البرنامج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments nicht deklariert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments not declared" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments no declarados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments non déclarés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments が宣言されていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments가 선언되지 않았습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments não declarados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ProgramArguments не объявлены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未声明 ProgramArguments" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未聲明 ProgramArguments" + } + } + } + }, + "item.status.exitCode": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ · رمز الخروج %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · Exit-Code %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · exit code %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · código de salida %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · code de sortie %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · 終了コード %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · 종료 코드 %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ · código de saída %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · код выхода %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · 退出码 %d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · 退出碼 %d" + } + } + } + }, + "item.status.pid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · PID %d" + } + } + } + }, + "item.trigger.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم الإعلان عن أي مشغل تلقائي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein automatischer Trigger deklariert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No automatic trigger declared" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se ha declarado ningún activador automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun déclenchement automatique déclaré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動トリガーが宣言されていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 트리거가 선언되지 않았습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum gatilho automático declarado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматический триггер не объявлен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未声明自动触发条件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未聲明自動觸發條件" + } + } + } + }, + "item.trigger.keepAlive": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "KeepAlive: %@" + } + } + } + }, + "item.trigger.runAtLoad": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل عند تسجيل الدخول/التحميل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Anmelden/Laden ausführen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Run at login/load" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ejecutar al iniciar sesión/carga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécuter à la connexion/au chargement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ログイン時/ロード時に実行" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그인/로드 시 실행" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Executar no login/carregamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запускать при входе/загрузке" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录/加载时运行" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登錄/加載時運行" + } + } + } + }, + "item.trigger.startCalendar": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مجدول: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geplant: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scheduled: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Programado: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Planifié : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "予定: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예정: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Programado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запланировано: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "定时: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "定時: %@" + } + } + } + }, + "item.trigger.startInterval": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "كل %d ثانية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle %d Sekunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Every %d seconds" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cada %d segundos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toutes les %d secondes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 秒ごと" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d초마다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A cada %d segundos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Каждые %d секунд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每 %d 秒" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每 %d 秒" + } + } + } + }, + "managedAction.bootout.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تفريغ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unload" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Décharger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언로드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Descarregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выгрузить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "卸载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "卸載" + } + } + } + }, + "managedAction.bootstrap.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحميل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Load" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charger" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロード" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加載" + } + } + } + }, + "managedAction.disable.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعطيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deaktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비활성화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desativar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "禁用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "禁用" + } + } + } + }, + "managedAction.enable.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمكين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Habilitar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ativar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用" + } + } + } + }, + "managedAction.restart.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neustart" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 시작" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重啟" + } + } + } + }, + "managedAction.start.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابدأ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Starten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開始" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Iniciar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запустить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動" + } + } + } + }, + "managedAction.stop.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Stoppen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detener" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Parar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + } + }, + "manager.action.favorite": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة للمفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favorisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorite" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir a favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter aux favoris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りに追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기 추가" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar aos favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + }, + "manager.action.favoriteFilter": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Favoris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + }, + "manager.action.refresh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リフレッシュ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + } + } + }, + "manager.action.refreshing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحديث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird aktualisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refreshing" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновление" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新中" + } + } + } + }, + "manager.action.revealInFinder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض في Finder" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Anzeigen in Finder" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show in Finder" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mostrar en Finder" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher dans Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finder で表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Finder에 표시" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mostrar em Finder" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать в Finder" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 Finder 中显示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 Finder 中顯示" + } + } + } + }, + "manager.action.unfavorite": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة من المفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favorit entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unfavorite" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quitar de favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer des favoris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りから削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기 제거" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover dos favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить из избранного" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消关注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消關注" + } + } + } + }, + "manager.badge.readOnly": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "للقراءة فقط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreibgeschützt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Read Only" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo lectura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture seule" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取り専用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 전용" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Somente leitura" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только чтение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只讀" + } + } + } + }, + "manager.confirm.buttonTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ %@" + } + } + } + }, + "manager.confirm.cancel": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отменить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + } + } + }, + "manager.confirm.defaultTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد الإجراء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktion bestätigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm Action" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar acción" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer l'action" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクションを確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조치 확인" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar ação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтвердить действие" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認操作" + } + } + } + }, + "manager.confirm.message": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nسيتم استدعاء launchctl %@. يمكن عادةً استعادة LaunchAgents الخاصة بالمستخدم، لكن التعطيل أو التفريغ أو الإيقاف قد يؤثر في المهام الخلفية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nDadurch wird launchctl %@ aufgerufen. Benutzer-LaunchAgents lassen sich normalerweise wiederherstellen, aber Deaktivieren, Entladen oder Stoppen kann Hintergrundaufgaben beeinträchtigen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nThis will call launchctl %@. User LaunchAgents are usually recoverable, but disabling, unloading, or stopping may affect background tasks." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nEsto llamará a launchctl %@. Los LaunchAgents de usuario suelen poder recuperarse, pero desactivarlos, descargarlos o detenerlos puede afectar a tareas en segundo plano." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nCette action appellera launchctl %@. Les LaunchAgents utilisateur sont généralement récupérables, mais les désactiver, les décharger ou les arrêter peut affecter les tâches en arrière-plan." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nlaunchctl %@ を呼び出します。ユーザー LaunchAgent は通常復旧できますが、無効化、アンロード、停止はバックグラウンドタスクに影響する場合があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nlaunchctl %@를 호출합니다. 사용자 LaunchAgent는 일반적으로 복구할 수 있지만 비활성화, 언로드 또는 중지는 백그라운드 작업에 영향을 줄 수 있습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nIsso chamará launchctl %@. LaunchAgents de usuário geralmente podem ser recuperados, mas desativar, descarregar ou parar pode afetar tarefas em segundo plano." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\nБудет вызван launchctl %@. Пользовательские LaunchAgents обычно можно восстановить, но отключение, выгрузка или остановка могут повлиять на фоновые задачи." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\n此操作会调用 launchctl %@。用户级 LaunchAgent 通常可以恢复,但禁用、卸载或停止可能影响后台任务。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@\n%@\n\n此操作會調用 launchctl %@。用戶級 LaunchAgent 通常可以恢復,但禁用、卸載或停止可能影響後台任務。" + } + } + } + }, + "manager.confirm.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد %@ عنصر تشغيل؟" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startelement %@ bestätigen?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm %@ launch item?" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿Confirmar %@ del elemento de inicio?" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer %@ de l’élément de lancement ?" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動項目の %@ を確認しますか?" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 항목 %@을(를) 확인할까요?" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar %@ do item de inicialização?" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтвердить действие «%@» для элемента запуска?" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认%@启动项?" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認%@啟動項?" + } + } + } + }, + "manager.favorite.addHelp": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة عنصر التشغيل إلى المفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Launch-Item als Favorit markieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorite launch item" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir elemento de inicio a favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter l’élément de lancement aux favoris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動項目をお気に入りに追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 항목을 즐겨찾기에 추가" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar item de inicialização aos favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить элемент запуска в избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关注启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注啟動項" + } + } + } + }, + "manager.favorite.removeHelp": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة المفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favorit entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove favorite" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar favorito" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer le favori" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入りを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기 삭제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover favorito" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消关注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消關注" + } + } + } + }, + "manager.field.keepAlive.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ما إذا كان سيتم إعادة تشغيل العملية تلقائيًا بعد الخروج منها." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ob der Prozess nach dem Beenden automatisch neu gestartet wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Whether the process is relaunched automatically after it exits." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Si el proceso se reinicia automáticamente después de salir." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Si le processus est relancé automatiquement après sa sortie." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロセスが終了後に自動的に再起動されるかどうか。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로세스가 종료된 후 자동으로 다시 시작되는지 여부" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Se o processo é reiniciado automaticamente após ser encerrado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запускается ли процесс автоматически после завершения." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进程退出后是否按条件自动拉起。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "進程退出後是否按條件自動拉起。" + } + } + } + }, + "manager.field.notSet": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير محدد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht festgelegt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not set" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No establecido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non défini" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정되지 않음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não definido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не установлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + } + } + }, + "manager.field.programArguments.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم تنفيذ الأوامر والوسائط عند التشغيل." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Befehl und Argumente, die beim Start ausgeführt werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Command and arguments executed at launch." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comando y argumentos ejecutados en el lanzamiento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commande et arguments exécutés au lancement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動時に実行されるコマンドと引数。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 시 실행되는 명령 및 인수." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Comandos e argumentos executados na inicialização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда и аргументы выполняются при запуске." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动时执行的命令与参数。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動時執行的命令與參數。" + } + } + } + }, + "manager.field.runAtLoad.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ما إذا كان سيتم التشغيل مرة واحدة مباشرة بعد تحميل LaunchAgent." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gibt an, ob die Ausführung unmittelbar nach dem Laden von LaunchAgent einmal ausgeführt werden soll." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Whether to run once immediately after the LaunchAgent is loaded." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Si se debe ejecutar una vez inmediatamente después de cargar LaunchAgent." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "S'il faut exécuter une fois immédiatement après le chargement de LaunchAgent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LaunchAgent がロードされた直後に 1 回実行するかどうか。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LaunchAgent이 로드된 후 즉시 한 번 실행할지 여부." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Se deve ser executado uma vez imediatamente após o LaunchAgent ser carregado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запускать ли один раз сразу после загрузки LaunchAgent." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加载 LaunchAgent 后是否立即运行一次。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加載 LaunchAgent 後是否立即運行一次。" + } + } + } + }, + "manager.field.startCalendar.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم التشغيل في أوقات تقويم محددة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird zu bestimmten Kalenderzeiten ausgelöst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Triggers at specific calendar times." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se activa en momentos específicos del calendario." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déclenchements à des heures spécifiques du calendrier." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "特定のカレンダー時刻にトリガーされます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "특정 달력 시간에 트리거됩니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aciona em horários específicos do calendário." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Срабатывает в определенное календарное время." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按日历时间触发。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按日曆時間觸發。" + } + } + } + }, + "manager.field.startInterval.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم تشغيله بفاصل زمني محدد بالثواني." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird in einem festen Intervall in Sekunden ausgelöst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Triggers at a fixed interval in seconds." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se activa en un intervalo fijo en segundos." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Se déclenche à un intervalle fixe en secondes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "秒単位の固定間隔でトリガーします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초 단위로 고정된 간격으로 트리거됩니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aciona em um intervalo fixo em segundos." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Срабатывает через фиксированный интервал в секундах." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按固定秒数间隔触发。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按固定秒數間隔觸發。" + } + } + } + }, + "manager.field.triggerSummary.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ملخص قابل للقراءة تم إنشاؤه من الحقول المشتركة." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesbare Zusammenfassung, generiert aus gemeinsamen Feldern." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Readable summary generated from common fields." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resumen legible generado a partir de campos comunes." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résumé lisible généré à partir des champs communs." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "共通フィールドから生成された読みやすい概要。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "공통 필드에서 생성된 읽기 가능한 요약" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resumo legível gerado a partir de campos comuns." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Читабельная сводка, созданная на основе общих полей." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "根据常见字段生成的可读说明。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "根據常見字段生成的可讀說明。" + } + } + } + }, + "manager.field.triggerSummary.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ملخص المشغل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Trigger-Zusammenfassung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trigger Summary" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resumen de activador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résumé du déclencheur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トリガーの概要" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "트리거 요약" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resumo do gatilho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сводка по триггеру" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "触发摘要" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "觸發摘要" + } + } + } + }, + "manager.filter.origin": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأصل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Quelle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Origin" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Origen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Origine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成元" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "출처" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Origem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Происхождение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "来源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "來源" + } + } + } + }, + "manager.filter.scope": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النطاق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geltungsbereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scope" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alcance" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Portée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "범위" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escopo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Область применения" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "範圍" + } + } + } + }, + "manager.filter.state": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحالة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "State" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "État" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "状態" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Estado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態" + } + } + } + }, + "manager.itemRow.scopeStatus": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "manager.itemRow.userBadge": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المستخدم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "User" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usuario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Usuário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользователь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用戶" + } + } + } + }, + "manager.keyFields.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحقول الرئيسية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schlüsselfelder" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Key Fields" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Campos clave" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Champs clés" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーフィールド" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "주요 필드" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Campos-chave" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ключевые поля" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关键字段" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關鍵字段" + } + } + } + }, + "manager.list.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد عناصر متطابقة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine passenden Einträge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Matching Items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay elementos coincidentes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun élément correspondant" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するアイテムがありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일치하는 항목 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum item correspondente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет подходящих элементов" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有匹配项目" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有匹配項目" + } + } + } + }, + "manager.list.scanning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scannen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描" + } + } + } + }, + "manager.list.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عناصر التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startelemente" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch Items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elementos de lanzamiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éléments de lancement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 항목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Itens de lançamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Элементы запуска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動項" + } + } + } + }, + "manager.metric.appCreated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "من التطبيق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "App erstellt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App Created" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "De app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créés par app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリ作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 생성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Criados por app" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Созданы приложением" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用创建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用創建" + } + } + } + }, + "manager.metric.failed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المشاكل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Probleme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Issues" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Problemas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Problèmes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "問題" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "문제" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Problemas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проблемы" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "异常" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "異常" + } + } + } + }, + "manager.metric.favorite": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Favoris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "关注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "關注" + } + } + } + }, + "manager.metric.running": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d'exécution" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Em execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работают" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行中" + } + } + } + }, + "manager.metric.total": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإجمالي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gesamt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合計" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "합계" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Total" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Итого" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "总数" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "總數" + } + } + } + }, + "manager.metric.userCreated": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "من المستخدم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vom Benutzer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "User Created" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "De usuario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créés par utilisateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザー作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 생성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Criados pelo usuário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Созданы пользователем" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户创建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用戶創建" + } + } + } + }, + "manager.note.placeholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أضف ملاحظة محلية لعنصر التشغيل هذا. تبقى على هذا الـ Mac ولا تُكتب في plist." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fügen Sie eine lokale Notiz für dieses Startelement hinzu. Es bleibt auf diesem Mac und wird nicht in die Plist geschrieben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add a local note for this launch item. It stays on this Mac and is not written to the plist." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añade una nota local para este elemento de inicio. Se queda en este Mac y no se escribe en el plist." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajoutez une note locale à cet élément de lancement. Elle reste sur ce Mac et n’est pas écrite dans le plist." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この起動項目にローカルメモを追加します。この Mac にのみ保存され、plist には書き込まれません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 시작 항목에 로컬 메모를 추가합니다. 이 Mac에만 저장되며 plist에는 기록되지 않습니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicione uma nota local para este item de inicialização. Ela fica neste Mac e não é gravada no plist." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавьте локальную заметку для этого элемента запуска. Она останется на этом Mac и не будет записана в plist." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "为这个启动项添加本地备注(仅保存在本机,不写入 plist)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "為這個啟動項添加本地備註(僅保存在本機,不寫入 plist)。" + } + } + } + }, + "manager.note.save": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حفظ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speichern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Guardar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Salvar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сохранить" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "保存" + } + } + } + }, + "manager.note.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ملاحظة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Notiz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Note" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nota" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Remarque" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メモ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메모" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nota" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Примечание" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "备注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "備註" + } + } + } + }, + "manager.placeholder.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض حقول plist وحالة التشغيل والإجراءات المتاحة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Plist-Felder, Laufzeitstatus und verfügbare Aktionen anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View plist fields, runtime state, and available actions" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver campos de plist, estado de ejecución y acciones disponibles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher les champs plist, l'état d'exécution et les actions disponibles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "plist フィールド、実行時状態、および利用可能なアクションを表示する" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "plist 필드, 런타임 상태 및 사용 가능한 작업 보기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver campos plist, estado de execução e ações disponíveis" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотр полей plist, состояния выполнения и доступных действий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看 plist 字段、运行状态和可用操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看 plist 字段、運行狀態和可用操作" + } + } + } + }, + "manager.placeholder.loadingDescription": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتم تحديث القائمة مع تقدم عملية المسح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Liste wird mit fortschreitendem Scanvorgang aktualisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The list updates as scanning progresses" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La lista se actualiza a medida que avanza el escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La liste est mise à jour au fur et à mesure de la progression de l'analyse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンの進行に応じてリストが更新されます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔이 진행됨에 따라 목록이 업데이트됩니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A lista é atualizada à medida que a verificação avança" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Список обновляется по мере сканирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "左侧列表会随扫描进度逐步更新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "左側列表會隨掃描進度逐步更新" + } + } + } + }, + "manager.placeholder.loadingTitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ قراءة عناصر التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startelemente werden gelesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading launch items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Leyendo elementos de inicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture des éléments de lancement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動項目を読み込み中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 항목 읽는 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lendo itens de lançamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение элементов запуска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在读取启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在讀取啟動項" + } + } + } + }, + "manager.placeholder.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حدد عنصر التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie ein Startelement aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select a Launch Item" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccione un elemento de lanzamiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionnez un élément de lancement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動アイテムを選択してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 항목 선택" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Selecione um item de lançamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите элемент запуска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择一个启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇一個啟動項" + } + } + } + }, + "manager.rawPlist.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "plist خام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rohe plist" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Raw plist" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "plist sin formato" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "plist brut" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "生の plist" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원시 plist" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Plist bruto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исходный plist" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原始 plist" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原始 plist" + } + } + } + }, + "manager.rawPlist.unreadable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكن عرض المحتوى الأولي بتنسيق UTF-8" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rohinhalte können nicht als UTF-8 angezeigt werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cannot display raw content as UTF-8" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se puede mostrar contenido sin formato como UTF-8" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'afficher le contenu brut au format UTF-8" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "生のコンテンツを UTF-8 として表示できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원시 콘텐츠를 UTF-8로 표시할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não é possível exibir conteúdo bruto como UTF-8" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Невозможно отобразить необработанный контент в формате UTF-8" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法以 UTF-8 显示原始内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法以 UTF-8 顯示原始內容" + } + } + } + }, + "manager.scanActivity.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تقدم الفحص" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan-Fortschritt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan Progress" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Progreso del escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Progression de l'analyse" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンの進行状況" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 진행" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Progresso da verificação" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ход сканирования" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描进度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描進度" + } + } + } + }, + "manager.search.placeholder": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "ابحث عن label أو أمر أو مسار" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Label, Befehl oder Pfad suchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Search label, command, or path" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar label, comando o ruta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher label, commande ou chemin" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "label、コマンド、パスを検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "label, 명령 또는 경로 검색" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar label, comando ou caminho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Найти метку, команду или путь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索 label、命令或路径" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜索 label、命令或路徑" + } + } + } + }, + "manager.seconds": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d ثانية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Sekunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d seconds" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d segundos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d secondes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 秒" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d초" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d segundos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d секунд" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 秒" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 秒" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض عناصر تشغيل launchctl وإدارتها" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "launchctl-Startelemente anzeigen und verwalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View and manage launchctl launch items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver y administrar elementos de inicio de launchctl" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher et gérer les éléments de lancement launchctl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "launchctl 起動項目を表示および管理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "launchctl 시작 항목 보기 및 관리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver e gerenciar itens de inicialização do launchctl" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просмотр элементов запуска launchctl и управление ими" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看和管理 launchctl 启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "查看和管理 launchctl 啟動項" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عناصر التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Startelemente" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Launch Items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elementos de inicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éléments de lancement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작 항목" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Itens de inicialização" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Элементы запуска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動項" + } + } + } + }, + "origin.system.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام المدمج" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemintern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Built-in System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système intégré" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム内蔵" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 기본" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Do sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Встроенные системные" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统内置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統內置" + } + } + } + }, + "origin.thirdParty.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "من التطبيق" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Von App erstellt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "App Created" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Creado por app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créé par app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アプリ作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "앱 생성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Criado por app" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Созданы приложением" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用创建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "應用創建" + } + } + } + }, + "origin.userCreated.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "من المستخدم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vom Benutzer erstellt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "User Created" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Creado por usuario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créé par utilisateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザー作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자 생성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Criado pelo usuário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Созданы пользователем" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户创建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用戶創建" + } + } + } + }, + "originFilter.all.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "كل المصادر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Ursprünge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All Origins" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todos los orígenes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toutes les origines" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての作成元" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 출처" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Todas as origens" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все источники" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部来源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部來源" + } + } + } + }, + "originFilter.favorite.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المفضلة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Favoriten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorites" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Favoris" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お気に入り" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즐겨찾기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已关注" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已關注" + } + } + } + }, + "originFilter.manageable.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قابل للإدارة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwaltbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Manageable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Manejable" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Gérable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理可能" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관리 가능" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Gerenciável" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Управляемый" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可操作" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可操作" + } + } + } + }, + "originFilter.readOnly.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "للقراءة فقط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schreibgeschützt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Read Only" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo lectura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture seule" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取り専用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 전용" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Somente leitura" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только чтение" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只讀" + } + } + } + }, + "panel.action.openManager": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح المدير" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manager öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Manager" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir gestor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le gestionnaire" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マネージャーを開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관리자 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir gerenciador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть менеджер" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开管理器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開管理器" + } + } + } + }, + "panel.action.refresh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث القائمة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liste aktualisieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh List" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser la liste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リストを更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록 새로고침" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar lista" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить список" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新列表" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新列表" + } + } + } + }, + "panel.action.refreshing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ التحديث" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird aktualisiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refreshing" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizando" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualisation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizando" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновление" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在刷新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在刷新" + } + } + } + }, + "panel.subtitle.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "افتح \"المدير\" أو قم بالتحديث لعرض عناصر التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnen Sie den Manager oder aktualisieren Sie ihn, um Startelemente anzuzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Manager or refresh to view launch items" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abre el gestor o actualiza para ver elementos de inicio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrez le gestionnaire ou actualisez pour afficher les éléments de lancement" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マネージャーを開くか更新して起動アイテムを表示します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 항목을 보려면 관리자를 열거나 새로고침하세요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abra o Gerenciador ou atualize para visualizar os itens de lançamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Откройте менеджер или обновите список, чтобы просмотреть элементы запуска" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开管理器或刷新后查看启动项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "打開管理器或刷新後查看啟動項" + } + } + } + }, + "panel.subtitle.refreshing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ فحص LaunchAgents وLaunchDaemons" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scannen von LaunchAgents und LaunchDaemons" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning LaunchAgents and LaunchDaemons" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneando LaunchAgents y LaunchDaemons" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse des LaunchAgents et LaunchDaemons" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "LaunchAgent と LaunchDaemon をスキャン中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "LaunchAgents 및 LaunchDaemons 검색" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Verificando LaunchAgents e LaunchDaemons" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование LaunchAgents и LaunchDaemons" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描 LaunchAgents 与 LaunchDaemons" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描 LaunchAgents 與 LaunchDaemons" + } + } + } + }, + "panel.subtitle.summary": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d عنصر · %d قيد التشغيل · %d أنشأه المستخدم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente · %d aktiv · %d vom Benutzer erstellt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d items · %d running · %d user created" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d elementos · %d en ejecución · %d de usuario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d éléments · %d en cours · %d créés par utilisateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 項目 · %d 実行中 · %d ユーザー作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d개 항목 · %d개 실행 중 · %d개 사용자 생성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d itens · %d em execução · %d criados pelo usuário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d элементов · %d работают · %d созданы пользователем" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 项 · %d 运行中 · %d 用户创建" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 項 · %d 運行中 · %d 用戶創建" + } + } + } + }, + "scanner.keepAlive.always": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إبقاء التشغيل دائمًا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer aktiv halten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Always keep alive" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantener siempre vivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toujours maintenir actif" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "常に維持" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항상 유지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manter sempre ativo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всегда поддерживать активным" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "持续保活" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "持續保活" + } + } + } + }, + "scanner.keepAlive.custom": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حالة مخصصة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzerdefinierte Bedingung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom condition" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Condición personalizada" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Condition personnalisée" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カスタム条件" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "맞춤 조건" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Condição personalizada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользовательское условие" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自定义条件" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自定義條件" + } + } + } + }, + "scanner.progress.disabledState": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قراءة حالة التعطيل من launchctl" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deaktivierungsstatus aus launchctl lesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading launchctl disabled state" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Leyendo estado desactivado de launchctl" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture de l’état désactivé de launchctl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "launchctl の無効状態を読み込み中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "launchctl 비활성화 상태 읽기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lendo estado desativado do launchctl" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение состояния отключения launchctl" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取 launchctl 禁用状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取 launchctl 禁用狀態" + } + } + } + }, + "scanner.progress.parseFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التحليل: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Analyse fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Parse failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Falló el análisis: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l'analyse : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解析に失敗しました: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "파싱 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na análise: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка анализа: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "解析失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "解析失敗:%@" + } + } + } + }, + "scanner.progress.runningState": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قراءة حالة التشغيل من launchctl" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laufstatus von launchctl wird gelesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading launchctl running state" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Leyendo estado de ejecución de launchctl" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture de l'état d'exécution de launchctl" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "launchctl の実行状態を読み取り中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "launchctl 실행 상태 읽기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lendo estado de execução do launchctl" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение состояния работы launchctl" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取 launchctl 运行状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取 launchctl 運行狀態" + } + } + } + }, + "scanner.progress.skipUnreadableDirectory": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تخطي الدليل غير القابل للقراءة: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unlesbares Verzeichnis überspringen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipping unreadable directory: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Omitir directorio ilegible: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignorer le répertoire illisible : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取り不可能なディレクトリをスキップします: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽을 수 없는 디렉터리 건너뛰기: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ignorando diretório ilegível: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропуск нечитаемого каталога: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "跳过不可读取目录:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "跳過不可讀取目錄:%@" + } + } + } + }, + "scope.global.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عالمي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mondial" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グローバル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "글로벌" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Global" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Глобальный" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全局" + } + } + } + }, + "scope.system.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Система" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統" + } + } + } + }, + "scope.user.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المستخدم الحالي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktueller Benutzer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Current User" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usuario actual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisateur actuel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のユーザー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 사용자" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Usuário atual" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Текущий пользователь" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "当前用户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "當前用戶" + } + } + } + }, + "scopeFilter.all.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جميع النطاقات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Bereiche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All Scopes" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todos los ámbitos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Toutes les étendues" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのスコープ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 범위" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Todos os escopos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все области" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部范围" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部範圍" + } + } + } + }, + "state.disabled.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معطل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Deaktiviert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disabled" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactivé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "비활성화" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已禁用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已禁用" + } + } + } + }, + "state.failed.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "异常退出" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "異常退出" + } + } + } + }, + "state.loaded.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التحميل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loaded" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロード済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로드됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Carregado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загружен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已加載" + } + } + } + }, + "state.running.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Läuft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d'exécution" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 중" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Em execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Работает" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "運行中" + } + } + } + }, + "state.unknown.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير معروف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Inconnu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desconhecido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知" + } + } + } + }, + "state.unloaded.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التفريغ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unloaded" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déchargé" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アンロード済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "언로드됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Descarregado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выгружен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未加载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未加載" + } + } + } + }, + "stateFilter.all.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "كل الحالات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All States" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Todos los estados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tous les états" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべての状態" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "모든 상태" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Todos os estados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Все состояния" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全部狀態" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlCommand.swift b/Plugins/LaunchControl/Sources/LaunchControlCommand.swift new file mode 100644 index 0000000..a0654ff --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlCommand.swift @@ -0,0 +1,66 @@ +import Foundation + +struct LaunchControlCommandResult: Sendable { + let exitCode: Int32 + let standardOutput: String + let standardError: String + + var combinedOutput: String { + [standardOutput, standardError] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + } +} + +protocol LaunchControlCommandRunning: Sendable { + func runLaunchctl(arguments: [String]) throws -> LaunchControlCommandResult +} + +struct ProcessLaunchControlCommandRunner: LaunchControlCommandRunning { + private let timeout: TimeInterval + + init(timeout: TimeInterval = 2) { + self.timeout = timeout + } + + func runLaunchctl(arguments: [String]) throws -> LaunchControlCommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/launchctl") + process.arguments = arguments + + let outputPipe = Pipe() + let errorPipe = Pipe() + let completion = DispatchSemaphore(value: 0) + process.standardOutput = outputPipe + process.standardError = errorPipe + process.terminationHandler = { _ in + completion.signal() + } + + try process.run() + let deadline = DispatchTime.now() + timeout + if completion.wait(timeout: deadline) == .timedOut { + process.terminate() + _ = completion.wait(timeout: .now() + 0.5) + if process.isRunning { + process.interrupt() + } + } + + let output = String( + data: outputPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + let error = String( + data: errorPipe.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + + return LaunchControlCommandResult( + exitCode: process.terminationStatus, + standardOutput: output, + standardError: error + ) + } +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlController.swift b/Plugins/LaunchControl/Sources/LaunchControlController.swift new file mode 100644 index 0000000..949a59d --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlController.swift @@ -0,0 +1,331 @@ +import AppKit +import Foundation +import MacToolsPluginKit + +@MainActor +final class LaunchControlController: ObservableObject { + @Published private(set) var snapshot = LaunchControlSnapshot() + + var onStateChange: (() -> Void)? + + private let scanner: LaunchControlScanner + private let runner: any LaunchControlCommandRunning + private let favoritesStore: LaunchControlFavoritesStore + private let notesStore: LaunchControlNotesStore + private let localization: PluginLocalization + private var refreshTask: Task? + + init( + scanner: LaunchControlScanner? = nil, + runner: any LaunchControlCommandRunning = ProcessLaunchControlCommandRunner(), + favoritesStore: LaunchControlFavoritesStore? = nil, + notesStore: LaunchControlNotesStore? = nil, + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.runner = runner + self.favoritesStore = favoritesStore ?? LaunchControlFavoritesStore(context: context) + self.notesStore = notesStore ?? LaunchControlNotesStore(context: context) + self.localization = localization + self.scanner = scanner ?? LaunchControlScanner(runner: runner, localization: localization) + } + + deinit { + refreshTask?.cancel() + } + + func cancelRefresh() { + refreshTask?.cancel() + refreshTask = nil + + guard snapshot.isRefreshing else { + return + } + + snapshot.isRefreshing = false + snapshot.currentScanTarget = nil + appendScanLog(localization.string("controller.scanLog.stopped", defaultValue: "扫描已停止")) + onStateChange?() + } + + func refresh() { + guard !snapshot.isRefreshing else { return } + + snapshot.isRefreshing = true + snapshot.items = [] + snapshot.selectedItemID = nil + snapshot.errorMessage = nil + snapshot.operationMessage = nil + snapshot.scanLogEntries = [ + localization.string("controller.scanLog.started", defaultValue: "开始扫描 LaunchAgent / LaunchDaemon") + ] + snapshot.currentScanTarget = nil + onStateChange?() + + let progressStream = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(120) + ) + let progressTask = Task { @MainActor [weak self] in + for await event in progressStream.stream { + self?.handleScanEvent(event) + } + } + + refreshTask = Task { [weak self, scanner, progressStream] in + let result = await Task.detached(priority: .userInitiated) { + scanner.scan { event in + progressStream.continuation.yield(event) + } + }.value + progressStream.continuation.finish() + await progressTask.value + + guard let self, !Task.isCancelled else { return } + self.apply(result: result) + } + } + + func selectItem(id: String?) { + snapshot.selectedItemID = id + onStateChange?() + } + + func openInFinder(_ item: LaunchControlItem) { + NSWorkspace.shared.activateFileViewerSelecting([item.plistURL]) + } + + func setFavorite(_ isFavorite: Bool, for item: LaunchControlItem) { + favoritesStore.setFavorite(isFavorite, for: item.id) + refreshPersistedState(for: item) + snapshot.operationMessage = isFavorite + ? localization.format("controller.favorite.added", defaultValue: "已关注 %@", item.label) + : localization.format("controller.favorite.removed", defaultValue: "已取消关注 %@", item.label) + onStateChange?() + } + + func setNote(_ note: String, for item: LaunchControlItem) { + notesStore.setNote(note, for: item.id) + refreshPersistedState(for: item) + let isCleared = note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + snapshot.operationMessage = isCleared + ? localization.format("controller.note.cleared", defaultValue: "已清除备注:%@", item.label) + : localization.format("controller.note.saved", defaultValue: "已保存备注:%@", item.label) + onStateChange?() + } + + func bootstrap(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.bootstrap), + arguments: ["bootstrap", item.launchctlDomain, item.plistURL.path] + ) + } + + func bootout(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.bootout), + arguments: ["bootout", item.launchctlDomain, item.plistURL.path] + ) + } + + func enable(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.enable), + arguments: ["enable", "\(item.launchctlDomain)/\(item.label)"] + ) + } + + func disable(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.disable), + arguments: ["disable", "\(item.launchctlDomain)/\(item.label)"] + ) + } + + func start(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.start), + arguments: ["kickstart", "\(item.launchctlDomain)/\(item.label)"] + ) + } + + func stop(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.stop), + arguments: ["kill", "TERM", "\(item.launchctlDomain)/\(item.label)"] + ) + } + + func restart(_ item: LaunchControlItem) { + performManagedAction( + item: item, + title: actionTitle(.restart), + arguments: ["kickstart", "-k", "\(item.launchctlDomain)/\(item.label)"] + ) + } + + private func apply(result: LaunchControlScanResult) { + let selectedID = snapshot.selectedItemID + snapshot.items = sortedItems(result.items.map(applyingPersistedState)) + snapshot.selectedItemID = result.items.contains(where: { $0.id == selectedID }) + ? selectedID + : result.items.first?.id + snapshot.isRefreshing = false + snapshot.lastRefreshDate = Date() + snapshot.errorMessage = result.warnings.first + snapshot.currentScanTarget = nil + appendScanLog(localization.format("controller.scanLog.completed", defaultValue: "扫描完成:%d 项", result.items.count)) + onStateChange?() + } + + private func handleScanEvent(_ event: LaunchControlScanEvent) { + switch event { + case let .directory(path): + snapshot.currentScanTarget = path + appendScanLog(localization.format("controller.scanLog.directory", defaultValue: "目录:%@", path)) + case let .file(path): + snapshot.currentScanTarget = path + appendScanLog(localization.format( + "controller.scanLog.file", + defaultValue: "读取:%@", + URL(fileURLWithPath: path).lastPathComponent + )) + case let .found(item): + upsertScannedItem(item) + case let .message(message): + appendScanLog(message) + } + + onStateChange?() + } + + private func upsertScannedItem(_ item: LaunchControlItem) { + let item = applyingPersistedState(item) + + if let index = snapshot.items.firstIndex(where: { $0.id == item.id }) { + snapshot.items[index] = item + } else { + snapshot.items.append(item) + } + snapshot.items = sortedItems(snapshot.items) + if snapshot.selectedItemID == nil { + snapshot.selectedItemID = snapshot.items.first?.id + } + } + + private func refreshPersistedState(for item: LaunchControlItem) { + guard let index = snapshot.items.firstIndex(where: { $0.id == item.id }) else { + return + } + + snapshot.items[index] = applyingPersistedState(snapshot.items[index]) + snapshot.items = sortedItems(snapshot.items) + } + + private func applyingPersistedState(_ item: LaunchControlItem) -> LaunchControlItem { + var updated = item + updated.isFavorite = favoritesStore.isFavorite(item.id) + updated.note = notesStore.note(for: item.id) + return updated + } + + private func sortedItems(_ items: [LaunchControlItem]) -> [LaunchControlItem] { + items.sorted { lhs, rhs in + if lhs.isFavorite != rhs.isFavorite { + return lhs.isFavorite + } + if lhs.origin != rhs.origin { + return lhs.origin.rawValue < rhs.origin.rawValue + } + if lhs.scope != rhs.scope { + return lhs.scope.rawValue < rhs.scope.rawValue + } + return lhs.label.localizedCaseInsensitiveCompare(rhs.label) == .orderedAscending + } + } + + private func appendScanLog(_ message: String) { + snapshot.scanLogEntries.append(message) + if snapshot.scanLogEntries.count > 80 { + snapshot.scanLogEntries.removeFirst(snapshot.scanLogEntries.count - 80) + } + } + + private func performManagedAction( + item: LaunchControlItem, + title: String, + arguments: [String] + ) { + guard item.canManage else { + snapshot.operationMessage = localization.string( + "controller.operation.readOnly", + defaultValue: "系统或全局启动项默认只读,避免误操作。" + ) + onStateChange?() + return + } + + snapshot.operationMessage = localization.format( + "controller.operation.running", + defaultValue: "%@ %@...", + title, + item.label + ) + snapshot.errorMessage = nil + onStateChange?() + + Task { [weak self, runner] in + let result: Result = await Task.detached(priority: .userInitiated) { + do { + return .success(try runner.runLaunchctl(arguments: arguments)) + } catch { + return .failure(error) + } + }.value + + guard let self else { return } + + switch result { + case let .success(commandResult) where commandResult.exitCode == 0: + self.snapshot.operationMessage = self.localization.format( + "controller.operation.completed", + defaultValue: "%@完成", + title + ) + self.refresh() + case let .success(commandResult): + let message = commandResult.combinedOutput + self.snapshot.operationMessage = self.localization.format( + "controller.operation.failed", + defaultValue: "%@失败", + title + ) + self.snapshot.errorMessage = message.isEmpty + ? self.localization.format( + "controller.operation.exitCode", + defaultValue: "launchctl 返回退出码 %d", + commandResult.exitCode + ) + : message + self.onStateChange?() + case let .failure(error): + self.snapshot.operationMessage = self.localization.format( + "controller.operation.failed", + defaultValue: "%@失败", + title + ) + self.snapshot.errorMessage = error.localizedDescription + self.onStateChange?() + } + } + } + + private func actionTitle(_ action: LaunchControlManagedAction) -> String { + action.title(localization: localization) + } +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift b/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift new file mode 100644 index 0000000..3ac4e3d --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlManagerView.swift @@ -0,0 +1,903 @@ +import SwiftUI +import MacToolsPluginKit + +struct LaunchControlManagerView: View { + @ObservedObject var controller: LaunchControlController + private let localization: PluginLocalization + + @State private var scopeFilter: LaunchControlFilter = .user + @State private var originFilter: LaunchControlOriginFilter = .all + @State private var stateFilter: LaunchControlStateFilter = .all + @State private var searchText = "" + @State private var pendingAction: LaunchControlPendingAction? + + init( + controller: LaunchControlController, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.controller = controller + self.localization = localization + } + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) { + summaryHeader + toolbar + + if let message = controller.snapshot.errorMessage { + statusBanner(message: message, systemImage: "exclamationmark.triangle.fill", color: .orange) + } else if let message = controller.snapshot.operationMessage { + statusBanner(message: message, systemImage: "checkmark.circle.fill", color: .green) + } + + HStack(alignment: .top, spacing: 0) { + itemList + .frame(minWidth: 280, idealWidth: 330, maxWidth: 360) + + Divider() + + detailPane + .frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) + } + .frame(minHeight: 500) + .pluginSettingsCardBackground(.plugin) + + scanActivity + } + .onAppear { + if controller.snapshot.items.isEmpty { + controller.refresh() + } + } + .confirmationDialog( + pendingAction?.confirmationTitle(localization: localization) + ?? localization.string("manager.confirm.defaultTitle", defaultValue: "确认操作"), + isPresented: Binding( + get: { pendingAction != nil }, + set: { if !$0 { pendingAction = nil } } + ), + titleVisibility: .visible + ) { + if let pendingAction { + Button(pendingAction.buttonTitle(localization: localization), role: pendingAction.role) { + perform(pendingAction) + self.pendingAction = nil + } + } + Button(localization.string("manager.confirm.cancel", defaultValue: "取消"), role: .cancel) { + pendingAction = nil + } + } message: { + if let pendingAction { + Text(pendingAction.message(localization: localization)) + } + } + } + + private var summaryHeader: some View { + HStack(alignment: .center, spacing: 12) { + metric(localization.string("manager.metric.total", defaultValue: "总数"), value: controller.snapshot.items.count, color: .primary) + metric(localization.string("manager.metric.favorite", defaultValue: "关注"), value: controller.snapshot.items.filter(\.isFavorite).count, color: .yellow) + metric(localization.string("manager.metric.running", defaultValue: "运行中"), value: controller.snapshot.items.filter { $0.state == .running }.count, color: .green) + metric(localization.string("manager.metric.userCreated", defaultValue: "用户创建"), value: controller.snapshot.items.filter { $0.origin == .userCreated }.count, color: .orange) + metric(localization.string("manager.metric.appCreated", defaultValue: "应用创建"), value: controller.snapshot.items.filter { $0.origin == .thirdParty }.count, color: .blue) + metric(localization.string("manager.metric.failed", defaultValue: "异常"), value: controller.snapshot.items.filter { $0.state == .failed }.count, color: .red) + + Spacer() + + if let target = controller.snapshot.currentScanTarget { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + .scaleEffect(0.72) + Text(URL(fileURLWithPath: target).lastPathComponent.isEmpty ? target : URL(fileURLWithPath: target).lastPathComponent) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: 260, alignment: .trailing) + } + } + } + + private var toolbar: some View { + HStack(spacing: 10) { + Picker(localization.string("manager.filter.scope", defaultValue: "范围"), selection: $scopeFilter) { + ForEach(LaunchControlFilter.allCases) { filter in + Text(filter.title(localization: localization)).tag(filter) + } + } + .labelsHidden() + .frame(width: 112) + + Picker(localization.string("manager.filter.origin", defaultValue: "来源"), selection: $originFilter) { + ForEach(LaunchControlOriginFilter.allCases) { filter in + Text(filter.title(localization: localization)).tag(filter) + } + } + .labelsHidden() + .frame(width: 112) + + Picker(localization.string("manager.filter.state", defaultValue: "状态"), selection: $stateFilter) { + ForEach(LaunchControlStateFilter.allCases) { filter in + Text(filter.title(localization: localization)).tag(filter) + } + } + .labelsHidden() + .frame(width: 112) + + TextField(localization.string("manager.search.placeholder", defaultValue: "搜索 label、命令或路径"), text: $searchText) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 180, idealWidth: 260, maxWidth: 320) + + Spacer() + + Button { + originFilter = originFilter == .favorite ? .all : .favorite + } label: { + Label(localization.string("manager.action.favoriteFilter", defaultValue: "关注"), systemImage: originFilter == .favorite ? "star.fill" : "star") + } + .buttonStyle(.bordered) + + Button { + controller.refresh() + } label: { + Label( + controller.snapshot.isRefreshing + ? localization.string("manager.action.refreshing", defaultValue: "刷新中") + : localization.string("manager.action.refresh", defaultValue: "刷新"), + systemImage: "arrow.clockwise" + ) + } + .disabled(controller.snapshot.isRefreshing) + } + } + + private var itemList: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(localization.string("manager.list.title", defaultValue: "启动项")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Spacer() + Text("\(filteredItems.count) / \(controller.snapshot.items.count)") + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.top, 12) + + List(selection: selectedItemBinding) { + ForEach(filteredItems) { item in + LaunchControlItemRow( + item: item, + localization: localization, + onFavoriteToggle: { + controller.setFavorite(!item.isFavorite, for: item) + } + ) + .tag(item.id) + } + } + .listStyle(.inset) + .overlay { + if controller.snapshot.isRefreshing && controller.snapshot.items.isEmpty { + ProgressView(localization.string("manager.list.scanning", defaultValue: "正在扫描")) + .controlSize(.small) + } else if filteredItems.isEmpty { + ContentUnavailableView( + localization.string("manager.list.empty", defaultValue: "没有匹配项目"), + systemImage: "magnifyingglass" + ) + } + } + } + .background(PluginSettingsTheme.Palette.nativeFieldBackground) + } + + private var detailPane: some View { + Group { + if let item = selectedItem { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + detailHeader(item) + actionBar(item) + LaunchControlNoteEditor(item: item, controller: controller, localization: localization) + .id(item.id) + keyFields(item) + rawPlist(item) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(16) + } + } else { + placeholder + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var placeholder: some View { + VStack(spacing: 10) { + Image(systemName: "list.bullet.rectangle") + .font(.system(size: PluginSettingsTheme.Size.emptyStateIcon, weight: .regular)) + .foregroundStyle(.secondary) + Text(controller.snapshot.isRefreshing + ? localization.string("manager.placeholder.loadingTitle", defaultValue: "正在读取启动项") + : localization.string("manager.placeholder.title", defaultValue: "选择一个启动项") + ) + .font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold)) + Text(controller.snapshot.isRefreshing + ? localization.string("manager.placeholder.loadingDescription", defaultValue: "左侧列表会随扫描进度逐步更新") + : localization.string("manager.placeholder.description", defaultValue: "查看 plist 字段、运行状态和可用操作") + ) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + @ViewBuilder + private var scanActivity: some View { + if controller.snapshot.isRefreshing || !controller.snapshot.scanLogEntries.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(localization.string("manager.scanActivity.title", defaultValue: "扫描进度")) + .font(PluginSettingsTheme.Typography.sectionTitle) + Spacer() + if controller.snapshot.isRefreshing { + ProgressView() + .controlSize(.small) + .scaleEffect(0.72) + } + } + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(Array(controller.snapshot.scanLogEntries.enumerated()), id: \.offset) { index, entry in + Text(entry) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .id(index) + } + } + } + .frame(height: 88) + .onChange(of: controller.snapshot.scanLogEntries.count) { + if let lastIndex = controller.snapshot.scanLogEntries.indices.last { + proxy.scrollTo(lastIndex, anchor: .bottom) + } + } + } + } + .padding(10) + .pluginSettingsCardBackground(.plugin) + } + } + + private var filteredItems: [LaunchControlItem] { + controller.snapshot.items.filter { item in + let scopeMatches = scopeFilter.scope.map { $0 == item.scope } ?? true + let originMatches = originFilter.matches(item) + let stateMatches = stateFilter.matches(item.state) + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + let searchMatches: Bool + if query.isEmpty { + searchMatches = true + } else { + let haystack = [ + item.label, + item.commandText(localization: localization), + item.plistURL.path, + item.origin.title(localization: localization), + item.triggerSummary(localization: localization), + item.note + ].joined(separator: "\n") + searchMatches = haystack.localizedCaseInsensitiveContains(query) + } + return scopeMatches && originMatches && stateMatches && searchMatches + } + } + + private var selectedItem: LaunchControlItem? { + if let selectedID = controller.snapshot.selectedItemID, + let visibleItem = filteredItems.first(where: { $0.id == selectedID }) { + return visibleItem + } + + return filteredItems.first ?? controller.snapshot.selectedItem + } + + private var selectedItemBinding: Binding { + Binding( + get: { selectedItem?.id }, + set: { controller.selectItem(id: $0) } + ) + } + + private func detailHeader(_ item: LaunchControlItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: item.origin == .system ? "lock.shield" : "powerplug") + .font(PluginSettingsTheme.Typography.pageTitle.weight(.semibold)) + .foregroundStyle(item.origin == .system ? Color.secondary : Color.orange) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 5) { + Text(item.label) + .font(PluginSettingsTheme.Typography.pageTitle) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Text(item.plistURL.path) + .font(PluginSettingsTheme.Typography.controlLabel) + .foregroundStyle(.secondary) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + Button { + controller.setFavorite(!item.isFavorite, for: item) + } label: { + Image(systemName: item.isFavorite ? "star.fill" : "star") + .font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold)) + .foregroundStyle(item.isFavorite ? Color.yellow : Color.secondary) + } + .buttonStyle(.plain) + .help(item.isFavorite + ? localization.string("manager.favorite.removeHelp", defaultValue: "取消关注") + : localization.string("manager.favorite.addHelp", defaultValue: "关注启动项") + ) + } + + FlowLayout(spacing: 8, rowSpacing: 6) { + badge(item.scope.title(localization: localization), color: .blue) + badge(item.origin.title(localization: localization), color: item.origin == .system ? .gray : .orange) + badge(item.statusText(localization: localization), color: item.state == .failed ? .red : .green) + if !item.canManage { + badge(localization.string("manager.badge.readOnly", defaultValue: "只读"), color: .gray) + } + } + } + } + + private func actionBar(_ item: LaunchControlItem) -> some View { + FlowLayout(spacing: 8, rowSpacing: 8) { + Button { + controller.setFavorite(!item.isFavorite, for: item) + } label: { + Label( + item.isFavorite + ? localization.string("manager.action.unfavorite", defaultValue: "取消关注") + : localization.string("manager.action.favorite", defaultValue: "关注"), + systemImage: item.isFavorite ? "star.slash" : "star" + ) + } + + Button { + controller.openInFinder(item) + } label: { + Label(localization.string("manager.action.revealInFinder", defaultValue: "在 Finder 中显示"), systemImage: "finder") + } + + if item.canManage { + Button { + pendingAction = .bootstrap(item) + } label: { + Label(LaunchControlManagedAction.bootstrap.title(localization: localization), systemImage: "tray.and.arrow.down") + } + .disabled(item.isLoaded) + + Button { + pendingAction = .bootout(item) + } label: { + Label(LaunchControlManagedAction.bootout.title(localization: localization), systemImage: "tray.and.arrow.up") + } + .disabled(!item.isLoaded) + + Button { + pendingAction = item.isDisabled ? .enable(item) : .disable(item) + } label: { + Label( + item.isDisabled + ? LaunchControlManagedAction.enable.title(localization: localization) + : LaunchControlManagedAction.disable.title(localization: localization), + systemImage: item.isDisabled ? "checkmark.circle" : "nosign" + ) + } + + Button { + pendingAction = item.state == .running ? .stop(item) : .start(item) + } label: { + Label( + item.state == .running + ? LaunchControlManagedAction.stop.title(localization: localization) + : LaunchControlManagedAction.start.title(localization: localization), + systemImage: item.state == .running ? "stop.circle" : "play.circle" + ) + } + .disabled(item.isDisabled) + + Button { + pendingAction = .restart(item) + } label: { + Label(LaunchControlManagedAction.restart.title(localization: localization), systemImage: "arrow.clockwise.circle") + } + .disabled(item.isDisabled) + } + } + .buttonStyle(.bordered) + } + + private func keyFields(_ item: LaunchControlItem) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text(localization.string("manager.keyFields.title", defaultValue: "关键字段")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + + fieldRow( + "ProgramArguments", + value: item.commandText(localization: localization), + help: localization.string("manager.field.programArguments.help", defaultValue: "启动时执行的命令与参数。") + ) + fieldRow( + "RunAtLoad", + value: item.runAtLoad ? "true" : "false", + help: localization.string("manager.field.runAtLoad.help", defaultValue: "加载 LaunchAgent 后是否立即运行一次。") + ) + fieldRow( + "KeepAlive", + value: item.keepAliveDescription ?? notSetText, + help: localization.string("manager.field.keepAlive.help", defaultValue: "进程退出后是否按条件自动拉起。") + ) + fieldRow( + "StartInterval", + value: item.startInterval.map { secondsText($0) } ?? notSetText, + help: localization.string("manager.field.startInterval.help", defaultValue: "按固定秒数间隔触发。") + ) + fieldRow( + "StartCalendarInterval", + value: item.startCalendarDescription ?? notSetText, + help: localization.string("manager.field.startCalendar.help", defaultValue: "按日历时间触发。") + ) + fieldRow( + localization.string("manager.field.triggerSummary.title", defaultValue: "触发摘要"), + value: item.triggerSummary(localization: localization), + help: localization.string("manager.field.triggerSummary.help", defaultValue: "根据常见字段生成的可读说明。") + ) + } + } + + private func rawPlist(_ item: LaunchControlItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(localization.string("manager.rawPlist.title", defaultValue: "原始 plist")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + + ScrollView(.horizontal) { + Text(item.rawPlist.isEmpty + ? localization.string("manager.rawPlist.unreadable", defaultValue: "无法以 UTF-8 显示原始内容") + : item.rawPlist + ) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + } + .frame(minHeight: 180, maxHeight: 260) + .pluginSettingsCardBackground(.recessed) + } + } + + private func fieldRow(_ title: String, value: String, help: String) -> some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 4) { + GridRow { + Text(title) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 138, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(PluginSettingsTheme.Typography.rowTitle) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Text(help) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .pluginSettingsCardBackground(.plugin) + } + + private func statusBanner(message: String, systemImage: String, color: Color) -> some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(color) + Text(message) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(10) + .background(color.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.control, style: .continuous)) + } + + private func metric(_ title: String, value: Int, color: Color) -> some View { + HStack(spacing: 6) { + Text("\(value)") + .font(PluginSettingsTheme.Typography.pageTitle) + .foregroundStyle(color) + .monospacedDigit() + Text(title) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .pluginSettingsCardBackground(.plugin) + } + + private func badge(_ title: String, color: Color) -> some View { + Text(title) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.12)) + .clipShape(Capsule()) + } + + private func perform(_ action: LaunchControlPendingAction) { + switch action { + case let .bootstrap(item): + controller.bootstrap(item) + case let .bootout(item): + controller.bootout(item) + case let .enable(item): + controller.enable(item) + case let .disable(item): + controller.disable(item) + case let .start(item): + controller.start(item) + case let .stop(item): + controller.stop(item) + case let .restart(item): + controller.restart(item) + } + } + + private var notSetText: String { + localization.string("manager.field.notSet", defaultValue: "未设置") + } + + private func secondsText(_ seconds: Int) -> String { + localization.format("manager.seconds", defaultValue: "%d 秒", seconds) + } +} + +private struct FlowLayout: Layout { + var spacing: CGFloat + var rowSpacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let rows = rows(proposal: proposal, subviews: subviews) + return CGSize( + width: proposal.width ?? rows.map(\.width).max() ?? 0, + height: rows.last.map { $0.y + $0.height } ?? 0 + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + for row in rows(proposal: ProposedViewSize(width: bounds.width, height: proposal.height), subviews: subviews) { + for item in row.items { + subviews[item.index].place( + at: CGPoint(x: bounds.minX + item.x, y: bounds.minY + row.y), + proposal: ProposedViewSize(item.size) + ) + } + } + } + + private func rows(proposal: ProposedViewSize, subviews: Subviews) -> [FlowRow] { + let maxWidth = proposal.width ?? .greatestFiniteMagnitude + var rows: [FlowRow] = [] + var currentItems: [FlowItem] = [] + var currentWidth: CGFloat = 0 + var currentHeight: CGFloat = 0 + var y: CGFloat = 0 + + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let nextWidth = currentItems.isEmpty ? size.width : currentWidth + spacing + size.width + if nextWidth > maxWidth, !currentItems.isEmpty { + rows.append(FlowRow(items: currentItems, y: y, width: currentWidth, height: currentHeight)) + y += currentHeight + rowSpacing + currentItems = [] + currentWidth = 0 + currentHeight = 0 + } + + let x = currentItems.isEmpty ? 0 : currentWidth + spacing + currentItems.append(FlowItem(index: index, x: x, size: size)) + currentWidth = currentItems.isEmpty ? size.width : x + size.width + currentHeight = max(currentHeight, size.height) + } + + if !currentItems.isEmpty { + rows.append(FlowRow(items: currentItems, y: y, width: currentWidth, height: currentHeight)) + } + + return rows + } +} + +private struct FlowRow { + let items: [FlowItem] + let y: CGFloat + let width: CGFloat + let height: CGFloat +} + +private struct FlowItem { + let index: Int + let x: CGFloat + let size: CGSize +} + +private struct LaunchControlItemRow: View { + let item: LaunchControlItem + let localization: PluginLocalization + let onFavoriteToggle: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(item.label) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + .lineLimit(1) + if item.origin == .userCreated { + Text(localization.string("manager.itemRow.userBadge", defaultValue: "用户")) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.orange) + } + } + + Text(item.commandText(localization: localization)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + + Text(localization.format( + "manager.itemRow.scopeStatus", + defaultValue: "%@ · %@", + item.scope.title(localization: localization), + item.statusText(localization: localization) + )) + .font(PluginSettingsTheme.Typography.secondaryLabel) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 4) + + Button(action: onFavoriteToggle) { + Image(systemName: item.isFavorite ? "star.fill" : "star") + .font(PluginSettingsTheme.Typography.secondaryLabel.weight(.semibold)) + .foregroundStyle(item.isFavorite ? Color.yellow : Color.secondary) + .frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .help(item.isFavorite + ? localization.string("manager.favorite.removeHelp", defaultValue: "取消关注") + : localization.string("manager.favorite.addHelp", defaultValue: "关注启动项") + ) + } + .padding(.vertical, 4) + } + + private var statusColor: Color { + switch item.state { + case .running: + return .green + case .failed: + return .red + case .disabled: + return .gray + case .loaded: + return .blue + case .unloaded, + .unknown: + return .secondary + } + } +} + +private enum LaunchControlPendingAction: Identifiable { + case bootstrap(LaunchControlItem) + case bootout(LaunchControlItem) + case enable(LaunchControlItem) + case disable(LaunchControlItem) + case start(LaunchControlItem) + case stop(LaunchControlItem) + case restart(LaunchControlItem) + + var id: String { + "\(actionName)-\(item.id)" + } + + var item: LaunchControlItem { + switch self { + case let .bootstrap(item), + let .bootout(item), + let .enable(item), + let .disable(item), + let .start(item), + let .stop(item), + let .restart(item): + return item + } + } + + var actionName: String { + action.title() + } + + func actionName(localization: PluginLocalization) -> String { + action.title(localization: localization) + } + + var action: LaunchControlManagedAction { + switch self { + case .bootstrap: + return .bootstrap + case .bootout: + return .bootout + case .enable: + return .enable + case .disable: + return .disable + case .start: + return .start + case .stop: + return .stop + case .restart: + return .restart + } + } + + var buttonTitle: String { + "\(actionName) \(item.label)" + } + + func buttonTitle(localization: PluginLocalization) -> String { + localization.format( + "manager.confirm.buttonTitle", + defaultValue: "%@ %@", + actionName(localization: localization), + item.label + ) + } + + var confirmationTitle: String { + confirmationTitle(localization: PluginLocalization(bundle: .main)) + } + + func confirmationTitle(localization: PluginLocalization) -> String { + localization.format( + "manager.confirm.title", + defaultValue: "确认%@启动项?", + actionName(localization: localization) + ) + } + + var message: String { + message(localization: PluginLocalization(bundle: .main)) + } + + func message(localization: PluginLocalization) -> String { + localization.format( + "manager.confirm.message", + defaultValue: "%@\n%@\n\n此操作会调用 launchctl %@。用户级 LaunchAgent 通常可以恢复,但禁用、卸载或停止可能影响后台任务。", + item.label, + item.plistURL.path, + actionName(localization: localization) + ) + } + + var role: ButtonRole? { + switch self { + case .bootout, + .disable, + .stop: + return .destructive + case .bootstrap, + .enable, + .start, + .restart: + return nil + } + } +} + +private struct LaunchControlNoteEditor: View { + let item: LaunchControlItem + let controller: LaunchControlController + let localization: PluginLocalization + @State private var draft: String + + init( + item: LaunchControlItem, + controller: LaunchControlController, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.item = item + self.controller = controller + self.localization = localization + _draft = State(initialValue: item.note) + } + + private var isDirty: Bool { + draft.trimmingCharacters(in: .whitespacesAndNewlines) + != item.note.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(localization.string("manager.note.title", defaultValue: "备注")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Spacer() + if isDirty { + Button(localization.string("manager.note.save", defaultValue: "保存")) { + controller.setNote(draft, for: item) + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + } + } + + TextEditor(text: $draft) + .font(.system(size: 12)) + .scrollContentBackground(.hidden) + .frame(minHeight: 60, maxHeight: 120) + .padding(6) + .pluginSettingsCardBackground(.recessed) + .overlay(alignment: .topLeading) { + if draft.isEmpty { + Text(localization.string( + "manager.note.placeholder", + defaultValue: "为这个启动项添加本地备注(仅保存在本机,不写入 plist)。" + )) + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 11) + .padding(.top, 13) + .allowsHitTesting(false) + } + } + } + } +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlModels.swift b/Plugins/LaunchControl/Sources/LaunchControlModels.swift new file mode 100644 index 0000000..9f9f119 --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlModels.swift @@ -0,0 +1,459 @@ +import Foundation +import MacToolsPluginKit + +enum LaunchControlScope: String, CaseIterable, Identifiable, Sendable { + case user + case global + case system + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .user: + return localization.string("scope.user.title", defaultValue: "当前用户") + case .global: + return localization.string("scope.global.title", defaultValue: "全局") + case .system: + return localization.string("scope.system.title", defaultValue: "系统") + } + } +} + +enum LaunchControlState: String, CaseIterable, Identifiable, Sendable { + case running + case loaded + case disabled + case failed + case unloaded + case unknown + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .running: + return localization.string("state.running.title", defaultValue: "运行中") + case .loaded: + return localization.string("state.loaded.title", defaultValue: "已加载") + case .disabled: + return localization.string("state.disabled.title", defaultValue: "已禁用") + case .failed: + return localization.string("state.failed.title", defaultValue: "异常退出") + case .unloaded: + return localization.string("state.unloaded.title", defaultValue: "未加载") + case .unknown: + return localization.string("state.unknown.title", defaultValue: "未知") + } + } +} + +enum LaunchControlOrigin: String, CaseIterable, Identifiable, Sendable { + case userCreated + case thirdParty + case system + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .userCreated: + return localization.string("origin.userCreated.title", defaultValue: "用户创建") + case .thirdParty: + return localization.string("origin.thirdParty.title", defaultValue: "应用创建") + case .system: + return localization.string("origin.system.title", defaultValue: "系统内置") + } + } +} + +enum LaunchControlOriginFilter: String, CaseIterable, Identifiable { + case all + case favorite + case manageable + case userCreated + case appCreated + case system + case readOnly + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .all: + return localization.string("originFilter.all.title", defaultValue: "全部来源") + case .favorite: + return localization.string("originFilter.favorite.title", defaultValue: "已关注") + case .manageable: + return localization.string("originFilter.manageable.title", defaultValue: "可操作") + case .userCreated: + return LaunchControlOrigin.userCreated.title(localization: localization) + case .appCreated: + return LaunchControlOrigin.thirdParty.title(localization: localization) + case .system: + return LaunchControlOrigin.system.title(localization: localization) + case .readOnly: + return localization.string("originFilter.readOnly.title", defaultValue: "只读") + } + } + + func matches(_ item: LaunchControlItem) -> Bool { + switch self { + case .all: + return true + case .favorite: + return item.isFavorite + case .manageable: + return item.canManage + case .userCreated: + return item.origin == .userCreated + case .appCreated: + return item.origin == .thirdParty + case .system: + return item.origin == .system + case .readOnly: + return !item.canManage + } + } +} + +enum LaunchControlFilter: String, CaseIterable, Identifiable { + case all + case user + case global + case system + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .all: + return localization.string("scopeFilter.all.title", defaultValue: "全部范围") + case .user: + return LaunchControlScope.user.title(localization: localization) + case .global: + return LaunchControlScope.global.title(localization: localization) + case .system: + return LaunchControlScope.system.title(localization: localization) + } + } + + var scope: LaunchControlScope? { + switch self { + case .all: + return nil + case .user: + return .user + case .global: + return .global + case .system: + return .system + } + } +} + +enum LaunchControlStateFilter: String, CaseIterable, Identifiable { + case all + case running + case loaded + case disabled + case failed + + var id: String { rawValue } + + var title: String { + title() + } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .all: + return localization.string("stateFilter.all.title", defaultValue: "全部状态") + case .running: + return LaunchControlState.running.title(localization: localization) + case .loaded: + return LaunchControlState.loaded.title(localization: localization) + case .disabled: + return LaunchControlState.disabled.title(localization: localization) + case .failed: + return LaunchControlState.failed.title(localization: localization) + } + } + + func matches(_ state: LaunchControlState) -> Bool { + switch self { + case .all: + return true + case .running: + return state == .running + case .loaded: + return state == .loaded || state == .running + case .disabled: + return state == .disabled + case .failed: + return state == .failed + } + } +} + +struct LaunchControlItem: Identifiable, Equatable, Sendable { + let id: String + let label: String + let plistURL: URL + let scope: LaunchControlScope + let origin: LaunchControlOrigin + let state: LaunchControlState + let pid: Int? + let lastExitStatus: Int? + let programArguments: [String] + let runAtLoad: Bool + let keepAliveDescription: String? + let startInterval: Int? + let startCalendarDescription: String? + let rawPlist: String + let launchctlDomain: String + let isDisabled: Bool + let isLoaded: Bool + var isFavorite: Bool + /// User-authored local note (persisted separately; never written to the plist). + var note: String = "" + + var commandText: String { + commandText() + } + + func commandText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + if !programArguments.isEmpty { + return programArguments.joined(separator: " ") + } + + return localization.string("item.commandText.empty", defaultValue: "未声明 ProgramArguments") + } + + var triggerSummary: String { + triggerSummary() + } + + func triggerSummary(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + var parts: [String] = [] + if runAtLoad { + parts.append(localization.string("item.trigger.runAtLoad", defaultValue: "登录/加载时运行")) + } + if let keepAliveDescription { + parts.append(localization.format( + "item.trigger.keepAlive", + defaultValue: "KeepAlive: %@", + keepAliveDescription + )) + } + if let startInterval { + parts.append(localization.format("item.trigger.startInterval", defaultValue: "每 %d 秒", startInterval)) + } + if let startCalendarDescription { + parts.append(localization.format( + "item.trigger.startCalendar", + defaultValue: "定时: %@", + startCalendarDescription + )) + } + return parts.isEmpty + ? localization.string("item.trigger.empty", defaultValue: "未声明自动触发条件") + : parts.joined(separator: " · ") + } + + var canManage: Bool { + scope == .user && !label.isEmpty && plistURL.path.hasPrefix(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents").path) + } + + var statusText: String { + statusText() + } + + func statusText(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + if let pid { + return localization.format( + "item.status.pid", + defaultValue: "%@ · PID %d", + state.title(localization: localization), + pid + ) + } + if let lastExitStatus { + return localization.format( + "item.status.exitCode", + defaultValue: "%@ · 退出码 %d", + state.title(localization: localization), + lastExitStatus + ) + } + return state.title(localization: localization) + } +} + +struct LaunchControlSnapshot: Equatable { + var items: [LaunchControlItem] = [] + var selectedItemID: String? + var isRefreshing = false + var lastRefreshDate: Date? + var errorMessage: String? + var operationMessage: String? + var scanLogEntries: [String] = [] + var currentScanTarget: String? + + var selectedItem: LaunchControlItem? { + guard let selectedItemID else { + return items.first + } + + return items.first(where: { $0.id == selectedItemID }) ?? items.first + } +} + +@MainActor +final class LaunchControlFavoritesStore { + private enum StorageKey { + static let storage = "launch-control.favorite-item-ids" + } + + private let storage: PluginStorage + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), + userDefaults: UserDefaults? = nil + ) { + self.storage = userDefaults.map { + UserDefaultsPluginStorage(pluginID: context.pluginID, userDefaults: $0) + } ?? context.storage + storage.migrateValueIfNeeded(fromLegacyKey: StorageKey.storage, to: StorageKey.storage) + } + + func favoriteItemIDs() -> Set { + Set(storage.stringArray(forKey: StorageKey.storage) ?? []) + } + + func isFavorite(_ itemID: String) -> Bool { + favoriteItemIDs().contains(itemID) + } + + func setFavorite(_ isFavorite: Bool, for itemID: String) { + var favorites = favoriteItemIDs() + if isFavorite { + favorites.insert(itemID) + } else { + favorites.remove(itemID) + } + storage.set(favorites.sorted(), forKey: StorageKey.storage) + } +} + +/// Persists per-item user notes locally (keyed by item ID / plist path). Notes are +/// never written back to the launchd plist — they live only in plugin storage. +@MainActor +final class LaunchControlNotesStore { + private enum StorageKey { + static let storage = "launch-control.item-notes" + } + + private let storage: PluginStorage + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), + userDefaults: UserDefaults? = nil + ) { + self.storage = userDefaults.map { + UserDefaultsPluginStorage(pluginID: context.pluginID, userDefaults: $0) + } ?? context.storage + } + + func allNotes() -> [String: String] { + guard + let data = storage.data(forKey: StorageKey.storage), + let notes = try? JSONDecoder().decode([String: String].self, from: data) + else { + return [:] + } + return notes + } + + func note(for itemID: String) -> String { + allNotes()[itemID] ?? "" + } + + func setNote(_ note: String, for itemID: String) { + var notes = allNotes() + let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + notes.removeValue(forKey: itemID) + } else { + notes[itemID] = trimmed + } + guard let data = try? JSONEncoder().encode(notes) else { return } + storage.set(data, forKey: StorageKey.storage) + } +} + +enum LaunchControlScanEvent: Sendable { + case directory(String) + case file(String) + case found(LaunchControlItem) + case message(String) +} + +enum LaunchControlManagedAction: String, Sendable { + case bootstrap + case bootout + case enable + case disable + case start + case stop + case restart + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + switch self { + case .bootstrap: + return localization.string("managedAction.bootstrap.title", defaultValue: "加载") + case .bootout: + return localization.string("managedAction.bootout.title", defaultValue: "卸载") + case .enable: + return localization.string("managedAction.enable.title", defaultValue: "启用") + case .disable: + return localization.string("managedAction.disable.title", defaultValue: "禁用") + case .start: + return localization.string("managedAction.start.title", defaultValue: "启动") + case .stop: + return localization.string("managedAction.stop.title", defaultValue: "停止") + case .restart: + return localization.string("managedAction.restart.title", defaultValue: "重启") + } + } +} + +struct LaunchControlPlistSummary: Sendable { + let label: String + let programArguments: [String] + let runAtLoad: Bool + let keepAliveDescription: String? + let startInterval: Int? + let startCalendarDescription: String? + let rawPlist: String +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift b/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift new file mode 100644 index 0000000..af3fd91 --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlPlugin.swift @@ -0,0 +1,191 @@ +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class LaunchControlPluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + LaunchControlPluginProvider(context: context) + } +} + +@MainActor +private struct LaunchControlPluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + let localization = PluginLocalization(bundle: context.resourceBundle) + let controller = LaunchControlController(context: context, localization: localization) + return [LaunchControlPlugin(context: context, controller: controller, localization: localization)] + } +} + +@MainActor +final class LaunchControlPlugin: MacToolsPlugin, PluginPrimaryPanel { + enum ControlID { + static let refresh = "launch-control-refresh" + static let openManager = "launch-control-open-manager" + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let controller: LaunchControlController + private let localization: PluginLocalization + private var isExpanded = false + + init( + context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "launch-control"), + controller: LaunchControlController? = nil, + localization: PluginLocalization? = nil + ) { + let localization = localization ?? PluginLocalization(bundle: context.resourceBundle) + self.localization = localization + self.controller = controller ?? LaunchControlController(context: context, localization: localization) + self.metadata = PluginMetadata( + id: "launch-control", + title: localization.string("metadata.title", defaultValue: "启动项"), + iconName: "powerplug", + iconTint: Color(nsColor: .systemOrange), + order: 95, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "查看和管理 launchctl 启动项" + ) + ) + self.controller.onStateChange = { [weak self] in + self?.onStateChange?() + } + } + + var primaryPanelState: PluginPanelState { + let snapshot = controller.snapshot + return PluginPanelState( + subtitle: subtitle(for: snapshot), + isOn: snapshot.isRefreshing, + isExpanded: isExpanded, + isEnabled: true, + isVisible: true, + detail: isExpanded ? buildDetail(for: snapshot) : nil, + errorMessage: snapshot.errorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { [] } + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + var configuration: PluginConfiguration? { + let localization = localization + return PluginConfiguration(description: metadata.defaultDescription) { _ in + LaunchControlManagerView(controller: self.controller, localization: localization) + } + } + + func refresh() { + if controller.snapshot.items.isEmpty { + controller.refresh() + } + } + + func deactivate(reason: PluginDeactivationReason) { + controller.cancelRefresh() + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(value): + isExpanded = value + onStateChange?() + case let .invokeAction(controlID): + if controlID == ControlID.refresh { + controller.refresh() + } + case .setSwitch, + .setSelection, + .setNavigationSelection, + .clearNavigationSelection, + .setDate, + .setSlider: + break + } + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + PluginPermissionState(isGranted: true, footnote: nil) + } + + func handlePermissionAction(id: String) {} + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + private func buildDetail(for snapshot: LaunchControlSnapshot) -> PluginPanelDetail { + let refreshControl = PluginPanelControl( + id: ControlID.refresh, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: snapshot.isRefreshing + ? localization.string("panel.action.refreshing", defaultValue: "正在刷新") + : localization.string("panel.action.refresh", defaultValue: "刷新列表"), + actionIconSystemName: "arrow.clockwise", + isEnabled: !snapshot.isRefreshing + ) + + let openManagerControl = PluginPanelControl( + id: ControlID.openManager, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.openManager", defaultValue: "打开管理器"), + actionIconSystemName: "arrow.up.right.square", + actionBehavior: .dismissBeforeHandling, + showsLeadingDivider: true, + isEnabled: true + ) + + return PluginPanelDetail(primaryControls: [refreshControl, openManagerControl], secondaryPanel: nil) + } + + private func subtitle(for snapshot: LaunchControlSnapshot) -> String { + if snapshot.isRefreshing { + return localization.string( + "panel.subtitle.refreshing", + defaultValue: "正在扫描 LaunchAgent 与 LaunchDaemon" + ) + } + + if snapshot.items.isEmpty { + return localization.string( + "panel.subtitle.empty", + defaultValue: "打开管理器或刷新后查看启动项" + ) + } + + let userCreatedCount = snapshot.items.filter { $0.origin == .userCreated }.count + let runningCount = snapshot.items.filter { $0.state == .running }.count + return localization.format( + "panel.subtitle.summary", + defaultValue: "%d 项 · %d 运行中 · %d 用户创建", + snapshot.items.count, + runningCount, + userCreatedCount + ) + } +} diff --git a/Plugins/LaunchControl/Sources/LaunchControlScanner.swift b/Plugins/LaunchControl/Sources/LaunchControlScanner.swift new file mode 100644 index 0000000..a9c20ba --- /dev/null +++ b/Plugins/LaunchControl/Sources/LaunchControlScanner.swift @@ -0,0 +1,428 @@ +import Foundation +import MacToolsPluginKit + +struct LaunchControlScanResult: Sendable { + let items: [LaunchControlItem] + let warnings: [String] +} + +struct LaunchControlScanner: @unchecked Sendable { + private struct LaunchDirectory: Sendable { + let url: URL + let scope: LaunchControlScope + let domain: String + } + + private let fileManager: FileManager + private let runner: any LaunchControlCommandRunning + private let userIDProvider: @Sendable () -> uid_t + private let localization: PluginLocalization + + init( + fileManager: FileManager = .default, + runner: any LaunchControlCommandRunning = ProcessLaunchControlCommandRunner(), + userIDProvider: @escaping @Sendable () -> uid_t = { getuid() }, + localization: PluginLocalization = PluginLocalization(bundle: .main) + ) { + self.fileManager = fileManager + self.runner = runner + self.userIDProvider = userIDProvider + self.localization = localization + } + + func scan(progress: @escaping @Sendable (LaunchControlScanEvent) -> Void = { _ in }) -> LaunchControlScanResult { + let userID = userIDProvider() + let userDomain = "gui/\(userID)" + let directories = launchDirectories(userDomain: userDomain) + var warnings: [String] = [] + progress(.message(localization.string("scanner.progress.disabledState", defaultValue: "读取 launchctl 禁用状态"))) + let disabledLabelsByDomain = loadDisabledLabels(domains: Set(directories.map(\.domain))) + // Fetch the whole user GUI domain in ONE `launchctl list` instead of one + // `launchctl print gui//