Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ jobs:
done
fi

HOST_IDENTIFIER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Contents/Info.plist")"
codesign --force --sign - --identifier "$HOST_IDENTIFIER.cli-broker" "$APP_PATH/Contents/MacOS/MacToolsCLIBroker"

codesign --force --deep --sign - --entitlements Configs/MacTools.entitlements "$APP_PATH"
codesign --verify --deep --strict "$APP_PATH"

Expand Down Expand Up @@ -143,7 +146,10 @@ jobs:
chmod +x "$ARTIFACT_ROOT/run-debug.sh"

cat > "$ARTIFACT_ROOT/README.txt" <<'EOF'
This is an unsigned Debug build for local testing.
This is an ad-hoc signed Debug build for local testing.

This artifact intentionally omits the standalone CLI. An ad-hoc signature
has no Apple Team Identifier, so it cannot satisfy the CLI trust boundary.

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:
Expand Down
109 changes: 104 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ jobs:
echo "PRERELEASE=$PRERELEASE"
echo "DMG_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.dmg"
echo "SHA256_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.sha256"
echo "CLI_ARCHIVE_PATH=${ARTIFACT_DIR}/${TAG}/mactools-cli-${VERSION}-macos-universal.zip"
echo "CLI_SHA256_PATH=${ARTIFACT_DIR}/${TAG}/mactools-cli-${VERSION}-macos-universal.sha256"
} >> "$GITHUB_ENV"

- name: Verify production plugin catalog
Expand Down Expand Up @@ -203,6 +205,43 @@ jobs:
"$1"
}

sign_path_with_identifier() {
/usr/bin/codesign \
--force \
--sign "$SIGNING_IDENTITY" \
--keychain "$KEYCHAIN_PATH" \
--options runtime \
--timestamp \
--identifier "$2" \
"$1"
}

signing_detail() {
/usr/bin/codesign -dvvv "$1" 2>&1 \
| awk -F= -v key="$2" '$1 == key { print substr($0, length(key) + 2); exit }'
}

validate_cli_role_signature() {
local details
local actual_identifier
local actual_team
details="$(/usr/bin/codesign -dvvv "$1" 2>&1)"
actual_identifier="$(signing_detail "$1" Identifier)"
actual_team="$(signing_detail "$1" TeamIdentifier)"
[[ "$actual_identifier" == "$2" ]] || {
echo "Signing identifier mismatch for $1: expected $2, got $actual_identifier" >&2
exit 1
}
[[ -n "$3" && "$actual_team" == "$3" ]] || {
echo "Team Identifier mismatch for $1" >&2
exit 1
}
[[ "$details" == *"runtime"* ]] || {
echo "Hardened runtime is missing for $1" >&2
exit 1
}
}

sign_path_with_entitlements() {
/usr/bin/codesign \
--force \
Expand All @@ -225,6 +264,14 @@ jobs:
"$1"
}

CLI_PATH="${DERIVED_DATA}/Build/Products/Release/MacToolsCLI"
BROKER_PATH="$APP_PATH/Contents/MacOS/MacToolsCLIBroker"
HOST_IDENTIFIER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Contents/Info.plist")"
[[ -x "$CLI_PATH" ]] || { echo "Standalone CLI is missing" >&2; exit 1; }
[[ -x "$BROKER_PATH" ]] || { echo "CLI broker is missing" >&2; exit 1; }
sign_path_with_identifier "$CLI_PATH" "$HOST_IDENTIFIER.cli"
sign_path_with_identifier "$BROKER_PATH" "$HOST_IDENTIFIER.cli-broker"

if [[ -d "$SPARKLE_FRAMEWORK" ]]; then
if [[ -d "$SPARKLE_CURRENT/XPCServices/Installer.xpc" ]]; then
sign_path "$SPARKLE_CURRENT/XPCServices/Installer.xpc"
Expand Down Expand Up @@ -287,6 +334,23 @@ jobs:
fi

