This commit is contained in:
@@ -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
|
||||
@@ -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 }}."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}."
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -0,0 +1,129 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
Pending release notes live in `changes/unreleased/*.md` and are compiled during
|
||||
the app and plugin release processes.
|
||||
|
||||
## [v1.1.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- The plugin marketplace can sort by install status (not installed first, or installed first with updates prioritized) or by name (A–Z / Z–A).
|
||||
|
||||
### Changed
|
||||
|
||||
- Placed plugin-specific settings between permission and shortcut sections for a more natural configuration flow.
|
||||
- The in-app update dialog now includes plugin changes released since the previous app version.
|
||||
- Changing the app language now refreshes all Settings and plugin controls immediately without requiring a restart.
|
||||
- The language picker now shows each option in the system language alongside its native name.
|
||||
- New app versions use a PluginKit-versioned plugin catalog and update installed plugins before loading them, while older app versions continue using the legacy catalog.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Custom and gallery menu bar icons now use one standard height and content inset with consistent settings previews.
|
||||
|
||||
## [plugins-1.1.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Device Battery can show trusted iPhone, iPad, iPod touch, Vision Pro, and paired Apple Watch battery and charging status over USB or Wi-Fi.
|
||||
- Keep Awake can optionally keep the display on for every session through its plugin settings and shows an indicator while that mode is active.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refined the native window switcher overlay and added editable, stable single-key app shortcuts.
|
||||
- Device Battery now refreshes Apple mobile devices less often while the component panel is hidden.
|
||||
- All plugin packages are rebuilt for PluginKit 3 and published together in a versioned catalog so they remain compatible with the new app runtime.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Eject Disks no longer treats internal system or non-ejectable volumes as removable disks.
|
||||
- Translator source text no longer overlaps the copy and speech actions when the text spans multiple lines.
|
||||
|
||||
## [v1.0.31] - 2026-07-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the menu bar icon settings previews so uploaded and online icons are easier to inspect.
|
||||
|
||||
## [plugins-1.0.32] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- System Status now includes settings for arranging component-panel metrics and optional compact menu bar metrics.
|
||||
- Added Window Switcher for shortcut-driven window switching with direct cycling, key-based selection, configurable ordering, stable key hints, and hover quit buttons.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Activity Bar now includes Cursor coding activity in AI work summaries and coding tool trends.
|
||||
- Activity Bar now listens for installed AI hooks independently of Input Monitoring tracking.
|
||||
- Activity Bar can now uninstall the AI activity hooks it adds for Claude Code, Cursor, and Codex.
|
||||
- Battery Charge Limit now prefers the system charge ceiling path when available, uses the correct macOS 26 charging key, and verifies SMC writes to reduce USB-C display disconnects when charging stops at the limit.
|
||||
- System Status now reports total GPU usage from the accelerator's total activity metric instead of pipeline-specific counters, avoiding false 100% history samples.
|
||||
- Translator service order and enabled-state changes now persist immediately.
|
||||
|
||||
## [v1.0.30] - 2026-07-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduce unnecessary menu bar refresh work when plugins report state changes, keeping panels steadier during frequent updates.
|
||||
|
||||
## [plugins-1.0.31] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Add a Homebrew management plugin for browsing packages, installing and upgrading formulae, and running diagnostics from the menu bar and settings.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hide Menu Bar Icons no longer quits after Accessibility is granted before Screen Recording is approved.
|
||||
- Hide Menu Bar Icons now keeps the divider where you drag it instead of snapping it back next to the MacTools icon.
|
||||
- Keep Awake now restores permanent sessions after restarting MacTools.
|
||||
|
||||
## [v1.0.29] - 2026-07-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Kept panel position stable and feature panel height accurate as controls expand.
|
||||
|
||||
## [plugins-1.0.30] - 2026-07-05
|
||||
|
||||
### Changed
|
||||
|
||||
- Stop Battery Charge Limit background monitoring while the charging limit is disabled.
|
||||
- Pause Menu Bar Hidden background observers when the feature is hidden or not actively in use.
|
||||
- Reduce System Status background sampling to lower idle energy use while keeping foreground updates responsive.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent repeated low-battery notifications when device battery readings switch sources or briefly disappear.
|
||||
- Refresh Empty Trash availability when opening the feature panel.
|
||||
- Fixed Mouse Enhancer settings switches rendering incorrectly on first open.
|
||||
- Avoid administrator password prompts on quit when Fan Control or Battery Charge Limit did not apply SMC changes.
|
||||
|
||||
## [v1.0.28] - 2026-07-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Finder right-click menu items now stay hidden while the Right Click plugin is disabled.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- App release notes now come from concise changelog fragments that are compiled into CHANGELOG.md during release.
|
||||
|
||||
## [plugins-1.0.29] - 2026-07-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added Mouse Enhancer, a mouse and trackpad controls plugin with separate horizontal and vertical scroll reversing plus trackpad-tap middle-click simulation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Right Click now keeps Finder extension menu visibility aligned with the plugin's enabled state.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Plugin batch release notes now come from plugin changelog fragments compiled into CHANGELOG.md during release.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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。默认只构建和上传版本递增的插件,并将新条目合并进生产 catalog;catalog 私钥、Developer ID 证书和 GitHub token 必须来自 CI secrets 或本地环境变量。
|
||||
- GitHub Actions 自动构建与发布配置见 `docs/github-actions.md`;插件 catalog、包结构和批次发布流程见 `docs/plugins/plugin-catalog.md`。
|
||||
@@ -0,0 +1,2 @@
|
||||
#include "Debug.xcconfig"
|
||||
#include "AppVersion.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
#include "Release.xcconfig"
|
||||
#include "AppVersion.xcconfig"
|
||||
@@ -0,0 +1,2 @@
|
||||
MARKETING_VERSION = 1.1.0
|
||||
CURRENT_PROJECT_VERSION = 62
|
||||
@@ -0,0 +1,4 @@
|
||||
#include? "../LocalConfig.xcconfig"
|
||||
|
||||
SWIFT_STRICT_CONCURRENCY = complete
|
||||
PLUGIN_CATALOG_PUBLIC_KEY = V7knIDPAmvC9rhCB3k3EQnSR+ZKiUlA12AIAgg/ajVk=
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
#include? "../LocalConfig.xcconfig"
|
||||
|
||||
SWIFT_STRICT_CONCURRENCY = complete
|
||||
PLUGIN_CATALOG_PUBLIC_KEY = V7knIDPAmvC9rhCB3k3EQnSR+ZKiUlA12AIAgg/ajVk=
|
||||
@@ -0,0 +1,4 @@
|
||||
@./AGENTS.md
|
||||
|
||||
## Gemini CLI Adapter
|
||||
- Shared repository instructions live in `AGENTS.md`; update that file instead of duplicating rules here.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
DEVELOPMENT_TEAM =
|
||||
BUNDLE_IDENTIFIER_PREFIX = com.example
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "activity-bar",
|
||||
"displayName": "活动统计",
|
||||
"summary": "统计输入、前台应用使用时长和 AI 编程活动",
|
||||
"localizedMetadata": {
|
||||
"ar": {
|
||||
"displayName": "إحصاءات النشاط",
|
||||
"summary": "تتبع الإدخال ووقت استخدام التطبيق الأمامي ونشاط ترميز الذكاء الاصطناعي."
|
||||
},
|
||||
"de": {
|
||||
"displayName": "Aktivitätsstatistik",
|
||||
"summary": "Verfolgen Sie Eingaben, Nutzungsdauer der Vordergrund-App und KI-Codierungsaktivitäten."
|
||||
},
|
||||
"en": {
|
||||
"displayName": "Activity Stats",
|
||||
"summary": "Track input, foreground app usage time, and AI coding activity."
|
||||
},
|
||||
"es": {
|
||||
"displayName": "Estadísticas de actividad",
|
||||
"summary": "Seguimiento de la entrada, el tiempo de uso de la aplicación en primer plano y la actividad de codificación de IA."
|
||||
},
|
||||
"fr": {
|
||||
"displayName": "Statistiques d’activité",
|
||||
"summary": "Suivez les entrées, la durée d'utilisation de l'application au premier plan et l'activité de codage de l'IA."
|
||||
},
|
||||
"ja": {
|
||||
"displayName": "アクティビティ統計",
|
||||
"summary": "入力、フォアグラウンド アプリの使用時間、AI コーディング アクティビティを追跡します。"
|
||||
},
|
||||
"ko": {
|
||||
"displayName": "활동 통계",
|
||||
"summary": "입력, 포그라운드 앱 사용 시간 및 AI 코딩 활동을 추적합니다."
|
||||
},
|
||||
"pt": {
|
||||
"displayName": "Estatísticas de atividade",
|
||||
"summary": "Rastreie a entrada, o tempo de uso do aplicativo em primeiro plano e a atividade de codificação de IA."
|
||||
},
|
||||
"ru": {
|
||||
"displayName": "Статистика активности",
|
||||
"summary": "Отслеживайте ввод данных, время использования приложений на переднем плане и активность ИИ-кодирования."
|
||||
},
|
||||
"zh-Hans": {
|
||||
"displayName": "活动统计",
|
||||
"summary": "统计输入、前台应用使用时长和 AI 编程活动"
|
||||
},
|
||||
"zh-Hant": {
|
||||
"displayName": "活動統計",
|
||||
"summary": "統計輸入、前臺應用使用時長和 AI 編程活動"
|
||||
}
|
||||
},
|
||||
"version": "1.0.15",
|
||||
"minHostVersion": "1.0.2",
|
||||
"pluginKitVersion": 3,
|
||||
"bundleRelativePath": "ActivityBar.bundle",
|
||||
"factoryClass": "ActivityBarPlugin.ActivityBarPluginFactory",
|
||||
"build": {
|
||||
"project": "../../MacTools.xcodeproj",
|
||||
"scheme": "ActivityBarPlugin"
|
||||
},
|
||||
"capabilities": {
|
||||
"primaryPanel": true,
|
||||
"componentPanel": true,
|
||||
"configuration": false
|
||||
},
|
||||
"permissions": [],
|
||||
"category": "monitoring"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "app-hotkey",
|
||||
"displayName": "应用快捷键",
|
||||
"summary": "为常用应用绑定全局快捷键,快速打开或切换到前台",
|
||||
"localizedMetadata": {
|
||||
"ar": {
|
||||
"displayName": "اختصارات التطبيقات",
|
||||
"summary": "قم بربط الاختصارات العالمية بالتطبيقات الشائعة للتشغيل أو التبديل السريع."
|
||||
},
|
||||
"de": {
|
||||
"displayName": "App-Kurzbefehle",
|
||||
"summary": "Binden Sie globale Verknüpfungen an gängige Apps, um sie schnell zu starten oder zu wechseln."
|
||||
},
|
||||
"en": {
|
||||
"displayName": "App Hotkeys",
|
||||
"summary": "Bind global shortcuts to common apps for quick launch or switching."
|
||||
},
|
||||
"es": {
|
||||
"displayName": "Atajos de apps",
|
||||
"summary": "Vincule accesos directos globales a aplicaciones comunes para iniciar o cambiar rápidamente."
|
||||
},
|
||||
"fr": {
|
||||
"displayName": "Raccourcis d’apps",
|
||||
"summary": "Liez des raccourcis globaux aux applications courantes pour un lancement ou une commutation rapide."
|
||||
},
|
||||
"ja": {
|
||||
"displayName": "アプリショートカット",
|
||||
"summary": "グローバル ショートカットを一般的なアプリにバインドして、すばやく起動したり切り替えたりできます。"
|
||||
},
|
||||
"ko": {
|
||||
"displayName": "앱 단축키",
|
||||
"summary": "빠른 실행 또는 전환을 위해 일반 앱에 전역 바로가기를 바인딩합니다."
|
||||
},
|
||||
"pt": {
|
||||
"displayName": "Atalhos de apps",
|
||||
"summary": "Vincule atalhos globais a aplicativos comuns para inicialização ou troca rápida."
|
||||
},
|
||||
"ru": {
|
||||
"displayName": "Горячие клавиши приложений",
|
||||
"summary": "Привяжите глобальные ярлыки к общим приложениям для быстрого запуска или переключения."
|
||||
},
|
||||
"zh-Hans": {
|
||||
"displayName": "应用快捷键",
|
||||
"summary": "为常用应用绑定全局快捷键,快速打开或切换到前台"
|
||||
},
|
||||
"zh-Hant": {
|
||||
"displayName": "應用程式快速鍵",
|
||||
"summary": "為常用應用綁定全局快速鍵,快速打開或切換到前臺"
|
||||
}
|
||||
},
|
||||
"version": "1.0.8",
|
||||
"minHostVersion": "1.0.2",
|
||||
"pluginKitVersion": 3,
|
||||
"bundleRelativePath": "AppHotkey.bundle",
|
||||
"factoryClass": "AppHotkeyPlugin.AppHotkeyPluginFactory",
|
||||
"build": {
|
||||
"project": "../../MacTools.xcodeproj",
|
||||
"scheme": "AppHotkeyPlugin"
|
||||
},
|
||||
"capabilities": {
|
||||
"primaryPanel": true,
|
||||
"componentPanel": false,
|
||||
"configuration": true
|
||||
},
|
||||
"permissions": [],
|
||||
"category": "productivity"
|
||||
}
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "appearance",
|
||||
"displayName": "深色模式",
|
||||
"summary": "切换系统亮色与深色外观",
|
||||
"localizedMetadata": {
|
||||
"ar": {
|
||||
"displayName": "الوضع الداكن",
|
||||
"summary": "تبديل النظام بين المظهر الفاتح والداكن."
|
||||
},
|
||||
"de": {
|
||||
"displayName": "Dunkelmodus",
|
||||
"summary": "Schalten Sie das System zwischen heller und dunkler Darstellung um."
|
||||
},
|
||||
"en": {
|
||||
"displayName": "Dark Mode",
|
||||
"summary": "Switch the system between light and dark appearance."
|
||||
},
|
||||
"es": {
|
||||
"displayName": "Modo oscuro",
|
||||
"summary": "Cambie el sistema entre apariencia clara y oscura."
|
||||
},
|
||||
"fr": {
|
||||
"displayName": "Mode sombre",
|
||||
"summary": "Basculez le système entre l’apparence claire et sombre."
|
||||
},
|
||||
"ja": {
|
||||
"displayName": "ダークモード",
|
||||
"summary": "システムの外観を明暗で切り替えます。"
|
||||
},
|
||||
"ko": {
|
||||
"displayName": "다크 모드",
|
||||
"summary": "밝은 화면과 어두운 화면 간에 시스템을 전환합니다."
|
||||
},
|
||||
"pt": {
|
||||
"displayName": "Modo escuro",
|
||||
"summary": "Alterne o sistema entre aparência clara e escura."
|
||||
},
|
||||
"ru": {
|
||||
"displayName": "Тёмный режим",
|
||||
"summary": "Переключение системы между светлым и темным внешним видом."
|
||||
},
|
||||
"zh-Hans": {
|
||||
"displayName": "深色模式",
|
||||
"summary": "切换系统亮色与深色外观"
|
||||
},
|
||||
"zh-Hant": {
|
||||
"displayName": "深色模式",
|
||||
"summary": "切換系統亮色與深色外觀"
|
||||
}
|
||||
},
|
||||
"version": "1.0.7",
|
||||
"minHostVersion": "1.0.2",
|
||||
"pluginKitVersion": 3,
|
||||
"bundleRelativePath": "Appearance.bundle",
|
||||
"factoryClass": "AppearancePlugin.AppearancePluginFactory",
|
||||
"build": {
|
||||
"project": "../../MacTools.xcodeproj",
|
||||
"scheme": "AppearancePlugin"
|
||||
},
|
||||
"capabilities": {
|
||||
"primaryPanel": true,
|
||||
"componentPanel": false,
|
||||
"configuration": false
|
||||
},
|
||||
"permissions": [],
|
||||
"category": "display"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (0–100). nil when unavailable.
|
||||
var levelPercent: Int?
|
||||
var state: BatteryPowerState
|
||||
/// True when the AC adapter is connected (drawing external power).
|
||||
var isOnAdapter: Bool
|
||||
|
||||
var hasBattery: Bool { isAvailable }
|
||||
|
||||
static let empty = BatterySnapshot(
|
||||
isAvailable: false,
|
||||
levelPercent: nil,
|
||||
state: .unavailable,
|
||||
isOnAdapter: false
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Write Errors
|
||||
|
||||
enum BatteryChargeWriteError: Error, LocalizedError, Equatable {
|
||||
case helperNotFound
|
||||
case helperInstallFailed(BatteryChargeHelperInstallFailure)
|
||||
case helperVerificationFailed
|
||||
case noSupportedSMCKey
|
||||
case writeFailed(BatteryChargeWriteFailure)
|
||||
|
||||
var errorDescription: String? {
|
||||
localizedDescription(localization: BatteryChargeLimitLocalization.fallback)
|
||||
}
|
||||
|
||||
func localizedDescription(localization: PluginLocalization) -> String {
|
||||
switch self {
|
||||
case .helperNotFound:
|
||||
return localization.string(
|
||||
"error.helperNotFound",
|
||||
defaultValue: "未找到内置电池控制组件。请重新安装电池充电上限插件。"
|
||||
)
|
||||
case .helperInstallFailed(let failure):
|
||||
return localization.format(
|
||||
"error.helperInstallFailed",
|
||||
defaultValue: "安装电池控制组件失败:%@",
|
||||
failure.localizedDescription(localization: localization)
|
||||
)
|
||||
case .helperVerificationFailed:
|
||||
return localization.string(
|
||||
"error.helperVerificationFailed",
|
||||
defaultValue: "电池控制组件校验失败。请重新安装电池充电上限插件。"
|
||||
)
|
||||
case .noSupportedSMCKey:
|
||||
return localization.string(
|
||||
"error.noSupportedSMCKey",
|
||||
defaultValue: "当前 Mac 固件未提供可写的充电控制键。可能是 macOS 已升级到不支持的版本。"
|
||||
)
|
||||
case .writeFailed(let failure):
|
||||
return localization.format(
|
||||
"error.writeFailed",
|
||||
defaultValue: "写入充电控制失败:%@",
|
||||
failure.localizedDescription(localization: localization)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BatteryChargeHelperInstallFailure: Equatable {
|
||||
case missingAfterInstall
|
||||
case authorizationScriptUnavailable
|
||||
case systemMessage(String)
|
||||
|
||||
func localizedDescription(localization: PluginLocalization) -> String {
|
||||
switch self {
|
||||
case .missingAfterInstall:
|
||||
localization.string("error.helperInstall.missingAfterInstall", defaultValue: "安装后仍无法找到组件")
|
||||
case .authorizationScriptUnavailable:
|
||||
localization.string("error.helperInstall.authorizationScriptUnavailable", defaultValue: "无法创建授权脚本")
|
||||
case .systemMessage(let message):
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BatteryChargeWriteFailure: Equatable {
|
||||
case inhibitCharging
|
||||
case resumeCharging
|
||||
case toggleDischarge
|
||||
|
||||
func localizedDescription(localization: PluginLocalization) -> String {
|
||||
switch self {
|
||||
case .inhibitCharging:
|
||||
localization.string("error.write.inhibitCharging", defaultValue: "无法停止充电")
|
||||
case .resumeCharging:
|
||||
localization.string("error.write.resumeCharging", defaultValue: "无法恢复充电")
|
||||
case .toggleDischarge:
|
||||
localization.string("error.write.toggleDischarge", defaultValue: "无法切换放电模式")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SMC Protocols (for dependency injection / tests)
|
||||
|
||||
protocol BatteryChargeLimitReading: AnyObject {
|
||||
@MainActor func readSnapshot() -> BatterySnapshot
|
||||
}
|
||||
|
||||
protocol BatteryChargeLimitWriting: AnyObject {
|
||||
@MainActor var isHelperAvailable: Bool { get }
|
||||
@MainActor var isInstalledHelperAvailable: Bool { get }
|
||||
@MainActor func probeCapabilities() -> BatterySMCCapabilities
|
||||
@MainActor @discardableResult func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError?
|
||||
@MainActor @discardableResult func resumeCharging() -> BatteryChargeWriteError?
|
||||
@MainActor @discardableResult func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError?
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user