chore: import upstream snapshot with attribution
Build / Build and test (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:18:38 +08:00
commit 1d1286fadb
1468 changed files with 272754 additions and 0 deletions
+170
View File
@@ -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 <<EOF
DEVELOPMENT_TEAM =
BUNDLE_IDENTIFIER_PREFIX = ${CI_BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Generate Xcode project
run: make generate
- name: Build and run tests
run: |
xcodebuild \
-project "${PROJECT_NAME}.xcodeproj" \
-scheme "${SCHEME}" \
-configuration Debug \
-destination "platform=macOS" \
-derivedDataPath "${DERIVED_DATA}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY= \
test \
-quiet
- name: Package Debug app artifact
if: github.event_name == 'workflow_dispatch'
run: |
set -euo pipefail
PRODUCTS_DIR="${DERIVED_DATA}/Build/Products/Debug"
APP_PATH="${PRODUCTS_DIR}/MacTools Dev.app"
ARTIFACT_ROOT="build/debug-artifact/MacTools-Debug"
find "$PRODUCTS_DIR" -maxdepth 1 -name "*.bundle" -type d -print0 | while IFS= read -r -d '' bundle; do
codesign --force --deep --sign - "$bundle"
codesign --verify --deep --strict "$bundle"
done
if [[ -d "$APP_PATH/Contents/Frameworks" ]]; then
find "$APP_PATH/Contents/Frameworks" -type d \( -name "*.framework" -o -name "*.xpc" \) -print0 | while IFS= read -r -d '' item; do
codesign --force --deep --sign - "$item"
done
fi
codesign --force --deep --sign - --entitlements Configs/MacTools.entitlements "$APP_PATH"
codesign --verify --deep --strict "$APP_PATH"
./scripts/plugins/sync-debug-plugins.sh \
--source-dir Plugins \
--products-dir "$PRODUCTS_DIR" \
--output-dir build/LocalPlugins \
--skip-install
rm -rf build/debug-artifact
mkdir -p "$ARTIFACT_ROOT"
ditto "$APP_PATH" "$ARTIFACT_ROOT/MacTools Dev.app"
ditto build/LocalPlugins "$ARTIFACT_ROOT/LocalPlugins"
cat > "$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
+107
View File
@@ -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 }}."
+68
View File
@@ -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
+418
View File
@@ -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 <<EOF
DEVELOPMENT_TEAM = ${APPLE_DEVELOPMENT_TEAM}
BUNDLE_IDENTIFIER_PREFIX = ${BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Import Developer ID certificate
if: env.PLUGIN_RELEASE_REQUIRED == 'true' && env.PLUGIN_CHANGED_COUNT != '0'
env:
DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/developer-id.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 24)"
echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > "$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
+208
View File
@@ -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 <<EOF
DEVELOPMENT_TEAM =
BUNDLE_IDENTIFIER_PREFIX = ${CI_BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Prepare app release
if: inputs.type == 'app'
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
scripts/release.py \
--type app \
--version "$INPUT_VERSION" \
--yes
{
echo "RELEASE_TAG=v${INPUT_VERSION}"
echo "RELEASE_WORKFLOW=release.yml"
} >> "$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}."
+586
View File
@@ -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 <<EOF
DEVELOPMENT_TEAM = ${APPLE_DEVELOPMENT_TEAM}
BUNDLE_IDENTIFIER_PREFIX = ${BUNDLE_IDENTIFIER_PREFIX}
EOF
- name: Import Developer ID certificate
env:
DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/developer-id.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 24)"
echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > "$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("]]>", "]]]]><![CDATA[>")
content = f'''<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>{xml.escape(values["PROJECT_NAME"])} Releases</title>
<description>Latest release metadata for {xml.escape(values["PROJECT_NAME"])}.</description>
<language>en</language>
<item>
<title>Version {xml.escape(values["VERSION"])}</title>
<link>{xml.escape(values["RELEASE_NOTES_URL"])}</link>
<sparkle:version>{xml.escape(values["BUILD_NUMBER"])}</sparkle:version>
<sparkle:shortVersionString>{xml.escape(values["VERSION"])}</sparkle:shortVersionString>
<description sparkle:format="markdown"><![CDATA[{cdata_release_notes}]]></description>
<sparkle:fullReleaseNotesLink>{xml.escape(values["FULL_RELEASE_NOTES_URL"])}</sparkle:fullReleaseNotesLink>
<pubDate>{xml.escape(values["PUB_DATE"])}</pubDate>
<enclosure url="{xml.escape(values["DOWNLOAD_URL"])}" length="{xml.escape(values["FILE_SIZE"])}" type="application/octet-stream" sparkle:edSignature="{xml.escape(values["ED_SIGNATURE"])}" />
<sparkle:minimumSystemVersion>{xml.escape(values["MINIMUM_SYSTEM_VERSION"])}</sparkle:minimumSystemVersion>
</item>
</channel>
</rss>
'''
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
+23
View File
@@ -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
+103
View File
@@ -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/<PluginName>/` 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/<PluginName>/`: 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=<plugin directory name or plugin ID>`.
- 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/<TestClassName>` 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/<PluginName>/` 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/<PluginName>/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/<PluginName>/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 `<TypeName>Tests.swift`.
- Local and agent validation should default to the smallest relevant test method or class, such as `-only-testing:MacToolsTests/<TestClassName>` or `-only-testing:MacToolsTests/<TestClassName>/<testMethod>`. Do not run the full suite for narrow changes unless the scope justifies it.
- Plugin tests should prefer `Plugins/<PluginName>/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.
+129
View File
@@ -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 (AZ / ZA).
### 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.
+30
View File
@@ -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/<Name>`).
**Two plugin types and two loading paths**
- *Static plugins*: `Plugins/<Name>/` 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 <Name>Plugin -destination 'platform=macOS'`. For one test class, append `-only-testing:MacToolsTests/<TestClassName>` to the full test command from `AGENTS.md`.
- Plugin tests live under `Plugins/<Name>/Tests/`, but they run inside the `MacToolsTests` bundle. Typical imports are `@testable import MacTools` plus `@testable import <Name>Plugin`.
+77
View File
@@ -0,0 +1,77 @@
# Contributing to MacTools
<a href="CONTRIBUTING.zh-CN.md">[中文]</a> [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/<PluginName>/`: 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/<PluginName>/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/<PluginName>/` 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/<PluginName>/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 `<TypeName>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/<TestClassName>` 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`.
+76
View File
@@ -0,0 +1,76 @@
# Contributing to MacTools
[中文] <a href="CONTRIBUTING.md">[English]</a>
感谢你关注 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/<PluginName>/`:插件 manifest、源码、bundle 入口、资源和相邻测试。
- `Tests/`App/Core 共享逻辑的 XCTest;插件测试优先放在对应插件目录下。
- `project.yml`:XcodeGen 根项目源文件,只维护 App、PluginKit 和公共聚合入口;插件 target 由生成器自动生成。
- `Plugins/<PluginName>/project.yml`:可选的插件构建差异配置,仅在插件需要额外 framework、include path、bundle 资源、helper/tool target 或 target 覆盖时添加。
- `docs/plugins/`:插件包、catalog、本地调试和发布流程说明。
- `docs/superpowers/`:较大的产品、交互或实施设计文档。
## 开发约定
- 新增插件放在 `Plugins/<PluginName>/`,至少包含 `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/<PluginName>/Resources`。插件 `plugin.json` 保留 `displayName`/`summary` 作为 fallback,并为插件市场和未加载插件展示提供 `localizedMetadata`
- 新插件应尽量提供多语言,至少覆盖面板文案、设置文案、权限说明和插件元数据。
- 优先复用 Apple 原生框架;新增系统 framework、私有 include path、bundle 内辅助可执行文件时,在插件自己的 `project.yml` 中声明最小差异。需要单独签名的 bundle 资源可执行文件应写入 `plugin.json.package.signPaths`
## 测试
- 行为改动应补充或更新相邻 XCTest,测试文件命名使用 `<TypeName>Tests.swift`
- 完整测试:`xcodebuild -project MacTools.xcodeproj -scheme MacTools -configuration Debug -derivedDataPath build/DerivedData test -quiet`
- 单个测试类:在完整测试命令后追加 `-only-testing:MacToolsTests/<TestClassName>`
- 文件系统测试使用临时目录或 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。默认只构建和上传版本递增的插件,并将新条目合并进生产 catalogcatalog 私钥、Developer ID 证书和 GitHub token 必须来自 CI secrets 或本地环境变量。
- GitHub Actions 自动构建与发布配置见 `docs/github-actions.md`;插件 catalog、包结构和批次发布流程见 `docs/plugins/plugin-catalog.md`
+2
View File
@@ -0,0 +1,2 @@
#include "Debug.xcconfig"
#include "AppVersion.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
#include "Release.xcconfig"
#include "AppVersion.xcconfig"
+2
View File
@@ -0,0 +1,2 @@
MARKETING_VERSION = 1.1.0
CURRENT_PROJECT_VERSION = 62
+4
View File
@@ -0,0 +1,4 @@
#include? "../LocalConfig.xcconfig"
SWIFT_STRICT_CONCURRENCY = complete
PLUGIN_CATALOG_PUBLIC_KEY = V7knIDPAmvC9rhCB3k3EQnSR+ZKiUlA12AIAgg/ajVk=
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).url.right-click</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(MACTOOLS_URL_SCHEME)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>MTPluginCatalogPublicKey</key>
<string>$(PLUGIN_CATALOG_PUBLIC_KEY)</string>
<key>MTRightClickConfigurationHomeRelativePath</key>
<string>$(RIGHT_CLICK_CONFIGURATION_HOME_RELATIVE_PATH)</string>
<key>NSAppleEventsUsageDescription</key>
<string>点击日历日期时需要控制系统日历应用并定位到对应日期。</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>读取蓝牙外设电量,用于在设备电量组件中显示鼠标、键盘和耳机状态。</string>
<key>NSCalendarsUsageDescription</key>
<string>读取系统日历事件,用于在右键日历组件中显示当天日程。</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>读取系统日历事件,用于在右键日历组件中显示当天日程。</string>
<key>SUAllowsAutomaticUpdates</key>
<false/>
<key>SUEnableAutomaticChecks</key>
<false/>
<key>SUFeedURL</key>
<string>https://mactools.ggbond.app/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>$(SPARKLE_PUBLIC_ED_KEY)</string>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.directory</string>
</array>
<key>UTTypeDescription</key>
<string>MacTools Plugin</string>
<key>UTTypeIdentifier</key>
<string>com.ggbond.mactools.plugin-package</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mactoolsplugin</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>
+4
View File
@@ -0,0 +1,4 @@
#include? "../LocalConfig.xcconfig"
SWIFT_STRICT_CONCURRENCY = complete
PLUGIN_CATALOG_PUBLIC_KEY = V7knIDPAmvC9rhCB3k3EQnSR+ZKiUlA12AIAgg/ajVk=
+4
View File
@@ -0,0 +1,4 @@
@./AGENTS.md
## Gemini CLI Adapter
- Shared repository instructions live in `AGENTS.md`; update that file instead of duplicating rules here.
+202
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
DEVELOPMENT_TEAM =
BUNDLE_IDENTIFIER_PREFIX = com.example
+149
View File
@@ -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:<owner>/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)
@@ -0,0 +1,3 @@
import ActivityBarPlugin
private let activityBarPluginFactoryAnchor: Any.Type = ActivityBarPluginFactory.self
File diff suppressed because it is too large Load Diff
@@ -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..<boundedCount).reversed().map { offset in
let date = calendar.date(byAdding: .day, value: -offset, to: end) ?? end
let key = dateKey(for: date)
return stats(for: key)
}
}
func handleEvent(_ event: ActivityBarHookEvent) {
let now = dateProvider()
let project = projectName(from: event.cwd)
let sessionID = event.sessionID.isEmpty ? "unknown" : event.sessionID
let tool = ActivityBarCodingTool.displayName(forSessionID: sessionID)
closeElapsedTime(for: sessionID, now: now)
if let prompt = event.userPrompt, event.event == .userPromptSubmit {
addWords(countWords(prompt), project: project, tool: tool)
}
if event.event == .preToolUse {
addToolCall(project: project, tool: tool)
}
switch event.event {
case .sessionEnd:
activeSessions.removeValue(forKey: sessionID)
default:
activeSessions[sessionID] = ActiveSession(
project: project,
tool: tool,
status: event.status,
startedAt: activeSessions[sessionID]?.startedAt ?? now,
lastUpdatedAt: now
)
}
activeSessionCount = activeSessions.count
persist()
}
func flushActiveDurations() {
let now = dateProvider()
for sessionID in activeSessions.keys {
closeElapsedTime(for: sessionID, now: now)
activeSessions[sessionID]?.lastUpdatedAt = now
}
persist()
}
func resetToday() {
days[dateKey(for: dateProvider())] = ActivityBarCodingDailyStats(date: dateKey(for: dateProvider()))
persist()
}
private func closeElapsedTime(for sessionID: String, now: Date) {
guard var session = activeSessions[sessionID] else {
return
}
let elapsed = now.timeIntervalSince(session.lastUpdatedAt)
if elapsed > 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 [:]
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<Void, Never>?
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())
}
}
@@ -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"
"""
}
}
@@ -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<CGEvent>? in
guard let userInfo else {
return Unmanaged.passUnretained(event)
}
let monitor = Unmanaged<ActivityBarInputMonitor>
.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
}
}
@@ -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
)
}
}
@@ -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"
}
}
@@ -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
}
}
}
@@ -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..<pathBytes.count {
buffer[index] = UInt8(bitPattern: pathBytes[index])
}
}
let bindResult = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
bind(fileDescriptor, socketAddress, socklen_t(MemoryLayout<sockaddr_un>.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<sockaddr_un>.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<timeval>.size)
)
var data = Data()
let bufferSize = 8_192
let buffer = UnsafeMutablePointer<UInt8>.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<T>(_ body: () throws -> T) rethrows -> T {
lock()
defer {
unlock()
}
return try body()
}
}
@@ -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<Void, Never>?
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..<boundedCount).reversed().map { offset in
let date = calendar.date(byAdding: .day, value: -offset, to: end) ?? end
let key = dateKey(for: date)
return stats(for: key)
}
}
func incrementKeystroke(app: String) {
mutateToday(app: app) { day, appStats in
day.keystrokes += 1
appStats.keystrokes += 1
}
}
func incrementPointerClick(app: String) {
mutateToday(app: app) { day, appStats in
day.pointerClicks += 1
appStats.pointerClicks += 1
}
}
func incrementScroll(app: String) {
mutateToday(app: app) { day, appStats in
day.scrollEvents += 1
appStats.scrollEvents += 1
}
}
func addScreenTime(_ seconds: TimeInterval, app: String) {
guard seconds > 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) }
}
}
@@ -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.
@@ -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")
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
+67
View File
@@ -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 dactivité",
"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"
}
+3
View File
@@ -0,0 +1,3 @@
settings:
base:
OTHER_LDFLAGS: -framework ApplicationServices -framework Charts
@@ -0,0 +1,3 @@
import AppHotkeyPlugin
private let appHotkeyPluginFactoryAnchor: Any.Type = AppHotkeyPluginFactory.self
File diff suppressed because it is too large Load Diff
@@ -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<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr else { return status }
guard hotKeyID.signature == 0x4148_4B59 else {
return OSStatus(eventNotHandledErr)
}
let manager = Unmanaged<AppHotkeyManager>
.fromOpaque(userData)
.takeUnretainedValue()
Task { @MainActor in
manager.dispatch(carbonID: hotKeyID.id)
}
return noErr
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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 }
}
}
}
@@ -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)
}
}
@@ -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)
}
}
+67
View File
@@ -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 dapps",
"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"
}
@@ -0,0 +1,3 @@
import AppearancePlugin
private let appearancePluginFactoryAnchor: Any.Type = AppearancePluginFactory.self
@@ -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"
}
@@ -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?()
}
}
}
}
}
+67
View File
@@ -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 lapparence 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"
}
@@ -0,0 +1,3 @@
import AutoHideDockPlugin
private let autoHideDockPluginFactoryAnchor: Any.Type = AutoHideDockPluginFactory.self
@@ -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"
}
@@ -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
}
}
@@ -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)
}
}
+67
View File
@@ -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"
}
@@ -0,0 +1,3 @@
import AutoHideMenuBarPlugin
private let autoHideMenuBarPluginFactoryAnchor: Any.Type = AutoHideMenuBarPluginFactory.self
@@ -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"
}
@@ -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
}
}
}
@@ -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)
}
}
+67
View File
@@ -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"
}
@@ -0,0 +1,3 @@
import BatteryChargeLimitPlugin
private let batteryChargeLimitPluginFactoryAnchor: Any.Type = BatteryChargeLimitPluginFactory.self
File diff suppressed because it is too large Load Diff
@@ -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 [<pct>] 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 <KEY> Print the current value of a 1-byte SMC key
@@ -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<SMCParam>.size
let result = IOConnectCallStructMethod(
connection,
kernelIndexSMC,
&input,
MemoryLayout<SMCParam>.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<SMCParam>.size
let result = IOConnectCallStructMethod(
connection,
kernelIndexSMC,
&input,
MemoryLayout<SMCParam>.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<SMCParam>.size
let result = IOConnectCallStructMethod(
connection,
kernelIndexSMC,
&input,
MemoryLayout<SMCParam>.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<SMCParam>.size
let result = IOConnectCallStructMethod(
connection,
kernelIndexSMC,
&input,
MemoryLayout<SMCParam>.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 [<percent>]")
print(" \(program) resume")
print(" \(program) discharge on|off")
print(" \(program) read <KEY>")
}
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)
}
@@ -0,0 +1,6 @@
import Foundation
import MacToolsPluginKit
enum BatteryChargeLimitLocalization {
static let fallback = PluginLocalization(bundle: .main)
}
@@ -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")
}
@@ -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 (0100). 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?
}
@@ -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<Void, Never>?
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
}
}
@@ -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
)
}
}
@@ -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 充电控制键")
}
}
@@ -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)
}
}
@@ -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<String, BatteryChargeWriteError> {
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: "\\\"")
}
}
@@ -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
)
}
}
+72
View File
@@ -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"
}
+22
View File
@@ -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
@@ -0,0 +1,3 @@
import CalendarPlugin
private let calendarPluginFactoryAnchor: Any.Type = CalendarPluginFactory.self
@@ -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
}
}
File diff suppressed because it is too large Load Diff
@@ -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
}
}
@@ -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
)
}
}
@@ -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<Void, Never>?
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
}
}
@@ -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)
}
}
@@ -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)
)
}
}
@@ -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)]
}
}
@@ -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..<symbols.count] + symbols[0..<startIndex])
}
private func lunarText(for date: Date) -> 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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)))
}
}
@@ -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"
}
}
@@ -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)))
}
}
@@ -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)))
}
}
@@ -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) {}
}
+70
View File
@@ -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"
}
+16
View File
@@ -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"

Some files were not shown because too many files have changed in this diff Show More