/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
/usr/bin/codesign --verify --strict --verbose=2 "$CLI_PATH"
/usr/bin/codesign --verify --strict --verbose=2 "$BROKER_PATH"
HOST_TEAM="$(signing_detail "$APP_PATH" TeamIdentifier)"
validate_cli_role_signature "$CLI_PATH" "$HOST_IDENTIFIER.cli" "$HOST_TEAM"
validate_cli_role_signature "$BROKER_PATH" "$HOST_IDENTIFIER.cli-broker" "$HOST_TEAM"

- name: Package standalone CLI
run: |
scripts/package-cli.sh \
--binary "${DERIVED_DATA}/Build/Products/Release/MacToolsCLI" \
--output "$CLI_ARCHIVE_PATH"

ARCHIVE_LIST="$(unzip -Z1 "$CLI_ARCHIVE_PATH")"
[[ "$ARCHIVE_LIST" == "mactools" ]] || {
echo "Unexpected standalone CLI archive contents: $ARCHIVE_LIST" >&2
exit 1
}

- name: Create and sign DMG
env:
Expand Down Expand Up @@ -320,7 +384,15 @@ jobs:

/usr/bin/codesign --verify --verbose=2 "$DMG_PATH"

- name: Notarize and staple DMG
- name: Validate signed app and standalone CLI artifacts
run: |
scripts/validate-release-artifacts.sh \
--cli-archive "$CLI_ARCHIVE_PATH" \
--dmg "$DMG_PATH" \
--version "$VERSION" \
--build "$BUILD_NUMBER"

- name: Notarize app and standalone CLI
env:
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
Expand All @@ -336,13 +408,28 @@ jobs:
--wait \
--timeout 30m

xcrun notarytool submit "$CLI_ARCHIVE_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"
scripts/validate-release-artifacts.sh \
--cli-archive "$CLI_ARCHIVE_PATH" \
--dmg "$DMG_PATH" \
--version "$VERSION" \
--build "$BUILD_NUMBER" \
--gatekeeper
rm -f "$API_KEY_PATH"

- name: Compute SHA256
run: |
shasum -a 256 "$DMG_PATH" | tee "$SHA256_PATH"
scripts/write-sha256.sh --artifact "$DMG_PATH" --output "$SHA256_PATH"
scripts/write-sha256.sh --artifact "$CLI_ARCHIVE_PATH" --output "$CLI_SHA256_PATH"
cat "$SHA256_PATH"
cat "$CLI_SHA256_PATH"

- name: Extract release notes from CHANGELOG
run: |
Expand Down Expand Up @@ -472,6 +559,8 @@ jobs:
path: |
${{ env.DMG_PATH }}
${{ env.SHA256_PATH }}
${{ env.CLI_ARCHIVE_PATH }}
${{ env.CLI_SHA256_PATH }}
if-no-files-found: error
retention-days: 30

Expand All @@ -487,10 +576,20 @@ jobs:
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 upload "$TAG" \
"$DMG_PATH#${PROJECT_NAME}.dmg" \
"$SHA256_PATH#${PROJECT_NAME}.sha256" \
"$CLI_ARCHIVE_PATH" \
"$CLI_SHA256_PATH" \
--clobber
gh release edit "$TAG" "${RELEASE_ARGS[@]}"
else
gh release create "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" "${RELEASE_ARGS[@]}"
gh release create "$TAG" \
"$DMG_PATH#${PROJECT_NAME}.dmg" \
"$SHA256_PATH#${PROJECT_NAME}.sha256" \
"$CLI_ARCHIVE_PATH" \
"$CLI_SHA256_PATH" \
"${RELEASE_ARGS[@]}"
fi

- name: Commit app release metadata to main
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,6 @@ Thanks for your interest in MacTools. Please keep each contribution small and cl
- 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, plugins with bumped versions are built and uploaded, then merged into that line's catalog. Changes under `Sources/MacToolsPluginKit/` require rebuilding and bumping every plugin so the catalog cannot retain binaries linked against an older shared framework. The standard `make release` flow performs these manifest bumps in the release commit; feature PRs should not pre-bump unrelated plugins. The first release of a new ABI also rebuilds every plugin and writes a versioned catalog. MacTools through 1.1.6 keeps reading the immutable PluginKit v4 catalog at `docs/plugins/v4/catalog.json`; MacTools 1.2 and later use PluginKit v5 at `docs/plugins/v5/catalog.json`. Publish the v5 plugin batch and catalog first, wait for Pages to serve the committed signed catalog, and only then prepare or publish the 1.2 app. The app release helper and final release workflow fail closed unless that deployed catalog exactly matches the committed catalog and has a valid signature. The catalog private key, Developer ID certificate, and GitHub token must come from CI secrets or local environment variables.
- Local production builds can still use the lower-level script: `./scripts/release-local.sh`; it produces both `MacTools.dmg` and the version-matched standalone `mactools-cli` archive. Before publishing both assets to GitHub Releases, run `gh auth login`, then `./scripts/release-local.sh --publish`.
- Plugin library releases are triggered by `plugins-*` batch tags through the `Plugin Release` workflow. Within one PluginKit ABI line, plugins with bumped versions are built and uploaded, then merged into that line's catalog. Changes under `Sources/MacToolsPluginKit/` require rebuilding and bumping every plugin so the catalog cannot retain binaries linked against an older shared framework. The standard `make release` flow performs these manifest bumps in the release commit; feature PRs should not pre-bump unrelated plugins. The first release of a new ABI also rebuilds every plugin and writes a versioned catalog. PluginKit v4 and v5 catalogs remain immutable compatibility lines; the CLI-capable host and rebuilt plugins use `docs/plugins/v6/catalog.json`. Publish the v6 plugin batch and catalog first, wait for Pages to serve the committed signed catalog, and only then prepare or publish its app release. The app release helper and final release workflow fail closed unless that deployed catalog exactly matches the committed catalog and has a valid signature. The catalog private key, Developer ID certificate, and GitHub token must come from CI secrets or local environment variables.
- GitHub Actions build and release configuration is documented in `docs/github-actions.md`; plugin catalog, package structure, and batch release flows are documented in `docs/plugins/plugin-catalog.md`.
17 changes: 17 additions & 0 deletions Configs/MacToolsCLIBroker-LaunchAgent.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?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>Label</key>
<string>__MACTOOLS_CLI_SERVICE_NAME__</string>
<key>BundleProgram</key>
<string>Contents/MacOS/MacToolsCLIBroker</string>
<key>MachServices</key>
<dict>
<key>__MACTOOLS_CLI_SERVICE_NAME__</key>
<true/>
</dict>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
26 changes: 23 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ 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_CLI_PATH := $(DEBUG_BUILD_PRODUCTS_DIR)/MacToolsCLI
CLI_PACKAGE_PATH ?= build/CLI/mactools-cli-debug-macos-$(HOST_ARCH).zip
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
Expand All @@ -37,14 +39,14 @@ PLUGIN_RELEASE_ASSETS_DIR ?= $(PLUGIN_RELEASE_DIST_DIR)/Assets
PLUGIN_RELEASE_CATALOG ?= $(PLUGIN_RELEASE_DIST_DIR)/catalog.json
PLUGIN_KIT_VERSION ?= $(shell $(PYTHON3) -c 'import glob,json; versions={json.load(open(path, encoding="utf-8"))["pluginKitVersion"] for path in glob.glob("Plugins/*/plugin.json")}; print(next(iter(versions)) if len(versions) == 1 else "")')
PLUGIN_RELEASE_SIGNED_CATALOG ?= $(if $(filter 2,$(PLUGIN_KIT_VERSION)),docs/plugins/catalog.json,docs/plugins/v$(PLUGIN_KIT_VERSION)/catalog.json)
PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= $(if $(filter 5,$(PLUGIN_KIT_VERSION)),1.2.0,1.1.6)
PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= $(if $(filter 5 6,$(PLUGIN_KIT_VERSION)),1.2.0,1.1.6)
PLUGIN_RELEASE_BASE_URL ?= https://github.com/$(PLUGIN_RELEASE_REPO)/releases/download/$(PLUGIN_RELEASE_TAG)
E2E_SCRIPT := scripts/e2e/mactools-e2e.sh
E2E_SESSION ?=
E2E_DURATION ?= 90
E2E_PACK ?=

.PHONY: setup validate-local-debug-config generate-plugin-config generate build script-tests ci sync-debug-plugins build-plugin build-plugins generate-icon-gallery package-plugins-release stop-debug-app install-debug-app run run-open e2e-preflight e2e-prepare e2e-upgrade e2e-reseed e2e-resume e2e-rebuild e2e-audit e2e-scenarios e2e-record e2e-record-pack e2e-verify-code e2e-collect e2e-restore e2e-self-test clean release release-local
.PHONY: setup validate-local-debug-config generate-plugin-config generate build build-cli package-cli script-tests ci sync-debug-plugins build-plugin build-plugins generate-icon-gallery package-plugins-release stop-debug-app install-debug-app run run-open e2e-preflight e2e-prepare e2e-upgrade e2e-reseed e2e-resume e2e-rebuild e2e-audit e2e-scenarios e2e-record e2e-record-pack e2e-verify-code e2e-collect e2e-restore e2e-self-test clean release release-local

setup:
@if [ ! -f LocalConfig.xcconfig ]; then cp LocalConfig.sample.xcconfig LocalConfig.xcconfig; fi
Expand Down Expand Up @@ -76,6 +78,17 @@ build: validate-local-debug-config generate
"$$LSREGISTER" -u "$(CURDIR)/$(APP_PATH)" >/dev/null 2>&1 || true; \
fi

build-cli: validate-local-debug-config generate
@echo "Building standalone Debug CLI..."
@mkdir -p build
@touch build/.metadata_never_index
@$(XCODEBUILD) -project $(PROJECT_FILE) -scheme MacToolsCLI -configuration Debug -destination "$(BUILD_DESTINATION)" -derivedDataPath $(DERIVED_DATA) build -quiet
@echo "CLI ready: $(abspath $(DEBUG_CLI_PATH))"

package-cli: build-cli
@./scripts/package-cli.sh --binary "$(DEBUG_CLI_PATH)" --output "$(CLI_PACKAGE_PATH)"
@echo "CLI package ready: $(abspath $(CLI_PACKAGE_PATH))"

script-tests:
@$(PYTHON3) -m unittest discover -s scripts/tests -p 'test_*.py'

Expand Down Expand Up @@ -146,8 +159,15 @@ package-plugins-release: generate
# sanitizer, and other DerivedData copies. Use ALLOW_MULTIPLE_DEBUG_APPS=1
# only when a deliberately isolated bundle needs to coexist.
stop-debug-app:
@if [[ "$(ALLOW_MULTIPLE_DEBUG_APPS)" == "1" ]]; then exit 0; fi
@PIDS=(); \
if [[ "$(ALLOW_MULTIPLE_DEBUG_APPS)" == "1" ]]; then exit 0; fi; \
INFO_PLIST="$(INSTALLED_APP_PATH)/Contents/Info.plist"; \
if [[ -f "$$INFO_PLIST" ]]; then \
BUNDLE_IDENTIFIER="$$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$$INFO_PLIST" 2>/dev/null || true)"; \
if [[ -n "$$BUNDLE_IDENTIFIER" ]]; then \
/bin/launchctl kill TERM "gui/$$(/usr/bin/id -u)/$$BUNDLE_IDENTIFIER.cli-broker" >/dev/null 2>&1 || true; \
fi; \
fi; \
while read -r PID COMMAND; do \
if [[ "$$COMMAND" == "$(INSTALLED_APP_EXECUTABLE)" \
|| "$$COMMAND" == "$(INSTALLED_APP_EXECUTABLE) "* ]]; then \
Expand Down
8 changes: 5 additions & 3 deletions Plugins/ActionGrid/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
"summary": "在指標附近開啟使用者設定的常用操作網格。"
}
},
"version": "1.0.0",
"version": "1.0.1",
"minHostVersion": "1.2.0",
"pluginKitVersion": 5,
"pluginKitVersion": 6,
"bundleRelativePath": "ActionGrid.bundle",
"factoryClass": "ActionGridPlugin.ActionGridPluginFactory",
"build": {
Expand All @@ -62,6 +62,8 @@
"componentPanel": false,
"settings": "workspace"
},
"permissions": [],
"permissions": [

],
"category": "system"
}
8 changes: 5 additions & 3 deletions Plugins/ActivityBar/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
"summary": "統計輸入、前臺應用使用時長和 AI 編程活動"
}
},
"version": "1.1.0",
"version": "1.1.1",
"minHostVersion": "1.2.0",
"pluginKitVersion": 5,
"pluginKitVersion": 6,
"bundleRelativePath": "ActivityBar.bundle",
"factoryClass": "ActivityBarPlugin.ActivityBarPluginFactory",
"build": {
Expand All @@ -62,6 +62,8 @@
"componentPanel": true,
"settings": "form"
},
"permissions": [],
"permissions": [

],
"category": "monitoring"
}
8 changes: 5 additions & 3 deletions Plugins/AppHotkey/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
"summary": "為常用應用綁定全局快速鍵,快速打開或切換到前臺"
}
},
"version": "1.1.0",
"version": "1.1.1",
"minHostVersion": "1.2.0",
"pluginKitVersion": 5,
"pluginKitVersion": 6,
"bundleRelativePath": "AppHotkey.bundle",
"factoryClass": "AppHotkeyPlugin.AppHotkeyPluginFactory",
"build": {
Expand All @@ -62,6 +62,8 @@
"componentPanel": false,
"settings": "form"
},
"permissions": [],
"permissions": [

],
"category": "productivity"
}
4 changes: 2 additions & 2 deletions Plugins/AppVolume/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
"summary": "分別調整正在播放音訊的應用程式音量"
}
},
"version": "1.1.0",
"version": "1.1.1",
"minHostVersion": "1.2.0",
"pluginKitVersion": 5,
"pluginKitVersion": 6,
"bundleRelativePath": "AppVolume.bundle",
"factoryClass": "AppVolumePlugin.AppVolumePluginFactory",
"build": {
Expand Down
8 changes: 5 additions & 3 deletions Plugins/Appearance/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@
"summary": "切換系統亮色與深色外觀"
}
},
"version": "1.1.0",
"version": "1.1.1",
"minHostVersion": "1.2.0",
"pluginKitVersion": 5,
"pluginKitVersion": 6,
"bundleRelativePath": "Appearance.bundle",
"factoryClass": "AppearancePlugin.AppearancePluginFactory",
"build": {
Expand All @@ -62,6 +62,8 @@
"componentPanel": false,
"settings": "none"
},
"permissions": [],
"permissions": [

],
"category": "display"
}
4 changes: 4 additions & 0 deletions Plugins/AppleShortcuts/Sources/AppleShortcutsController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ final class AppleShortcutsController: ObservableObject {
)
}

func waitForLibraryRefresh() async {
await refreshTask?.value
}

/// Requests folders and visual metadata only while the Apple Shortcuts settings workspace is visible.
func refreshForSettings(force: Bool = true) {
guard refreshTask == nil else {
Expand Down
Loading
Loading