diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c66c2d0..5fb20ee4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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" @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffb18c12..920bd882 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 \ @@ -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" @@ -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: @@ -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 }} @@ -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: | @@ -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 @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cce14c04..f4d90370 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. diff --git a/Configs/MacToolsCLIBroker-LaunchAgent.plist b/Configs/MacToolsCLIBroker-LaunchAgent.plist new file mode 100644 index 00000000..00b0dfaf --- /dev/null +++ b/Configs/MacToolsCLIBroker-LaunchAgent.plist @@ -0,0 +1,17 @@ + + + + + Label + __MACTOOLS_CLI_SERVICE_NAME__ + BundleProgram + Contents/MacOS/MacToolsCLIBroker + MachServices + + __MACTOOLS_CLI_SERVICE_NAME__ + + + ProcessType + Interactive + + diff --git a/Makefile b/Makefile index 80759148..6de7faea 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 @@ -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' @@ -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 \ diff --git a/Plugins/ActionGrid/plugin.json b/Plugins/ActionGrid/plugin.json index b30b3dfc..13585522 100644 --- a/Plugins/ActionGrid/plugin.json +++ b/Plugins/ActionGrid/plugin.json @@ -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": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/ActivityBar/plugin.json b/Plugins/ActivityBar/plugin.json index 43a953f6..2e21bc79 100644 --- a/Plugins/ActivityBar/plugin.json +++ b/Plugins/ActivityBar/plugin.json @@ -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": { @@ -62,6 +62,8 @@ "componentPanel": true, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "monitoring" } diff --git a/Plugins/AppHotkey/plugin.json b/Plugins/AppHotkey/plugin.json index 1e246279..bd4fb128 100644 --- a/Plugins/AppHotkey/plugin.json +++ b/Plugins/AppHotkey/plugin.json @@ -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": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/AppVolume/plugin.json b/Plugins/AppVolume/plugin.json index eee12ee0..24506ac1 100644 --- a/Plugins/AppVolume/plugin.json +++ b/Plugins/AppVolume/plugin.json @@ -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": { diff --git a/Plugins/Appearance/plugin.json b/Plugins/Appearance/plugin.json index be9cbeb3..20e59b1c 100644 --- a/Plugins/Appearance/plugin.json +++ b/Plugins/Appearance/plugin.json @@ -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": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/AppleShortcuts/Sources/AppleShortcutsController.swift b/Plugins/AppleShortcuts/Sources/AppleShortcutsController.swift index e1886887..cbeaba80 100644 --- a/Plugins/AppleShortcuts/Sources/AppleShortcutsController.swift +++ b/Plugins/AppleShortcuts/Sources/AppleShortcutsController.swift @@ -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 { diff --git a/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift b/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift index e8147b48..5865c85d 100644 --- a/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift +++ b/Plugins/AppleShortcuts/Sources/AppleShortcutsPlugin.swift @@ -22,6 +22,7 @@ private struct AppleShortcutsPluginProvider: PluginProvider { final class AppleShortcutsPlugin: MacToolsPlugin, PluginActionProviding, + PluginActionCatalogPreparing, PluginPortablePreferencesProviding, PluginPersistentPreferencesChangeSignaling, PluginPortablePreferencesRestorationReporting, @@ -229,6 +230,10 @@ final class AppleShortcutsPlugin: func activate(context _: PluginRuntimeContext) { controller.activate() } func refresh() { controller.refreshIfNeeded() } + func prepareActionCatalogForExternalDiscovery() async { + controller.refreshIfNeeded() + await controller.waitForLibraryRefresh() + } func deactivate(reason _: PluginDeactivationReason) { controller.deactivate() } func makePortablePreferencesBackup() -> Data? { store.portableBackup() } diff --git a/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift b/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift index e38b22dd..c2a6a203 100644 --- a/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift +++ b/Plugins/AppleShortcuts/Tests/AppleShortcutsPluginTests.swift @@ -5,6 +5,31 @@ import XCTest @MainActor final class AppleShortcutsPluginTests: XCTestCase { + func testExternalDiscoveryPreparationWaitsForInitialLibraryRefresh() async { + let shortcut = AppleShortcutItem(id: UUID(), name: "Prepared") + let runner = AppleShortcutsRunnerStub( + shortcuts: [shortcut], + delay: .milliseconds(100) + ) + let plugin = makePlugin(runner: runner) + plugin.activate(context: PluginRuntimeContext( + pluginID: "apple-shortcuts", + storage: AppleShortcutsTestStorage() + )) + + let preparation = Task { @MainActor in + await plugin.prepareActionCatalogForExternalDiscovery() + } + await Task.yield() + XCTAssertTrue(plugin.actionDefinitions.isEmpty) + + await preparation.value + + XCTAssertEqual(plugin.actionDefinitions.map(\.title), ["Prepared"]) + let listCallCount = await runner.listCallCount + XCTAssertEqual(listCallCount, 1) + } + func testPublishesEveryDiscoveredShortcutAcrossRename() async throws { let id = UUID() let runner = AppleShortcutsRunnerStub(shortcuts: [AppleShortcutItem(id: id, name: "Old")]) diff --git a/Plugins/AppleShortcuts/plugin.json b/Plugins/AppleShortcuts/plugin.json index ffb837db..d4954832 100644 --- a/Plugins/AppleShortcuts/plugin.json +++ b/Plugins/AppleShortcuts/plugin.json @@ -3,21 +3,54 @@ "displayName": "Apple 快捷指令", "summary": "发现并运行现有的 Apple 快捷指令。", "localizedMetadata": { - "ar": { "displayName": "اختصارات Apple", "summary": "اكتشف اختصارات Apple الحالية وشغّلها." }, - "de": { "displayName": "Apple-Kurzbefehle", "summary": "Vorhandene Apple-Kurzbefehle entdecken und ausführen." }, - "en": { "displayName": "Apple Shortcuts", "summary": "Discover and run existing Apple Shortcuts." }, - "es": { "displayName": "Atajos de Apple", "summary": "Descubre y ejecuta atajos de Apple existentes." }, - "fr": { "displayName": "Raccourcis Apple", "summary": "Découvrez et exécutez vos raccourcis Apple." }, - "ja": { "displayName": "Appleショートカット", "summary": "既存のAppleショートカットを見つけて実行します。" }, - "ko": { "displayName": "Apple 단축어", "summary": "기존 Apple 단축어를 찾아 실행합니다." }, - "pt": { "displayName": "Atalhos da Apple", "summary": "Descubra e execute atalhos existentes da Apple." }, - "ru": { "displayName": "Быстрые команды Apple", "summary": "Находите и запускайте существующие быстрые команды Apple." }, - "zh-Hans": { "displayName": "Apple 快捷指令", "summary": "发现并运行现有的 Apple 快捷指令。" }, - "zh-Hant": { "displayName": "Apple 捷徑", "summary": "探索並執行現有的 Apple 捷徑。" } + "ar": { + "displayName": "اختصارات Apple", + "summary": "اكتشف اختصارات Apple الحالية وشغّلها." + }, + "de": { + "displayName": "Apple-Kurzbefehle", + "summary": "Vorhandene Apple-Kurzbefehle entdecken und ausführen." + }, + "en": { + "displayName": "Apple Shortcuts", + "summary": "Discover and run existing Apple Shortcuts." + }, + "es": { + "displayName": "Atajos de Apple", + "summary": "Descubre y ejecuta atajos de Apple existentes." + }, + "fr": { + "displayName": "Raccourcis Apple", + "summary": "Découvrez et exécutez vos raccourcis Apple." + }, + "ja": { + "displayName": "Appleショートカット", + "summary": "既存のAppleショートカットを見つけて実行します。" + }, + "ko": { + "displayName": "Apple 단축어", + "summary": "기존 Apple 단축어를 찾아 실행합니다." + }, + "pt": { + "displayName": "Atalhos da Apple", + "summary": "Descubra e execute atalhos existentes da Apple." + }, + "ru": { + "displayName": "Быстрые команды Apple", + "summary": "Находите и запускайте существующие быстрые команды Apple." + }, + "zh-Hans": { + "displayName": "Apple 快捷指令", + "summary": "发现并运行现有的 Apple 快捷指令。" + }, + "zh-Hant": { + "displayName": "Apple 捷徑", + "summary": "探索並執行現有的 Apple 捷徑。" + } }, - "version": "1.0.0", + "version": "1.0.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "AppleShortcuts.bundle", "factoryClass": "AppleShortcutsPlugin.AppleShortcutsPluginFactory", "build": { @@ -29,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/AutoHideDock/plugin.json b/Plugins/AutoHideDock/plugin.json index 4eabc538..48c96f79 100644 --- a/Plugins/AutoHideDock/plugin.json +++ b/Plugins/AutoHideDock/plugin.json @@ -48,9 +48,9 @@ "summary": "自動隱藏Dock,提供更乾淨的桌面環境" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "AutoHideDock.bundle", "factoryClass": "AutoHideDockPlugin.AutoHideDockPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/AutoHideMenuBar/plugin.json b/Plugins/AutoHideMenuBar/plugin.json index 9187cf27..29dd97e8 100644 --- a/Plugins/AutoHideMenuBar/plugin.json +++ b/Plugins/AutoHideMenuBar/plugin.json @@ -48,9 +48,9 @@ "summary": "自動隱藏選單列,提供更完整的螢幕顯示空間" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "AutoHideMenuBar.bundle", "factoryClass": "AutoHideMenuBarPlugin.AutoHideMenuBarPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/AutoInput/plugin.json b/Plugins/AutoInput/plugin.json index dc6ace1e..ea6d76b5 100644 --- a/Plugins/AutoInput/plugin.json +++ b/Plugins/AutoInput/plugin.json @@ -48,9 +48,9 @@ "summary": "按應用程式記住並自動切換輸入法" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "AutoInput.bundle", "factoryClass": "AutoInputPlugin.AutoInputPluginFactory", "build": { diff --git a/Plugins/BatteryChargeLimit/plugin.json b/Plugins/BatteryChargeLimit/plugin.json index 5f7e2ec8..2e84a900 100644 --- a/Plugins/BatteryChargeLimit/plugin.json +++ b/Plugins/BatteryChargeLimit/plugin.json @@ -48,9 +48,9 @@ "summary": "設定電池充電上限,達到上限後停止充電;不自動恢復,由用戶決定何時繼續充電" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "BatteryChargeLimit.bundle", "factoryClass": "BatteryChargeLimitPlugin.BatteryChargeLimitPluginFactory", "build": { @@ -67,6 +67,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/Calendar/plugin.json b/Plugins/Calendar/plugin.json index c79f3fc1..4a1a443b 100644 --- a/Plugins/Calendar/plugin.json +++ b/Plugins/Calendar/plugin.json @@ -48,9 +48,9 @@ "summary": "查看日期、節假日和系統行程" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "Calendar.bundle", "factoryClass": "CalendarPlugin.CalendarPluginFactory", "build": { diff --git a/Plugins/ClipboardClear/plugin.json b/Plugins/ClipboardClear/plugin.json index 5be5d389..5a2f3594 100644 --- a/Plugins/ClipboardClear/plugin.json +++ b/Plugins/ClipboardClear/plugin.json @@ -48,9 +48,9 @@ "summary": "一鍵清空當前剪貼板內容" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "ClipboardClear.bundle", "factoryClass": "ClipboardClearPlugin.ClipboardClearPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "storage" } diff --git a/Plugins/CloudflareR2/plugin.json b/Plugins/CloudflareR2/plugin.json index 6e5bb640..d38046d2 100644 --- a/Plugins/CloudflareR2/plugin.json +++ b/Plugins/CloudflareR2/plugin.json @@ -48,9 +48,9 @@ "summary": "ارفع الملفات إلى Cloudflare R2." } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "CloudflareR2.bundle", "factoryClass": "CloudflareR2Plugin.CloudflareR2PluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/DeviceBattery/plugin.json b/Plugins/DeviceBattery/plugin.json index f969b299..62a85340 100644 --- a/Plugins/DeviceBattery/plugin.json +++ b/Plugins/DeviceBattery/plugin.json @@ -48,9 +48,9 @@ "summary": "查看 Mac、Apple 行動裝置、藍牙外設和雷柏滑鼠電量" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DeviceBattery.bundle", "factoryClass": "DeviceBatteryPlugin.DeviceBatteryPluginFactory", "build": { @@ -62,5 +62,7 @@ "componentPanel": true, "settings": "form" }, - "permissions": [] + "permissions": [ + + ] } diff --git a/Plugins/DiskClean/plugin.json b/Plugins/DiskClean/plugin.json index 8bfe8a57..87bc5b86 100644 --- a/Plugins/DiskClean/plugin.json +++ b/Plugins/DiskClean/plugin.json @@ -48,9 +48,9 @@ "summary": "掃描系統快取、開發產物與殘留安裝包,預設移到廢紙簍,執行前校驗路徑安全" } }, - "version": "2.1.0", + "version": "2.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DiskClean.bundle", "factoryClass": "DiskCleanPlugin.DiskCleanPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "storage" } diff --git a/Plugins/DisplayBrightness/plugin.json b/Plugins/DisplayBrightness/plugin.json index 2b4128f4..79baa89c 100644 --- a/Plugins/DisplayBrightness/plugin.json +++ b/Plugins/DisplayBrightness/plugin.json @@ -48,9 +48,9 @@ "summary": "快速調節每個顯示器的亮度" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DisplayBrightness.bundle", "factoryClass": "DisplayBrightnessPlugin.DisplayBrightnessPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/DisplayResolution/plugin.json b/Plugins/DisplayResolution/plugin.json index 4668c968..32083380 100644 --- a/Plugins/DisplayResolution/plugin.json +++ b/Plugins/DisplayResolution/plugin.json @@ -48,9 +48,9 @@ "summary": "查看並切換每個顯示器的分辨率" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DisplayResolution.bundle", "factoryClass": "DisplayResolutionPlugin.DisplayResolutionPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/DisplaySleep/plugin.json b/Plugins/DisplaySleep/plugin.json index 51a92bbd..103cdcd5 100644 --- a/Plugins/DisplaySleep/plugin.json +++ b/Plugins/DisplaySleep/plugin.json @@ -48,9 +48,9 @@ "summary": "一鍵讓所有顯示器立即進入休眠" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DisplaySleep.bundle", "factoryClass": "DisplaySleepPlugin.DisplaySleepPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/DisplayTrueColor/plugin.json b/Plugins/DisplayTrueColor/plugin.json index 1f32b1d1..cbd1619f 100644 --- a/Plugins/DisplayTrueColor/plugin.json +++ b/Plugins/DisplayTrueColor/plugin.json @@ -48,9 +48,9 @@ "summary": "自動調節顯示器顏色以適應環境光" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DisplayTrueColor.bundle", "factoryClass": "DisplayTrueColorPlugin.DisplayTrueColorPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/DockClickMinimize/plugin.json b/Plugins/DockClickMinimize/plugin.json index 2d397b08..6db85cbb 100644 --- a/Plugins/DockClickMinimize/plugin.json +++ b/Plugins/DockClickMinimize/plugin.json @@ -3,21 +3,54 @@ "displayName": "点击程序坞隐藏活跃 App", "summary": "点击活跃 App 的程序坞图标即可将其隐藏(如 Windows)。", "localizedMetadata": { - "ar": { "displayName": "إخفاء التطبيق النشط بالنقر على Dock", "summary": "أخفِ التطبيق النشط بالنقر على أيقونته في Dock (كما في Windows)." }, - "de": { "displayName": "Aktive App per Dock-Klick ausblenden", "summary": "Blendet die aktive App durch Klick auf ihr Dock-Symbol aus (wie unter Windows)." }, - "en": { "displayName": "Hide Active App on Dock Click", "summary": "Hide the active app by clicking its Dock icon (like on Windows)." }, - "es": { "displayName": "Ocultar la app activa al hacer clic en el Dock", "summary": "Oculta la app activa al hacer clic en su icono del Dock (como en Windows)." }, - "fr": { "displayName": "Masquer l’app active par clic dans le Dock", "summary": "Masque l’app active en cliquant sur son icône dans le Dock (comme sous Windows)." }, - "ja": { "displayName": "DockクリックでアクティブなAppを隠す", "summary": "DockのアイコンをクリックしてアクティブなAppを隠します(Windowsと同様)。" }, - "ko": { "displayName": "Dock 클릭으로 활성 앱 가리기", "summary": "Dock 아이콘을 클릭해 활성 앱을 가립니다(Windows처럼)." }, - "pt": { "displayName": "Ocultar app ativa com clique no Dock", "summary": "Oculta o app ativo ao clicar no ícone dele no Dock (como no Windows)." }, - "ru": { "displayName": "Скрыть активное приложение щелчком по Dock", "summary": "Скрывает активное приложение щелчком по его значку в Dock (как в Windows)." }, - "zh-Hans": { "displayName": "点击程序坞隐藏活跃 App", "summary": "点击活跃 App 的程序坞图标即可将其隐藏(如 Windows)。" }, - "zh-Hant": { "displayName": "點擊 Dock 隱藏使用中 App", "summary": "點擊使用中 App 的 Dock 圖示即可將其隱藏(如 Windows)。" } + "ar": { + "displayName": "إخفاء التطبيق النشط بالنقر على Dock", + "summary": "أخفِ التطبيق النشط بالنقر على أيقونته في Dock (كما في Windows)." + }, + "de": { + "displayName": "Aktive App per Dock-Klick ausblenden", + "summary": "Blendet die aktive App durch Klick auf ihr Dock-Symbol aus (wie unter Windows)." + }, + "en": { + "displayName": "Hide Active App on Dock Click", + "summary": "Hide the active app by clicking its Dock icon (like on Windows)." + }, + "es": { + "displayName": "Ocultar la app activa al hacer clic en el Dock", + "summary": "Oculta la app activa al hacer clic en su icono del Dock (como en Windows)." + }, + "fr": { + "displayName": "Masquer l’app active par clic dans le Dock", + "summary": "Masque l’app active en cliquant sur son icône dans le Dock (comme sous Windows)." + }, + "ja": { + "displayName": "DockクリックでアクティブなAppを隠す", + "summary": "DockのアイコンをクリックしてアクティブなAppを隠します(Windowsと同様)。" + }, + "ko": { + "displayName": "Dock 클릭으로 활성 앱 가리기", + "summary": "Dock 아이콘을 클릭해 활성 앱을 가립니다(Windows처럼)." + }, + "pt": { + "displayName": "Ocultar app ativa com clique no Dock", + "summary": "Oculta o app ativo ao clicar no ícone dele no Dock (como no Windows)." + }, + "ru": { + "displayName": "Скрыть активное приложение щелчком по Dock", + "summary": "Скрывает активное приложение щелчком по его значку в Dock (как в Windows)." + }, + "zh-Hans": { + "displayName": "点击程序坞隐藏活跃 App", + "summary": "点击活跃 App 的程序坞图标即可将其隐藏(如 Windows)。" + }, + "zh-Hant": { + "displayName": "點擊 Dock 隱藏使用中 App", + "summary": "點擊使用中 App 的 Dock 圖示即可將其隱藏(如 Windows)。" + } }, - "version": "1.0.0", + "version": "1.0.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DockClickMinimize.bundle", "factoryClass": "DockClickMinimizePlugin.DockClickMinimizePluginFactory", "build": { @@ -29,6 +62,9 @@ "componentPanel": false, "settings": "form" }, - "permissions": ["accessibility", "inputMonitoring"], + "permissions": [ + "accessibility", + "inputMonitoring" + ], "category": "productivity" } diff --git a/Plugins/DockLock/plugin.json b/Plugins/DockLock/plugin.json index 68074669..2226da73 100644 --- a/Plugins/DockLock/plugin.json +++ b/Plugins/DockLock/plugin.json @@ -16,9 +16,9 @@ "summary": "防止程序坞在多显示器之间意外移动" } }, - "version": "1.0.1", + "version": "1.0.2", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "DockLock.bundle", "factoryClass": "DockLockPlugin.DockLockPluginFactory", "build": { diff --git a/Plugins/EjectDisk/plugin.json b/Plugins/EjectDisk/plugin.json index b89cec98..18d9935d 100644 --- a/Plugins/EjectDisk/plugin.json +++ b/Plugins/EjectDisk/plugin.json @@ -48,9 +48,9 @@ "summary": "推出所有可移動磁盤" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "EjectDisk.bundle", "factoryClass": "EjectDiskPlugin.EjectDiskPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "storage" } diff --git a/Plugins/EmptyTrash/plugin.json b/Plugins/EmptyTrash/plugin.json index 56003419..2408ca6e 100644 --- a/Plugins/EmptyTrash/plugin.json +++ b/Plugins/EmptyTrash/plugin.json @@ -48,9 +48,9 @@ "summary": "清空垃圾桶中的所有項目" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "EmptyTrash.bundle", "factoryClass": "EmptyTrashPlugin.EmptyTrashPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "storage" } diff --git a/Plugins/FanControl/plugin.json b/Plugins/FanControl/plugin.json index 856a8ad3..ad040c6c 100644 --- a/Plugins/FanControl/plugin.json +++ b/Plugins/FanControl/plugin.json @@ -48,9 +48,9 @@ "summary": "管理風扇轉速預設,支持自動、全速和自定義策略" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "FanControl.bundle", "factoryClass": "FanControlPlugin.FanControlPluginFactory", "build": { @@ -67,6 +67,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/FixDamagedApp/plugin.json b/Plugins/FixDamagedApp/plugin.json index ad8a9475..2d49e667 100644 --- a/Plugins/FixDamagedApp/plugin.json +++ b/Plugins/FixDamagedApp/plugin.json @@ -48,9 +48,9 @@ "summary": "移除隔離屬性,解決「已損壞」或「不受信任」提示" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "FixDamagedApp.bundle", "factoryClass": "FixDamagedAppPlugin.FixDamagedAppPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/HideNotch/plugin.json b/Plugins/HideNotch/plugin.json index 756b18d1..36255a79 100644 --- a/Plugins/HideNotch/plugin.json +++ b/Plugins/HideNotch/plugin.json @@ -48,9 +48,9 @@ "summary": "自動遮擋劉海螢幕頂部區域" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "HideNotch.bundle", "factoryClass": "HideNotchPlugin.HideNotchPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/Homebrew/plugin.json b/Plugins/Homebrew/plugin.json index ded91eb6..dd8a88ab 100644 --- a/Plugins/Homebrew/plugin.json +++ b/Plugins/Homebrew/plugin.json @@ -48,9 +48,9 @@ "summary": "管理 Homebrew 包、軟體倉庫並執行系統診斷" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "Homebrew.bundle", "factoryClass": "HomebrewPlugin.HomebrewPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/IPOverview/plugin.json b/Plugins/IPOverview/plugin.json index 06dc8fc5..7412a9ce 100644 --- a/Plugins/IPOverview/plugin.json +++ b/Plugins/IPOverview/plugin.json @@ -48,9 +48,9 @@ "summary": "查看出口 IP、本地 IP、歸屬地與網路測速摘要" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "IPOverview.bundle", "factoryClass": "IPOverviewPlugin.IPOverviewPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "monitoring" } diff --git a/Plugins/InputRemapping/plugin.json b/Plugins/InputRemapping/plugin.json index e6601b10..90cc485c 100644 --- a/Plugins/InputRemapping/plugin.json +++ b/Plugins/InputRemapping/plugin.json @@ -3,25 +3,68 @@ "displayName": "自定义快捷操作:键盘、触控板、鼠标", "summary": "将输入映射为操作", "localizedMetadata": { - "ar": { "displayName": "اختصارات مخصصة: لوحة المفاتيح، لوحة التتبع، الماوس", "summary": "اربط الإدخالات بالإجراءات." }, - "de": { "displayName": "Eigene Kurzbefehle: Tastatur, Trackpad, Maus", "summary": "Eingaben Aktionen zuordnen." }, - "en": { "displayName": "Custom Shortcuts: Keyboard, Trackpad, Mouse", "summary": "Map inputs to actions" }, - "es": { "displayName": "Atajos personalizados: Teclado, Panel táctil, Ratón", "summary": "Asigna entradas a acciones." }, - "fr": { "displayName": "Raccourcis personnalisés : Clavier, Pavé tactile, Souris", "summary": "Associer les entrées aux actions." }, - "ja": { "displayName": "カスタムショートカット:キーボード、トラックパッド、マウス", "summary": "入力をアクションに割り当てます。" }, - "ko": { "displayName": "사용자화 단축키: 키보드, 트랙패드, 마우스", "summary": "입력을 동작에 연결합니다." }, - "pt": { "displayName": "Atalhos personalizados: Teclado, Trackpad, Rato", "summary": "Associe entradas a ações." }, - "ru": { "displayName": "Пользовательские сочетания: Клавиатура, Трекпад, Мышь", "summary": "Сопоставляйте ввод с действиями." }, - "zh-Hans": { "displayName": "自定义快捷操作:键盘、触控板、鼠标", "summary": "将输入映射为操作" }, - "zh-Hant": { "displayName": "自訂快速操作:鍵盤、觸控板、滑鼠", "summary": "將輸入對應至操作。" } + "ar": { + "displayName": "اختصارات مخصصة: لوحة المفاتيح، لوحة التتبع، الماوس", + "summary": "اربط الإدخالات بالإجراءات." + }, + "de": { + "displayName": "Eigene Kurzbefehle: Tastatur, Trackpad, Maus", + "summary": "Eingaben Aktionen zuordnen." + }, + "en": { + "displayName": "Custom Shortcuts: Keyboard, Trackpad, Mouse", + "summary": "Map inputs to actions" + }, + "es": { + "displayName": "Atajos personalizados: Teclado, Panel táctil, Ratón", + "summary": "Asigna entradas a acciones." + }, + "fr": { + "displayName": "Raccourcis personnalisés : Clavier, Pavé tactile, Souris", + "summary": "Associer les entrées aux actions." + }, + "ja": { + "displayName": "カスタムショートカット:キーボード、トラックパッド、マウス", + "summary": "入力をアクションに割り当てます。" + }, + "ko": { + "displayName": "사용자화 단축키: 키보드, 트랙패드, 마우스", + "summary": "입력을 동작에 연결합니다." + }, + "pt": { + "displayName": "Atalhos personalizados: Teclado, Trackpad, Rato", + "summary": "Associe entradas a ações." + }, + "ru": { + "displayName": "Пользовательские сочетания: Клавиатура, Трекпад, Мышь", + "summary": "Сопоставляйте ввод с действиями." + }, + "zh-Hans": { + "displayName": "自定义快捷操作:键盘、触控板、鼠标", + "summary": "将输入映射为操作" + }, + "zh-Hant": { + "displayName": "自訂快速操作:鍵盤、觸控板、滑鼠", + "summary": "將輸入對應至操作。" + } }, - "version": "1.0.1", + "version": "1.0.2", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "InputRemapping.bundle", "factoryClass": "InputRemappingPlugin.InputRemappingPluginFactory", - "build": { "project": "../../MacTools.xcodeproj", "scheme": "InputRemappingPlugin" }, - "capabilities": { "primaryPanel": true, "componentPanel": false, "settings": "workspace" }, - "permissions": ["accessibility", "inputMonitoring"], + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "InputRemappingPlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "settings": "workspace" + }, + "permissions": [ + "accessibility", + "inputMonitoring" + ], "category": "productivity" } diff --git a/Plugins/KeepAwake/plugin.json b/Plugins/KeepAwake/plugin.json index 643c6045..dfe5b285 100644 --- a/Plugins/KeepAwake/plugin.json +++ b/Plugins/KeepAwake/plugin.json @@ -48,9 +48,9 @@ "summary": "保持 Mac 喚醒;可選保持螢幕常亮或讓螢幕工具繼續運作。MacBook 闔蓋運作要求連接電源" } }, - "version": "1.3.0", + "version": "1.3.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "KeepAwake.bundle", "factoryClass": "KeepAwakePlugin.KeepAwakePluginFactory", "build": { @@ -67,6 +67,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/LaunchControl/plugin.json b/Plugins/LaunchControl/plugin.json index d2be66e4..567c5bd1 100644 --- a/Plugins/LaunchControl/plugin.json +++ b/Plugins/LaunchControl/plugin.json @@ -48,9 +48,9 @@ "summary": "查看和管理 launchctl 啟動項" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "LaunchControl.bundle", "factoryClass": "LaunchControlPlugin.LaunchControlPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/Launchpad/plugin.json b/Plugins/Launchpad/plugin.json index e3f578ea..3a83a84a 100644 --- a/Plugins/Launchpad/plugin.json +++ b/Plugins/Launchpad/plugin.json @@ -48,9 +48,9 @@ "summary": "用全域快速鍵或選單列打開 App 網格,搜尋並啟動。" } }, - "version": "1.2.0", + "version": "1.2.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "Launchpad.bundle", "factoryClass": "LaunchpadPlugin.LaunchpadPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/LockScreen/plugin.json b/Plugins/LockScreen/plugin.json index da101e62..4d4d834c 100644 --- a/Plugins/LockScreen/plugin.json +++ b/Plugins/LockScreen/plugin.json @@ -48,9 +48,9 @@ "summary": "一鍵立即鎖定螢幕,進入密碼解鎖界面" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "LockScreen.bundle", "factoryClass": "LockScreenPlugin.LockScreenPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/MicrophoneMute/plugin.json b/Plugins/MicrophoneMute/plugin.json index da38028b..aa923af8 100644 --- a/Plugins/MicrophoneMute/plugin.json +++ b/Plugins/MicrophoneMute/plugin.json @@ -48,9 +48,9 @@ "summary": "快速靜音或恢復預設麥克風輸入" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "MicrophoneMute.bundle", "factoryClass": "MicrophoneMutePlugin.MicrophoneMutePluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "audio" } diff --git a/Plugins/MiddleClick/plugin.json b/Plugins/MiddleClick/plugin.json index eddb0b3f..673935b9 100644 --- a/Plugins/MiddleClick/plugin.json +++ b/Plugins/MiddleClick/plugin.json @@ -48,9 +48,9 @@ "summary": "觸控板輕點 → 模擬滑鼠中鍵" } }, - "version": "1.0.16", + "version": "1.0.17", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "MiddleClick.bundle", "factoryClass": "MiddleClickPlugin.MiddleClickPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": ["accessibility"], + "permissions": [ + "accessibility" + ], "category": "productivity" } diff --git a/Plugins/MouseEnhancer/plugin.json b/Plugins/MouseEnhancer/plugin.json index f03851fe..08fc98ee 100644 --- a/Plugins/MouseEnhancer/plugin.json +++ b/Plugins/MouseEnhancer/plugin.json @@ -48,9 +48,9 @@ "summary": "分別調整滑鼠與觸控板的捲動方向" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "MouseEnhancer.bundle", "factoryClass": "MouseEnhancerPlugin.MouseEnhancerPluginFactory", "build": { diff --git a/Plugins/NightShift/plugin.json b/Plugins/NightShift/plugin.json index a2965894..a00ab53d 100644 --- a/Plugins/NightShift/plugin.json +++ b/Plugins/NightShift/plugin.json @@ -48,9 +48,9 @@ "summary": "降低藍光,使螢幕顏色更暖" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "NightShift.bundle", "factoryClass": "NightShiftPlugin.NightShiftPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/PhysicalCleanMode/plugin.json b/Plugins/PhysicalCleanMode/plugin.json index b7837491..42adb82b 100644 --- a/Plugins/PhysicalCleanMode/plugin.json +++ b/Plugins/PhysicalCleanMode/plugin.json @@ -48,9 +48,9 @@ "summary": "螢幕全黑並臨時禁用鍵盤輸入" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "PhysicalCleanMode.bundle", "factoryClass": "PhysicalCleanModePlugin.PhysicalCleanModePluginFactory", "build": { diff --git a/Plugins/QuitApps/plugin.json b/Plugins/QuitApps/plugin.json index ff9d42e1..e327cb01 100644 --- a/Plugins/QuitApps/plugin.json +++ b/Plugins/QuitApps/plugin.json @@ -48,9 +48,9 @@ "summary": "選擇並退出正在運行的App,或一鍵退出全部" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "QuitApps.bundle", "factoryClass": "QuitAppsPlugin.QuitAppsPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/RightClick/plugin.json b/Plugins/RightClick/plugin.json index 0144a2a8..2a28d69e 100644 --- a/Plugins/RightClick/plugin.json +++ b/Plugins/RightClick/plugin.json @@ -48,9 +48,9 @@ "summary": "為 Finder 右鍵菜單添加新建資料夾和路徑複製" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "RightClick.bundle", "factoryClass": "RightClickPlugin.RightClickPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/SavedScripts/Sources/SavedScriptRunner.swift b/Plugins/SavedScripts/Sources/SavedScriptRunner.swift index eabb4423..5c03fff6 100644 --- a/Plugins/SavedScripts/Sources/SavedScriptRunner.swift +++ b/Plugins/SavedScripts/Sources/SavedScriptRunner.swift @@ -107,6 +107,12 @@ struct ProcessSavedScriptRunner: SavedScriptRunning { for key in ["LANG", "LC_ALL", "USER", "LOGNAME"] { if let value = inherited[key] { environment[key] = value } } + if let context = PluginActionExecutionContext.cliInvocation { + environment[PluginCLIInvocationContext.chainEnvironmentKey] = + context.chainID.uuidString + environment[PluginCLIInvocationContext.depthEnvironmentKey] = + String(context.depth + 1) + } return environment } diff --git a/Plugins/SavedScripts/Tests/SavedScriptRunnerTests.swift b/Plugins/SavedScripts/Tests/SavedScriptRunnerTests.swift index 3d8add45..a43e4608 100644 --- a/Plugins/SavedScripts/Tests/SavedScriptRunnerTests.swift +++ b/Plugins/SavedScripts/Tests/SavedScriptRunnerTests.swift @@ -1,4 +1,5 @@ import Foundation +import MacToolsPluginKit import XCTest @testable import SavedScriptsPlugin @@ -22,6 +23,31 @@ final class SavedScriptRunnerTests: XCTestCase { XCTAssertFalse(result.outputWasTruncated) } + func testCLIInvocationContextPropagatesOnlyOpaqueChildEnvironment() async throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + let runner = ProcessSavedScriptRunner(temporaryDirectory: temporaryDirectory) + let chainID = UUID() + let script = SavedScript( + name: "CLI Context", + kind: .sh, + source: "printf '%s\\n%s' \"$MACTOOLS_CLI_CHAIN_ID\" \"$MACTOOLS_CLI_CHAIN_DEPTH\"" + ) + + let result = try await PluginActionExecutionContext.$cliInvocation.withValue( + PluginCLIInvocationContext(chainID: chainID, depth: 0) + ) { + try await runner.run(script) + } + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual( + result.standardOutput.split(separator: "\n").map(String.init), + [chainID.uuidString, "1"] + ) + } + func testWorkingDirectoryWithSpacesIsPassedWithoutShellInterpolation() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Plugins/SavedScripts/plugin.json b/Plugins/SavedScripts/plugin.json index 79206264..b0c6e55a 100644 --- a/Plugins/SavedScripts/plugin.json +++ b/Plugins/SavedScripts/plugin.json @@ -48,9 +48,9 @@ "summary": "儲存並執行 AppleScript 和 Shell 腳本。" } }, - "version": "1.0.0", + "version": "1.0.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "SavedScripts.bundle", "factoryClass": "SavedScriptsPlugin.SavedScriptsPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/Plugins/Sidecar/plugin.json b/Plugins/Sidecar/plugin.json index 4b7fe5b4..5db5fddf 100644 --- a/Plugins/Sidecar/plugin.json +++ b/Plugins/Sidecar/plugin.json @@ -48,9 +48,9 @@ "summary": "連接附近可用的 Sidecar 顯示器作為延伸顯示器" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "Sidecar.bundle", "factoryClass": "SidecarPlugin.SidecarPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/StageManager/plugin.json b/Plugins/StageManager/plugin.json index b99e09d9..c00d96f7 100644 --- a/Plugins/StageManager/plugin.json +++ b/Plugins/StageManager/plugin.json @@ -48,9 +48,9 @@ "summary": "開啟幕前調度,集中顯示當前窗口並把其他窗口收納到側邊" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "StageManager.bundle", "factoryClass": "StageManagerPlugin.StageManagerPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "display" } diff --git a/Plugins/SystemMute/plugin.json b/Plugins/SystemMute/plugin.json index 681dcb15..1b1d1255 100644 --- a/Plugins/SystemMute/plugin.json +++ b/Plugins/SystemMute/plugin.json @@ -48,9 +48,9 @@ "summary": "快速靜音或恢復系統音頻輸出" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "SystemMute.bundle", "factoryClass": "SystemMutePlugin.SystemMutePluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "none" }, - "permissions": [], + "permissions": [ + + ], "category": "audio" } diff --git a/Plugins/SystemSoftRestart/plugin.json b/Plugins/SystemSoftRestart/plugin.json index 06b32796..53b8b4cf 100644 --- a/Plugins/SystemSoftRestart/plugin.json +++ b/Plugins/SystemSoftRestart/plugin.json @@ -48,9 +48,9 @@ "summary": "重新啟動 macOS 使用者服務,嘗試恢復常見執行階段異常" } }, - "version": "1.0.0", + "version": "1.0.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "SystemSoftRestart.bundle", "factoryClass": "SystemSoftRestartPlugin.SystemSoftRestartPluginFactory", "build": { @@ -67,6 +67,8 @@ "componentPanel": false, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "system" } diff --git a/Plugins/SystemStatus/plugin.json b/Plugins/SystemStatus/plugin.json index 7d203082..8168cdec 100644 --- a/Plugins/SystemStatus/plugin.json +++ b/Plugins/SystemStatus/plugin.json @@ -48,9 +48,9 @@ "summary": "實時查看系統狀態" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "SystemStatus.bundle", "factoryClass": "SystemStatusPlugin.SystemStatusPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": true, "settings": "form" }, - "permissions": [], + "permissions": [ + + ], "category": "monitoring" } diff --git a/Plugins/TrackpadGestures/plugin.json b/Plugins/TrackpadGestures/plugin.json index 363f36ee..bb44f6c8 100644 --- a/Plugins/TrackpadGestures/plugin.json +++ b/Plugins/TrackpadGestures/plugin.json @@ -48,9 +48,9 @@ "summary": "將自訂觸控板手勢對應為 MacTools 操作、快速鍵或中鍵點按" } }, - "version": "1.0.2", + "version": "1.0.3", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "TrackpadGestures.bundle", "factoryClass": "TrackpadGesturesPlugin.TrackpadGesturesPluginFactory", "build": { diff --git a/Plugins/Translator/plugin.json b/Plugins/Translator/plugin.json index b67f462e..b61c0d0c 100644 --- a/Plugins/Translator/plugin.json +++ b/Plugins/Translator/plugin.json @@ -48,9 +48,9 @@ "summary": "透過快速鍵翻譯選取文字或截圖區域。" } }, - "version": "0.2.0", + "version": "0.2.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "Translator.bundle", "factoryClass": "TranslatorPlugin.TranslatorPluginFactory", "build": { diff --git a/Plugins/WindowLayouts/plugin.json b/Plugins/WindowLayouts/plugin.json index b7b4d82e..836f95e8 100644 --- a/Plugins/WindowLayouts/plugin.json +++ b/Plugins/WindowLayouts/plugin.json @@ -50,7 +50,7 @@ }, "version": "1.0.0", "minHostVersion": "1.2.1", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "WindowLayouts.bundle", "factoryClass": "WindowLayoutsPlugin.WindowLayoutsPluginFactory", "build": { diff --git a/Plugins/WindowSwitcher/plugin.json b/Plugins/WindowSwitcher/plugin.json index d15c1c26..da339c09 100644 --- a/Plugins/WindowSwitcher/plugin.json +++ b/Plugins/WindowSwitcher/plugin.json @@ -48,9 +48,9 @@ "summary": "用可設定快速鍵快速切換正在執行的視窗" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "WindowSwitcher.bundle", "factoryClass": "WindowSwitcherPlugin.WindowSwitcherPluginFactory", "build": { diff --git a/Plugins/XcodeClean/plugin.json b/Plugins/XcodeClean/plugin.json index 161675d7..7740485f 100644 --- a/Plugins/XcodeClean/plugin.json +++ b/Plugins/XcodeClean/plugin.json @@ -48,9 +48,9 @@ "summary": "分類清理 Xcode DerivedData、裝置支援、封存與快取" } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "XcodeClean.bundle", "factoryClass": "XcodeCleanPlugin.XcodeCleanPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "storage" } diff --git a/Plugins/ZshConfig/plugin.json b/Plugins/ZshConfig/plugin.json index 1162868d..2b304d28 100644 --- a/Plugins/ZshConfig/plugin.json +++ b/Plugins/ZshConfig/plugin.json @@ -48,9 +48,9 @@ "summary": "حرّر ~/.zshrc وملفات إعداد zsh الأخرى بسرعة، مع تحرير مدمج ومقتطفات شائعة." } }, - "version": "1.1.0", + "version": "1.1.1", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "ZshConfig.bundle", "factoryClass": "ZshConfigPlugin.ZshConfigPluginFactory", "build": { @@ -62,6 +62,8 @@ "componentPanel": false, "settings": "workspace" }, - "permissions": [], + "permissions": [ + + ], "category": "productivity" } diff --git a/README.md b/README.md index 589c51ea..ce4809eb 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ MacTools supports Simplified Chinese, Traditional Chinese, English, Spanish, Fre brew install --cask mactools ``` +MacTools provides an optional, separately downloadable authenticated CLI. Install +the `mactools-cli--macos-universal.zip` asset from the matching GitHub +release, then enable **Settings > General > Command Line** in the app. Use +`mactools help` to discover actions, workflows, diagnostics, and stable JSON +output. See the [CLI guide](docs/cli.md). + ## Upgrade ```bash diff --git a/Sources/App/AutomationSettingsView.swift b/Sources/App/AutomationSettingsView.swift index 7cd753c9..e8e02cd1 100644 --- a/Sources/App/AutomationSettingsView.swift +++ b/Sources/App/AutomationSettingsView.swift @@ -2066,8 +2066,10 @@ enum WorkflowRunPresentation { case .workflow: FeatureL10n.string("工作流") case .automaticRule: FeatureL10n.string("自动规则") case .appIntent: FeatureL10n.string("App Intent") + case .cli: FeatureL10n.string("命令行") case .manual: FeatureL10n.string("手动") case .test: FeatureL10n.string("测试") + default: actionSource.rawValue } case .automatic: FeatureL10n.string("自动规则") diff --git a/Sources/App/MacToolsApp.swift b/Sources/App/MacToolsApp.swift index a690580a..ec3765d0 100644 --- a/Sources/App/MacToolsApp.swift +++ b/Sources/App/MacToolsApp.swift @@ -273,6 +273,7 @@ final class AutomationStartupCoordinator { isPreparing = true await prepare() isPreparing = false + guard !Task.isCancelled else { return } actionRegistryDidBecomeReady() } } diff --git a/Sources/App/MacToolsAppRuntime.swift b/Sources/App/MacToolsAppRuntime.swift index ba0614bc..b3dbd568 100644 --- a/Sources/App/MacToolsAppRuntime.swift +++ b/Sources/App/MacToolsAppRuntime.swift @@ -23,6 +23,8 @@ final class MacToolsAppRuntime { private var statusItemController: MenuBarStatusItemController? private var actionGridOverlayController: ActionGridOverlayController? private var appIntentCatalogCancellable: AnyCancellable? + private var bootstrapTask: Task? + private lazy var cliHostBridge = CLIHostBridge(pluginHost: pluginHost) private lazy var settingsRecoveryScheduler = SettingsRecoveryScheduler { [weak self] in self?.windowRouter?.showSettings() } @@ -120,7 +122,10 @@ final class MacToolsAppRuntime { } func terminate() { + bootstrapTask?.cancel() + bootstrapTask = nil settingsRecoveryScheduler.cancel() + cliHostBridge.stop() pluginHost.flushAutomaticPreferencesBackupBeforeTermination() pluginHost.automationController.stopAutomaticRules() actionGridOverlayController?.close(restoringFocus: false) @@ -171,7 +176,9 @@ final class MacToolsAppRuntime { return } - Task { @MainActor in + bootstrapTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { bootstrapTask = nil } await automationStartupCoordinator.startAfterActionRegistryPreparation { let updateSucceeded = await pluginHost .automaticUpdateInstalledPluginsBeforeLoading() @@ -180,16 +187,33 @@ final class MacToolsAppRuntime { currentAppVersion: currentAppVersion ) } + await pluginHost.prepareActionCatalogForExternalDiscovery() } + guard !Task.isCancelled else { return } appIntentCoordinator.actionRegistryDidBecomeReady() activateAppURLRouter() + startCLIHostIfAvailable() } } private func completeBootstrap() { - automationStartupCoordinator.actionRegistryDidBecomeReady() - appIntentCoordinator.actionRegistryDidBecomeReady() - activateAppURLRouter() + bootstrapTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { bootstrapTask = nil } + await pluginHost.prepareActionCatalogForExternalDiscovery() + guard !Task.isCancelled else { return } + automationStartupCoordinator.actionRegistryDidBecomeReady() + appIntentCoordinator.actionRegistryDidBecomeReady() + activateAppURLRouter() + startCLIHostIfAvailable() + } + } + + private func startCLIHostIfAvailable() { + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + return + } + cliHostBridge.start() } private func activateAppURLRouter() { diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index 805f29c8..6d2aa9e1 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -298,6 +298,7 @@ struct GeneralSettingsView: View { @ObservedObject var menuBarIconGallery: MenuBarIconGalleryLibrary @ObservedObject var launchAtLoginController: LaunchAtLoginController @ObservedObject var menuBarPanelThemeStore: MenuBarPanelThemeStore + @ObservedObject private var cliService = CLIBrokerServiceController.shared @AppStorage(AppAppearancePreference.userDefaultsKey) private var appearancePreferenceRawValue = AppAppearancePreference.system.rawValue @AppStorage(AppLanguagePreference.userDefaultsKey) private var languagePreferenceRawValue = AppLanguagePreference.system.rawValue @AppStorage(MenuBarClickBehaviorPreference.userDefaultsKey) private var clickBehaviorRawValue = MenuBarClickBehaviorPreference.standard.rawValue @@ -352,6 +353,15 @@ struct GeneralSettingsView: View { layoutWidth: widths.readableContent ) } + Section { + CLISettingsRow(service: cliService) + .settingsGroupedFormRowWidth(widths.sectionLayout) + } header: { + SettingsGroupedFormSectionHeader( + title: AppL10n.settings("general.section.commandLine", defaultValue: "命令行"), + layoutWidth: widths.readableContent + ) + } Section { AppearanceSettingsRow( selectionRawValue: appearancePreferenceBinding @@ -523,6 +533,91 @@ struct GeneralSettingsView: View { } } +private struct CLISettingsRow: View { + @ObservedObject var service: CLIBrokerServiceController + + private var downloadURL: URL { + CLIServiceConfiguration.releaseDownloadURL( + version: Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String + ) + } + + var body: some View { + HStack(spacing: GeneralSettingsCardLayout.headerSpacing) { + ZStack { + RoundedRectangle(cornerRadius: GeneralSettingsCardLayout.iconCornerRadius, style: .continuous) + .fill(Color.accentColor.opacity(0.12)) + Image(systemName: "terminal") + .font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold)) + .foregroundStyle(Color.accentColor) + } + .frame(width: GeneralSettingsCardLayout.iconSize, height: GeneralSettingsCardLayout.iconSize) + + VStack(alignment: .leading, spacing: 3) { + Text(AppL10n.settings("commandLine.title", defaultValue: "MacTools 命令行")) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text(subtitle) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(service.lastError == nil ? .secondary : Color.orange) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Link( + AppL10n.settings("commandLine.download", defaultValue: "下载 CLI"), + destination: downloadURL + ) + .buttonStyle(.bordered) + .controlSize(.small) + + if service.status == .requiresApproval { + Button(AppL10n.settings("commandLine.approve", defaultValue: "允许后台运行")) { + service.openApprovalSettings() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + Toggle( + AppL10n.settings("commandLine.enable", defaultValue: "启用"), + isOn: Binding( + get: { service.isRegistered }, + set: { enabled in + if enabled { + _ = service.ensureRegistered() + } else { + _ = service.unregister() + } + } + ) + ) + .toggleStyle(.switch) + .controlSize(.small) + .fixedSize() + } + .frame(maxWidth: .infinity, minHeight: GeneralSettingsCardLayout.minRowHeight, alignment: .leading) + .padding(.horizontal, GeneralSettingsCardLayout.horizontalPadding) + .padding(.vertical, GeneralSettingsCardLayout.verticalPadding) + .onAppear { + service.refresh() + } + } + + private var subtitle: String { + if let error = service.lastError { return error } + switch service.status { + case .enabled: + return AppL10n.settings("commandLine.enabled", defaultValue: "已允许单独安装的 mactools-cli 连接到 MacTools。") + case .requiresApproval: + return AppL10n.settings("commandLine.requiresApproval", defaultValue: "请在系统设置中允许 MacTools 命令行代理后台运行。") + case .notRegistered, .notFound, .registrationFailed: + return AppL10n.settings("commandLine.description", defaultValue: "单独安装 mactools-cli 后,在此启用本机命令行集成。") + } + } +} + private struct GeneralSettingsSearchAnchorModifier: ViewModifier { @AccessibilityFocusState private var isAccessibilityFocused: Bool diff --git a/Sources/Core/Actions/ActionExecutor.swift b/Sources/Core/Actions/ActionExecutor.swift index 368266ba..3b1f2224 100644 --- a/Sources/Core/Actions/ActionExecutor.swift +++ b/Sources/Core/Actions/ActionExecutor.swift @@ -447,7 +447,7 @@ final class ActionExecutor { } let needsConfirmation = initial.definition.risk == .confirmationRequired - || (invocation.source == .runLink + || ((invocation.source == .runLink || invocation.source == .cli) && initial.definition.externalInvocationPolicy == .confirmAlways) if needsConfirmation { guard let confirmation = initial.definition.confirmation else { @@ -616,9 +616,14 @@ final class ActionExecutor { } } + if invocation.source == .runLink || invocation.source == .cli { + guard definition.externalInvocationPolicy != .unavailable else { + return .externalInvocationUnavailable + } + } + if invocation.source == .runLink { - guard definition.externalInvocationPolicy != .unavailable, - !ActionRegistry.containsSensitiveParameters( + guard !ActionRegistry.containsSensitiveParameters( invocation.reference, for: definition ) else { @@ -635,6 +640,8 @@ final class ActionExecutor { switch invocation.source { case .appIntent: surface = .appIntents + case .cli: + surface = .cli default: return nil } diff --git a/Sources/Core/CLI/CLIBrokerServiceController.swift b/Sources/Core/CLI/CLIBrokerServiceController.swift new file mode 100644 index 00000000..e0e1f3c1 --- /dev/null +++ b/Sources/Core/CLI/CLIBrokerServiceController.swift @@ -0,0 +1,208 @@ +import Foundation +import ServiceManagement + +@MainActor +protocol CLIBrokerServicing: AnyObject { + var status: SMAppService.Status { get } + func register() throws + func unregister() throws +} + +@MainActor +protocol CLIBrokerRegistrationStoring: AnyObject { + var registeredFingerprint: String? { get set } + var enabledIntent: Bool? { get set } +} + +@MainActor +private final class UserDefaultsCLIBrokerRegistrationStore: CLIBrokerRegistrationStoring { + private let defaults: UserDefaults + private let fingerprintKey: String + private let enabledIntentKey: String + + init(defaults: UserDefaults = .standard, bundleIdentifier: String?) { + self.defaults = defaults + let suffix = bundleIdentifier ?? "unknown" + fingerprintKey = "cli.broker.registered-fingerprint.\(suffix)" + enabledIntentKey = "cli.broker.enabled-intent.\(suffix)" + } + + var registeredFingerprint: String? { + get { defaults.string(forKey: fingerprintKey) } + set { defaults.set(newValue, forKey: fingerprintKey) } + } + + var enabledIntent: Bool? { + get { defaults.object(forKey: enabledIntentKey) as? Bool } + set { + if let newValue { + defaults.set(newValue, forKey: enabledIntentKey) + } else { + defaults.removeObject(forKey: enabledIntentKey) + } + } + } +} + +@MainActor +private final class SystemCLIBrokerService: CLIBrokerServicing { + private let service: SMAppService + + init(service: SMAppService) { + self.service = service + } + + var status: SMAppService.Status { service.status } + + func register() throws { + try service.register() + } + + func unregister() throws { + try service.unregister() + } +} + +@MainActor +final class CLIBrokerServiceController: ObservableObject { + static let shared = CLIBrokerServiceController() + + enum ServiceStatus: String { + case enabled + case requiresApproval + case notRegistered + case notFound + case registrationFailed + } + + @Published private(set) var status: ServiceStatus = .notRegistered + @Published private(set) var lastError: String? + + private let service: any CLIBrokerServicing + private let registrationStore: any CLIBrokerRegistrationStoring + private let currentRegistrationFingerprint: () -> String + + var isRegistered: Bool { + service.status == .enabled || service.status == .requiresApproval + } + + init( + service: (any CLIBrokerServicing)? = nil, + registrationStore: (any CLIBrokerRegistrationStoring)? = nil, + currentRegistrationFingerprint: (() -> String)? = nil + ) { + self.service = service ?? SystemCLIBrokerService( + service: .agent(plistName: CLIServiceConfiguration.launchAgentPlistName) + ) + self.registrationStore = registrationStore ?? UserDefaultsCLIBrokerRegistrationStore( + bundleIdentifier: Bundle.main.bundleIdentifier + ) + self.currentRegistrationFingerprint = currentRegistrationFingerprint ?? { + let info = Bundle.main.infoDictionary ?? [:] + let version = info["CFBundleShortVersionString"] as? String ?? "unknown" + let build = info["CFBundleVersion"] as? String ?? "unknown" + let path = Bundle.main.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path + return "\(path)|\(version)|\(build)" + } + refresh() + } + + @discardableResult + func ensureRegistered() -> Bool { + registrationStore.enabledIntent = true + return reconcileRegisteredService() + } + + @discardableResult + func reconcileRegisteredService() -> Bool { + refresh() + let enabledIntent: Bool + if let storedIntent = registrationStore.enabledIntent { + enabledIntent = storedIntent + } else if isRegistered { + registrationStore.enabledIntent = true + enabledIntent = true + } else { + return true + } + + guard enabledIntent else { + return unregisterDesiredService() + } + guard isRegistered else { + return registerDesiredService() + } + let fingerprint = currentRegistrationFingerprint() + guard registrationStore.registeredFingerprint != fingerprint else { return true } + do { + try service.unregister() + registrationStore.registeredFingerprint = nil + try service.register() + lastError = nil + } catch { + lastError = error.localizedDescription + } + refresh() + if isRegistered, lastError == nil { + registrationStore.registeredFingerprint = fingerprint + return true + } + return false + } + + @discardableResult + func unregister() -> Bool { + registrationStore.enabledIntent = false + return unregisterDesiredService() + } + + private func registerDesiredService() -> Bool { + guard service.status == .notRegistered || service.status == .notFound else { + return false + } + do { + try service.register() + lastError = nil + } catch { + lastError = error.localizedDescription + } + refresh() + if isRegistered { + registrationStore.registeredFingerprint = currentRegistrationFingerprint() + return true + } + return false + } + + private func unregisterDesiredService() -> Bool { + if service.status == .notRegistered || service.status == .notFound { + registrationStore.registeredFingerprint = nil + lastError = nil + refresh() + return true + } + do { + try service.unregister() + registrationStore.registeredFingerprint = nil + lastError = nil + } catch { + lastError = error.localizedDescription + } + refresh() + return service.status == .notRegistered || service.status == .notFound + } + + func openApprovalSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + func refresh() { + switch service.status { + case .enabled: status = .enabled + case .requiresApproval: status = .requiresApproval + case .notRegistered: status = lastError == nil ? .notRegistered : .registrationFailed + case .notFound: status = .notFound + @unknown default: status = .registrationFailed + } + } +} diff --git a/Sources/Core/CLI/CLIHostApplicationLauncher.swift b/Sources/Core/CLI/CLIHostApplicationLauncher.swift new file mode 100644 index 00000000..6e414920 --- /dev/null +++ b/Sources/Core/CLI/CLIHostApplicationLauncher.swift @@ -0,0 +1,88 @@ +import AppKit +import Foundation + +enum CLIHostApplicationLaunchError: Error, Equatable, LocalizedError { + case timedOut + case noRunningApplication + case failed(String) + + var errorDescription: String? { + switch self { + case .timedOut: + return "Launch Services did not finish opening MacTools before the startup deadline." + case .noRunningApplication: + return "Launch Services did not return a running MacTools application." + case let .failed(message): + return "Launch Services failed to open MacTools: \(message)" + } + } +} + +struct CLIHostApplicationLauncher { + typealias Completion = @Sendable (Result) -> Void + typealias OpenApplication = ( + URL, + NSWorkspace.OpenConfiguration, + @escaping Completion + ) -> Void + + private let openApplication: OpenApplication + + init(workspace: NSWorkspace = .shared) { + openApplication = { applicationURL, configuration, completion in + workspace.openApplication( + at: applicationURL, + configuration: configuration + ) { application, error in + if let error { + completion(.failure(CLIHostApplicationLaunchError.failed( + error.localizedDescription + ))) + } else if application == nil { + completion(.failure(CLIHostApplicationLaunchError.noRunningApplication)) + } else { + completion(.success(())) + } + } + } + } + + init(openApplication: @escaping OpenApplication) { + self.openApplication = openApplication + } + + func launch(applicationURL: URL, deadline: CLIStartupDeadline) async throws { + guard !deadline.isExpired else { throw CLIHostApplicationLaunchError.timedOut } + let (stream, continuation) = AsyncStream.makeStream(of: Result.self) + openApplication(applicationURL, Self.makeOpenConfiguration()) { result in + continuation.yield(result) + continuation.finish() + } + let timeoutTask = Task { + try? await deadline.sleepUntilExpired() + guard !Task.isCancelled else { return } + continuation.yield(.failure(CLIHostApplicationLaunchError.timedOut)) + continuation.finish() + } + defer { timeoutTask.cancel() } + + try await withTaskCancellationHandler { + for await result in stream { + try Task.checkCancellation() + return try result.get() + } + try Task.checkCancellation() + throw CLIHostApplicationLaunchError.noRunningApplication + } onCancel: { + continuation.yield(.failure(CancellationError())) + continuation.finish() + } + } + + static func makeOpenConfiguration() -> NSWorkspace.OpenConfiguration { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + configuration.allowsRunningApplicationSubstitution = false + return configuration + } +} diff --git a/Sources/Core/CLI/CLIHostBridge.swift b/Sources/Core/CLI/CLIHostBridge.swift new file mode 100644 index 00000000..1bf2053a --- /dev/null +++ b/Sources/Core/CLI/CLIHostBridge.swift @@ -0,0 +1,259 @@ +import Combine +import Foundation + +@MainActor +final class CLIHostBridge: NSObject, CLIHostXPCProtocol { + private let router: CLIHostRequestRouter + private let serviceController: CLIBrokerServiceController + private let identityValidator = CLIPeerIdentityValidator() + private var connection: NSXPCConnection? + private var requestTasks: [UUID: Task] = [:] + private var cancellationState = CLIHostCancellationRelayState() + private var reconnectTask: Task? + private var serviceStatusObservation: AnyCancellable? + private var isStarted = false + private lazy var callbackRelay = CLIHostBridgeCallbackRelay { @MainActor [weak self] in + self?.scheduleReconnect() + } + + init( + pluginHost: PluginHost, + serviceController: CLIBrokerServiceController = .shared + ) { + self.serviceController = serviceController + self.router = CLIHostRequestRouter( + pluginHost: pluginHost, + serviceStatus: { serviceController.status.rawValue } + ) + super.init() + serviceStatusObservation = serviceController.$status + .removeDuplicates() + .sink { [weak self] status in + Task { @MainActor [weak self] in + self?.serviceStatusDidChange(status) + } + } + } + + func start() { + isStarted = true + serviceController.reconcileRegisteredService() + serviceStatusDidChange(serviceController.status) + } + + func stop() { + isStarted = false + reconnectTask?.cancel() + reconnectTask = nil + requestTasks.values.forEach { $0.cancel() } + requestTasks.removeAll() + cancellationState.reset() + connection?.invalidate() + connection = nil + } + + nonisolated func handle(_ requestData: Data, withReply reply: @escaping (Data) -> Void) { + let reply = CLIHostReply(reply) + Task { @MainActor [weak self] in + guard let self else { reply.call(Data()); return } + let request: CLIRequestEnvelope + do { + request = try CLIProtocolCodec.decodeRequest( + CLIRequestEnvelope.self, + from: requestData, + allowedKeys: [ + "protocolVersion", "requestID", "operation", "sentAt", + "invocationContext", "payload", + ] + ) + } catch { + reply.call(Data()) + return + } + guard cancellationState.shouldBeginHandling(request.requestID) else { + cancellationState.markCompleted(request.requestID) + let response = CLIResponseEnvelope.failure( + request: request, + outcome: .cancelled, + category: "cancelled", + message: "The request was cancelled." + ) + reply.call((try? CLIProtocolCodec.encodeResponse(response)) ?? Data()) + return + } + let task = Task { @MainActor [weak self] in + guard let self else { reply.call(Data()); return } + let response = await router.handle(request) + let data = (try? CLIProtocolCodec.encodeResponse(response)) ?? Data() + requestTasks[request.requestID] = nil + cancellationState.markCompleted(request.requestID) + reply.call(data) + } + requestTasks[request.requestID] = task + } + } + + nonisolated func cancel(_ requestID: UUID, withReply reply: @escaping (Bool) -> Void) { + let reply = CLIHostReply(reply) + Task { @MainActor [weak self] in + guard let self else { + reply.call(false) + return + } + let task = requestTasks[requestID] + switch cancellationState.cancellationDisposition( + requestID: requestID, + hasActiveTask: task != nil + ) { + case .cancelActive: + task?.cancel() + reply.call(true) + case .recordedBeforeRegistration: + reply.call(true) + case .alreadyCompleted, .capacityExceeded: + reply.call(false) + } + } + } + + private func connect() { + guard isStarted, serviceController.status != .notRegistered, + serviceController.status != .notFound, + serviceController.status != .registrationFailed else { + return + } + connection?.invalidate() + let connection = NSXPCConnection( + machServiceName: CLIServiceConfiguration.serviceName( + bundleIdentifier: Bundle.main.bundleIdentifier + ) + ) + connection.remoteObjectInterface = NSXPCInterface(with: CLIBrokerXPCProtocol.self) + connection.exportedInterface = NSXPCInterface(with: CLIHostXPCProtocol.self) + connection.exportedObject = self + guard identityValidator.configure(connection, toRequire: .broker) else { + serviceController.refresh() + return + } + connection.invalidationHandler = callbackRelay.makeReconnectHandler() + connection.interruptionHandler = callbackRelay.makeReconnectHandler() + connection.activate() + self.connection = connection + + let proxy = connection.remoteObjectProxyWithErrorHandler( + callbackRelay.makeReconnectErrorHandler() + ) + guard let broker = proxy as? CLIBrokerXPCProtocol else { + scheduleReconnect() + return + } + let registration = CLIHostRegistration( + minimumProtocolVersion: CLIProtocolVersion.minimum, + maximumProtocolVersion: CLIProtocolVersion.current, + hostVersion: AppMetadata.shortVersion ?? "unknown", + hostBuild: AppMetadata.buildNumber ?? "unknown" + ) + guard let data = try? CLIProtocolCodec.encodeRequest(registration) else { return } + broker.registerHost( + data, + withReply: callbackRelay.makeRegistrationReplyHandler(for: connection) + ) + } + + private func scheduleReconnect() { + guard isStarted, reconnectTask == nil else { return } + connection = nil + requestTasks.values.forEach { $0.cancel() } + requestTasks.removeAll() + cancellationState.reset() + reconnectTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self?.reconnectTask = nil + self?.connect() + } + } + + private func serviceStatusDidChange( + _ status: CLIBrokerServiceController.ServiceStatus + ) { + guard isStarted else { return } + switch status { + case .enabled, .requiresApproval: + if connection == nil { + connect() + } + case .notRegistered, .notFound, .registrationFailed: + reconnectTask?.cancel() + reconnectTask = nil + connection?.invalidate() + connection = nil + } + } +} + +/// Keeps callbacks invoked by NSXPCConnection nonisolated, then explicitly hops UI state back to +/// the main actor. Foundation does not annotate these callback parameters as `@Sendable`, so a +/// closure created directly inside `CLIHostBridge.connect()` inherits `@MainActor` and traps when +/// XPC invokes it on its private reply queue. +final class CLIHostBridgeCallbackRelay: @unchecked Sendable { + private let reconnect: @MainActor @Sendable () -> Void + + init(reconnect: @escaping @MainActor @Sendable () -> Void) { + self.reconnect = reconnect + } + + nonisolated func makeReconnectHandler() -> @Sendable () -> Void { + { [weak self] in self?.requestReconnect() } + } + + nonisolated func makeReconnectErrorHandler() -> @Sendable (Error) -> Void { + { [weak self] _ in self?.requestReconnect() } + } + + nonisolated func makeRegistrationReplyHandler( + for connection: NSXPCConnection + ) -> @Sendable (Data) -> Void { + let connectionReference = CLIHostXPCConnectionReference(connection) + return { [weak self, connectionReference] response in + guard let connection = connectionReference.connection, + CLIPeerIdentityValidator().accepts(connection, as: .broker), + (try? CLIProtocolCodec.decodeResponse( + CLIHandshakeResponse.self, + from: response, + allowedKeys: [ + "selectedProtocolVersion", "brokerVersion", "brokerBuild", "hostVersion", + "hostBuild", "hostReady", "message", + ] + ))?.hostReady == true else { + self?.requestReconnect() + return + } + } + } + + nonisolated func requestReconnect() { + let reconnect = reconnect + Task { @MainActor in reconnect() } + } +} + +private final class CLIHostXPCConnectionReference: @unchecked Sendable { + weak var connection: NSXPCConnection? + + init(_ connection: NSXPCConnection) { + self.connection = connection + } +} + +private final class CLIHostReply: @unchecked Sendable { + private let closure: (Value) -> Void + + init(_ closure: @escaping (Value) -> Void) { + self.closure = closure + } + + func call(_ value: Value) { + closure(value) + } +} diff --git a/Sources/Core/CLI/CLIHostDiscovery.swift b/Sources/Core/CLI/CLIHostDiscovery.swift new file mode 100644 index 00000000..2f36b4e0 --- /dev/null +++ b/Sources/Core/CLI/CLIHostDiscovery.swift @@ -0,0 +1,143 @@ +import Foundation + +struct CLIStartupDeadline: Sendable { + typealias Now = @Sendable () -> ContinuousClock.Instant + typealias Sleep = @Sendable (Duration) async throws -> Void + + private let instant: ContinuousClock.Instant + private let now: Now + private let sleep: Sleep + + init(duration: Duration) { + let clock = ContinuousClock() + self.init( + duration: duration, + now: { clock.now }, + sleep: { try await clock.sleep(for: $0) } + ) + } + + init( + duration: Duration, + now: @escaping Now, + sleep: @escaping Sleep + ) { + instant = now().advanced(by: duration) + self.now = now + self.sleep = sleep + } + + var isExpired: Bool { now() >= instant } + + var remaining: Duration { + let value = now().duration(to: instant) + return value > .zero ? value : .zero + } + + var remainingTimeInterval: TimeInterval { + let components = remaining.components + return TimeInterval(components.seconds) + + TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000 + } + + func sleepUntilExpired() async throws { + let delay = remaining + if delay > .zero { try await sleep(delay) } + } + + func sleep(upTo maximum: Duration) async throws { + let delay = min(maximum, remaining) + if delay > .zero { try await sleep(delay) } + } +} + +enum CLIHostDiscoveryError: Error, Equatable, LocalizedError { + case timedOut + + var errorDescription: String? { + "MacTools host discovery did not finish before the startup deadline." + } +} + +enum CLIHostRecoveryDecision: Equatable { + case continueHandshake + case launchExactHost + case waitForReplacement + case rejectBrokerVersion + case rejectHostVersion +} + +enum CLIHostRecoveryPolicy { + static func decision( + brokerMatches: Bool, + hostMatches: Bool, + launchAllowed: Bool, + didLaunch: Bool + ) -> CLIHostRecoveryDecision { + if brokerMatches, hostMatches { return .continueHandshake } + if launchAllowed, !didLaunch { return .launchExactHost } + if didLaunch { return .waitForReplacement } + return brokerMatches ? .rejectHostVersion : .rejectBrokerVersion + } +} + +struct CLIHostDiscovery: Sendable { + typealias Locate = @Sendable (String, String, String) throws -> URL + + private let locate: Locate + + init(locator: CLIHostLocator) { + locate = { bundleIdentifier, version, build in + try locator.locate( + bundleIdentifier: bundleIdentifier, + version: version, + build: build + ) + } + } + + init(locate: @escaping Locate) { + self.locate = locate + } + + func locate( + bundleIdentifier: String, + version: String, + build: String, + deadline: CLIStartupDeadline + ) async throws -> URL { + guard !deadline.isExpired else { + throw CLIHostDiscoveryError.timedOut + } + let (stream, continuation) = AsyncStream.makeStream(of: Result.self) + let worker = Task.detached { [locate] in + let result = Result { + try locate(bundleIdentifier, version, build) + } + continuation.yield(result) + continuation.finish() + } + let timeout = Task { + try? await deadline.sleepUntilExpired() + guard !Task.isCancelled else { return } + continuation.yield(.failure(CLIHostDiscoveryError.timedOut)) + continuation.finish() + } + defer { + worker.cancel() + timeout.cancel() + } + + return try await withTaskCancellationHandler { + for await result in stream { + try Task.checkCancellation() + return try result.get() + } + try Task.checkCancellation() + throw CLIHostDiscoveryError.timedOut + } onCancel: { + continuation.yield(.failure(CancellationError())) + continuation.finish() + } + } +} diff --git a/Sources/Core/CLI/CLIHostLocator.swift b/Sources/Core/CLI/CLIHostLocator.swift new file mode 100644 index 00000000..29ef82cb --- /dev/null +++ b/Sources/Core/CLI/CLIHostLocator.swift @@ -0,0 +1,165 @@ +import AppKit +import CoreServices +import Foundation + +struct CLIHostCandidate: Equatable { + let url: URL + let bundleIdentifier: String? + let version: String? + let build: String? +} + +enum CLIHostLocationError: Error, Equatable { + case notFound(bundleIdentifier: String) + case versionIncompatible(expected: String, found: [String], candidate: URL?) + case teamMismatch(candidate: URL?) + case roleMismatch(candidate: URL?) + case invalidSignature(candidate: URL?) + + var category: String { + switch self { + case .notFound: return "hostNotFound" + case .versionIncompatible: return "hostVersionIncompatible" + case .teamMismatch: return "hostTeamMismatch" + case .roleMismatch: return "hostRoleMismatch" + case .invalidSignature: return "hostSignatureInvalid" + } + } + + var message: String { + switch self { + case .notFound: + return "MacTools is not installed." + case let .versionIncompatible(expected, found, _): + let installed = found.isEmpty ? "unknown" : found.joined(separator: ", ") + return "No installed MacTools app matches CLI version \(expected). Found: \(installed)." + case .teamMismatch: + return "The installed MacTools app belongs to a different developer team." + case .roleMismatch: + return "The installed application does not have the expected MacTools identity." + case .invalidSignature: + return "The installed MacTools application signature is invalid." + } + } + + var candidateURL: URL? { + switch self { + case .notFound: return nil + case let .versionIncompatible(_, _, candidate), + let .teamMismatch(candidate), + let .roleMismatch(candidate), + let .invalidSignature(candidate): + return candidate + } + } +} + +struct CLIHostLocator: Sendable { + typealias CandidateProvider = @Sendable (String) -> [CLIHostCandidate] + typealias IdentityEvaluator = @Sendable (URL) -> CLIHostIdentityAssessment + + private let candidateProvider: CandidateProvider + private let identityEvaluator: IdentityEvaluator + + init(identityValidator: CLIPeerIdentityValidator = CLIPeerIdentityValidator()) { + candidateProvider = Self.launchServicesCandidates(bundleIdentifier:) + identityEvaluator = { applicationURL in + identityValidator.applicationIdentityAssessment( + at: applicationURL, + as: .host + ) + } + } + + init( + candidateProvider: @escaping CandidateProvider, + identityEvaluator: @escaping IdentityEvaluator + ) { + self.candidateProvider = candidateProvider + self.identityEvaluator = identityEvaluator + } + + func locate( + bundleIdentifier: String, + version: String, + build: String + ) throws -> URL { + let candidates = candidateProvider(bundleIdentifier).sorted { + $0.url.standardizedFileURL.path < $1.url.standardizedFileURL.path + } + guard !candidates.isEmpty else { + throw CLIHostLocationError.notFound(bundleIdentifier: bundleIdentifier) + } + + let assessed = candidates.map { candidate in + let assessment = candidate.bundleIdentifier == bundleIdentifier + ? identityEvaluator(candidate.url) + : CLIHostIdentityAssessment.wrongRole + return (candidate: candidate, assessment: assessment) + } + + let exactRelease = assessed.filter { + $0.candidate.version == version && $0.candidate.build == build + } + if let match = exactRelease.first(where: { $0.assessment == .accepted }) { + return match.candidate.url.standardizedFileURL + } + if let rejectedExactRelease = exactRelease.first { + throw locationError( + assessment: rejectedExactRelease.assessment, + candidate: rejectedExactRelease.candidate.url + ) + } + + let trusted = assessed.filter { $0.assessment == .accepted }.map(\.candidate) + if !trusted.isEmpty { + let found = trusted.map { + "\($0.version ?? "unknown") (\($0.build ?? "unknown"))" + } + throw CLIHostLocationError.versionIncompatible( + expected: "\(version) (\(build))", + found: found, + candidate: trusted.first?.url + ) + } + + let rejected = assessed[0] + throw locationError( + assessment: rejected.assessment, + candidate: rejected.candidate.url + ) + } + + private func locationError( + assessment: CLIHostIdentityAssessment, + candidate: URL + ) -> CLIHostLocationError { + switch assessment { + case .wrongTeam: return .teamMismatch(candidate: candidate) + case .wrongRole: return .roleMismatch(candidate: candidate) + case .invalidSignature: return .invalidSignature(candidate: candidate) + case .accepted: + preconditionFailure("Accepted candidates are handled before rejection mapping.") + } + } + + private static func launchServicesCandidates(bundleIdentifier: String) -> [CLIHostCandidate] { + guard let values = LSCopyApplicationURLsForBundleIdentifier( + bundleIdentifier as CFString, + nil + )?.takeRetainedValue() as? [URL] else { return [] } + + var seen = Set() + return values.compactMap { url in + let standardizedURL = url.resolvingSymlinksInPath().standardizedFileURL + guard seen.insert(standardizedURL.path).inserted, + let bundle = Bundle(url: standardizedURL) else { return nil } + return CLIHostCandidate( + url: standardizedURL, + bundleIdentifier: bundle.bundleIdentifier, + version: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + build: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String + ) + } + } +} diff --git a/Sources/Core/CLI/CLIHostRequestRouter.swift b/Sources/Core/CLI/CLIHostRequestRouter.swift new file mode 100644 index 00000000..7baad62c --- /dev/null +++ b/Sources/Core/CLI/CLIHostRequestRouter.swift @@ -0,0 +1,749 @@ +import Foundation +import MacToolsPluginKit + +enum CLIActionCatalogProjection { + struct Group { + let key: ActionKey + let entries: [ActionCatalogEntry] + } + + static func groups(_ entries: [ActionCatalogEntry]) -> [Group] { + var indicesByKey: [ActionKey: Int] = [:] + var groups: [Group] = [] + for entry in entries { + if let index = indicesByKey[entry.reference.key] { + groups[index] = Group( + key: entry.reference.key, + entries: groups[index].entries + [entry] + ) + } else { + indicesByKey[entry.reference.key] = groups.count + groups.append(Group(key: entry.reference.key, entries: [entry])) + } + } + return groups + } +} + +@MainActor +final class CLIHostRequestRouter { + private let pluginHost: PluginHost + private let serviceStatus: () -> String + + init(pluginHost: PluginHost, serviceStatus: @escaping () -> String) { + self.pluginHost = pluginHost + self.serviceStatus = serviceStatus + } + + func handle(_ request: CLIRequestEnvelope) async -> CLIResponseEnvelope { + let startedAt = Date() + guard (CLIProtocolVersion.minimum...CLIProtocolVersion.current) + .contains(request.protocolVersion) else { + return .failure( + request: request, + outcome: .protocolIncompatible, + category: "protocolIncompatible", + message: "The CLI protocol version is not supported.", + startedAt: startedAt + ) + } + + do { + switch request.operation { + case .doctor: + return try response( + request, + startedAt: startedAt, + payload: CLIDoctorRecord( + hostVersion: AppMetadata.shortVersion ?? "unknown", + hostBuild: AppMetadata.buildNumber ?? "unknown", + protocolVersion: CLIProtocolVersion.current, + actionCount: cliCatalogGroups.count, + workflowCount: pluginHost.automationController.workflows.count, + pluginCount: pluginHost.pluginManagementItems.count, + brokerServiceStatus: serviceStatus() + ) + ) + case .actionsList: + let payload = try decode( + CLIActionListRequest.self, + request: request, + allowedKeys: ["runnableOnly", "continuationToken"] + ) + let actions = cliCatalogGroups.map(actionRecord) + .filter { !payload.runnableOnly || $0.cliEligibility.isAvailable } + .sorted { lhs, rhs in + if lhs.title == rhs.title { return lhs.reference.key.id < rhs.reference.key.id } + return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending + } + return try response( + request, + startedAt: startedAt, + payload: try page(actions, continuationToken: payload.continuationToken) + ) + case .actionsDescribe: + let payload = try actionTarget(request) + guard let record = actionRecord(for: payload.key) else { + return unknown(request, startedAt: startedAt, noun: "action") + } + return try response(request, startedAt: startedAt, payload: record) + case .actionsAvailability: + let payload = try actionTarget(request) + guard let record = actionRecord(for: payload.key) else { + return unknown(request, startedAt: startedAt, noun: "action") + } + return try response(request, startedAt: startedAt, payload: record.availability) + case .actionsRun: + let payload = try decode( + CLIActionRunRequest.self, + request: request, + allowedKeys: ["key", "parameters", "inputSource", "noWait"] + ) + return await withCLIInvocationContext(request) { + await runAction(payload, request: request, startedAt: startedAt) + } + case .workflowsList: + let list = try listRequest(request) + return try response( + request, + startedAt: startedAt, + payload: try page( + pluginHost.automationController.workflows + .map(workflowRecord) + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }, + continuationToken: list.continuationToken + ) + ) + case .workflowsDescribe: + let payload = try workflowTarget(request) + guard let workflow = resolveWorkflow(payload.nameOrID) else { + if let ambiguous = ambiguousWorkflowMessage(payload.nameOrID) { + return .failure( + request: request, + outcome: .invalidInput, + category: "ambiguousWorkflowName", + message: ambiguous, + startedAt: startedAt + ) + } + return unknown(request, startedAt: startedAt, noun: "workflow") + } + return try response(request, startedAt: startedAt, payload: workflowRecord(workflow)) + case .workflowsRun: + let payload = try decode( + CLIWorkflowRunRequest.self, + request: request, + allowedKeys: ["nameOrID", "noWait"] + ) + guard let workflow = resolveWorkflow(payload.nameOrID) else { + if let ambiguous = ambiguousWorkflowMessage(payload.nameOrID) { + return .failure( + request: request, + outcome: .invalidInput, + category: "ambiguousWorkflowName", + message: ambiguous, + startedAt: startedAt + ) + } + return unknown(request, startedAt: startedAt, noun: "workflow") + } + return await withCLIInvocationContext(request) { + await runAction( + CLIActionRunRequest( + key: CLIActionKey( + providerID: workflow.actionKey.providerID, + actionID: workflow.actionKey.actionID + ), + parameters: [:], + inputSource: .arguments, + noWait: payload.noWait + ), + request: request, + startedAt: startedAt + ) + } + case .pluginsList: + let list = try listRequest(request) + return try response( + request, + startedAt: startedAt, + payload: try page( + pluginHost.pluginManagementItems + .map(pluginRecord) + .sorted { $0.id < $1.id }, + continuationToken: list.continuationToken + ) + ) + case .pluginsDescribe, .pluginsDoctor: + let payload = try decode( + CLIPluginTargetRequest.self, + request: request, + allowedKeys: ["pluginID"] + ) + guard let item = pluginHost.pluginManagementItems.first(where: { $0.id == payload.pluginID }) else { + return unknown(request, startedAt: startedAt, noun: "plugin") + } + return try response(request, startedAt: startedAt, payload: pluginRecord(item)) + } + } catch { + return .failure( + request: request, + outcome: .invalidInput, + category: "invalidInput", + message: "The request payload is invalid.", + startedAt: startedAt + ) + } + } + + private func actionTarget(_ request: CLIRequestEnvelope) throws -> CLIActionTargetRequest { + try decode( + CLIActionTargetRequest.self, + request: request, + allowedKeys: ["key"] + ) + } + + private func listRequest(_ request: CLIRequestEnvelope) throws -> CLIListRequest { + try decode( + CLIListRequest.self, + request: request, + allowedKeys: ["continuationToken"] + ) + } + + private func page( + _ records: [Record], + continuationToken: String? + ) throws -> CLIPage { + let offset: Int + if let continuationToken { + guard let parsed = Int(continuationToken), parsed >= 0, parsed <= records.count else { + throw CLIProtocolCodecError.invalidObject + } + offset = parsed + } else { + offset = 0 + } + let end = min(offset + CLIProtocolVersion.maximumPageSize, records.count) + return CLIPage( + records: Array(records[offset.. CLIWorkflowTargetRequest { + try decode( + CLIWorkflowTargetRequest.self, + request: request, + allowedKeys: ["nameOrID"] + ) + } + + private func decode( + _ type: T.Type, + request: CLIRequestEnvelope, + allowedKeys: Set + ) throws -> T { + guard let payload = request.payload else { throw CLIProtocolCodecError.invalidObject } + return try CLIProtocolCodec.decodeRequest(type, from: payload, allowedKeys: allowedKeys) + } + + private func response( + _ request: CLIRequestEnvelope, + startedAt: Date, + payload: T, + actionReference: CLIActionReference? = nil, + outcome: CLIOutcome = .completed, + message: String? = nil, + finishedAt: Date? = .now + ) throws -> CLIResponseEnvelope { + CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: actionReference, + startedAt: startedAt, + finishedAt: finishedAt, + outcome: outcome, + message: message, + rejection: nil, + payload: try CLIProtocolCodec.encodeResponse(payload) + ) + } + + private func unknown( + _ request: CLIRequestEnvelope, + startedAt: Date, + noun: String + ) -> CLIResponseEnvelope { + .failure( + request: request, + outcome: .unknownTarget, + category: "unknown\(noun.capitalized)", + message: "The requested \(noun) was not found.", + startedAt: startedAt + ) + } + + private func actionRecord(for key: CLIActionKey) -> CLIActionRecord? { + cliCatalogGroups + .first(where: { group in + group.key.providerID == key.providerID + && group.key.actionID == key.actionID + }) + .map(actionRecord) + } + + private var cliCatalogGroups: [CLIActionCatalogProjection.Group] { + CLIActionCatalogProjection.groups(pluginHost.actionRegistry.catalogEntries) + } + + private func actionRecord(_ group: CLIActionCatalogProjection.Group) -> CLIActionRecord { + guard let definition = pluginHost.actionRegistry.definition(for: group.key) else { + let entry = group.entries[0] + let unavailable = CLIAvailabilityRecord( + isAvailable: false, + reason: "The action provider is not registered." + ) + return CLIActionRecord( + reference: CLIActionReference( + key: CLIActionKey( + providerID: entry.reference.key.providerID, + actionID: entry.reference.key.actionID + ), + schemaVersion: entry.reference.schemaVersion + ), + title: entry.title, + subtitle: entry.subtitle, + description: "Action provider is unavailable.", + systemImage: "questionmark.circle", + parameters: [], + availability: unavailable, + cliEligibility: unavailable, + capabilities: [], + externalInvocationPolicy: "unavailable" + ) + } + + let evaluations = group.entries.compactMap { entry -> ( + availability: CLIAvailabilityRecord, + eligibility: CLIAvailabilityRecord + )? in + guard case let .success(action) = pluginHost.actionRegistry.registeredAction( + for: entry.reference + ) else { return nil } + let availability = pluginHost.actionRegistry.availability(for: entry.reference) + return ( + CLIAvailabilityRecord( + isAvailable: availability.isAvailable, + reason: availability.reason + ), + cliEligibility(action: action, availability: availability) + ) + } + let availability = aggregateAvailability( + evaluations.map(\.availability), + fallbackReason: "No published preset is currently available." + ) + let eligibility = aggregateAvailability( + evaluations.map(\.eligibility), + fallbackReason: "No published preset is currently runnable from the CLI." + ) + let subtitles = Set(group.entries.map(\.subtitle)) + return CLIActionRecord( + reference: CLIActionReference( + key: CLIActionKey( + providerID: group.key.providerID, + actionID: group.key.actionID + ), + schemaVersion: definition.parameterSchemaVersion + ), + title: definition.title, + subtitle: subtitles.count == 1 ? group.entries[0].subtitle : nil, + description: definition.description, + systemImage: definition.systemImage, + parameters: definition.parameters.map(parameterRecord), + availability: availability, + cliEligibility: eligibility, + capabilities: capabilityNames(definition.capabilities), + externalInvocationPolicy: definition.externalInvocationPolicy.rawValue + ) + } + + private func aggregateAvailability( + _ records: [CLIAvailabilityRecord], + fallbackReason: String + ) -> CLIAvailabilityRecord { + if records.contains(where: \.isAvailable) { + return CLIAvailabilityRecord(isAvailable: true, reason: nil) + } + return CLIAvailabilityRecord( + isAvailable: false, + reason: records.compactMap(\.reason).first ?? fallbackReason + ) + } + + private func parameterRecord(_ definition: ActionParameterDefinition) -> CLIActionParameter { + CLIActionParameter( + id: definition.id, + title: definition.title, + kind: definition.kind.rawValue, + isRequired: definition.isRequired, + privacy: definition.privacy.rawValue, + portability: definition.portability.rawValue + ) + } + + private func cliEligibility( + action: RegisteredAction, + availability: ActionAvailability + ) -> CLIAvailabilityRecord { + guard action.catalogEntry != nil else { + return CLIAvailabilityRecord(isAvailable: false, reason: "Not published.") + } + guard action.definition.externalInvocationPolicy != .unavailable else { + return CLIAvailabilityRecord(isAvailable: false, reason: "External invocation is disabled.") + } + guard pluginHost.actionRegistry.exposurePolicy( + for: action.catalogEntry!.reference, + on: .cli + ) != .excluded else { + return CLIAvailabilityRecord(isAvailable: false, reason: "CLI invocation is disabled.") + } + return CLIAvailabilityRecord( + isAvailable: availability.isAvailable, + reason: availability.reason + ) + } + + private func runAction( + _ payload: CLIActionRunRequest, + request: CLIRequestEnvelope, + startedAt: Date + ) async -> CLIResponseEnvelope { + guard let catalogEntry = pluginHost.actionRegistry.catalogEntries.first(where: { + $0.reference.key.providerID == payload.key.providerID + && $0.reference.key.actionID == payload.key.actionID + }), + case let .success(registered) = pluginHost.actionRegistry.registeredAction(for: catalogEntry.reference) + else { return unknown(request, startedAt: startedAt, noun: "action") } + + let definitions = Dictionary( + uniqueKeysWithValues: registered.definition.parameters.map { ($0.id, $0) } + ) + let responseReference = CLIActionReference( + key: payload.key, + schemaVersion: registered.definition.parameterSchemaVersion + ) + if payload.inputSource == .arguments, + payload.parameters.keys.contains(where: { definitions[$0]?.privacy == .sensitive }) { + return .failure( + request: request, + outcome: .invalidInput, + category: "sensitiveParameterInArguments", + message: "Sensitive parameters must be read from standard input or a protected file.", + actionReference: responseReference, + startedAt: startedAt + ) + } + + let parameterValues = payload.parameters.mapValues(actionParameterValue) + let parameterSet: ActionParameterSet + do { + parameterSet = try ActionParameterSet(parameterValues) + } catch { + return .failure( + request: request, + outcome: .invalidInput, + category: "invalidParameters", + message: "The action parameters are invalid.", + actionReference: responseReference, + startedAt: startedAt + ) + } + let reference = ActionReference( + key: registered.definition.key, + schemaVersion: registered.definition.parameterSchemaVersion, + parameters: parameterSet + ) + let mode: ActionExecutionMode = registered.definition.capabilities.contains(.background) + ? .background + : .foreground + let invocation = ActionInvocation(reference: reference, source: .cli, mode: mode) + let suppliedValues = payload.parameters.values.map(printable) + + if payload.noWait { + guard registered.definition.capabilities.contains(.reportsProgress) else { + return .failure( + request: request, + outcome: .invalidInput, + category: "noWaitUnsupported", + message: "This action does not own durable progress.", + actionReference: responseReference, + startedAt: startedAt + ) + } + let result = await pluginHost.actionExecutor.startContinuingTrackingCompletion( + invocation, + expectedDefinition: registered.definition + ) + switch result.outcome { + case .started: + return (try? response( + request, + startedAt: startedAt, + payload: ["accepted": true], + actionReference: responseReference, + outcome: .started, + message: "Action started.", + finishedAt: nil + )) ?? .failure( + request: request, + outcome: .failed, + category: "responseEncodingFailed", + message: nil, + actionReference: responseReference, + startedAt: startedAt + ) + case .cancelled: + return terminal( + request, + startedAt: startedAt, + outcome: .cancelled, + message: nil, + actionReference: responseReference + ) + case let .rejected(rejection): + return rejectionResponse( + rejection, + request: request, + startedAt: startedAt, + sensitiveValues: suppliedValues, + actionReference: responseReference + ) + } + } + + let outcome = await pluginHost.actionExecutor.execute(invocation) + switch outcome { + case let .completed(.succeeded(message)): + return terminal( + request, + startedAt: startedAt, + outcome: .completed, + message: redacted(message, values: suppliedValues), + actionReference: responseReference + ) + case let .completed(.failed(message)): + return .failure( + request: request, + outcome: .failed, + category: "actionFailure", + message: redacted(message, values: suppliedValues), + actionReference: responseReference, + startedAt: startedAt + ) + case .completed(.cancelled): + return terminal( + request, + startedAt: startedAt, + outcome: .cancelled, + message: nil, + actionReference: responseReference + ) + case let .rejected(rejection): + return rejectionResponse( + rejection, + request: request, + startedAt: startedAt, + sensitiveValues: suppliedValues, + actionReference: responseReference + ) + } + } + + private func withCLIInvocationContext( + _ request: CLIRequestEnvelope, + operation: () async -> T + ) async -> T { + guard let context = request.invocationContext else { + return await operation() + } + return await PluginActionExecutionContext.$cliInvocation.withValue( + PluginCLIInvocationContext( + chainID: context.chainID, + depth: context.depth + ) + ) { + await operation() + } + } + + private func terminal( + _ request: CLIRequestEnvelope, + startedAt: Date, + outcome: CLIOutcome, + message: String?, + actionReference: CLIActionReference? + ) -> CLIResponseEnvelope { + CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: actionReference, + startedAt: startedAt, + finishedAt: .now, + outcome: outcome, + message: message, + rejection: nil, + payload: nil + ) + } + + private func rejectionResponse( + _ rejection: ActionExecutionRejection, + request: CLIRequestEnvelope, + startedAt: Date, + sensitiveValues: [String], + actionReference: CLIActionReference + ) -> CLIResponseEnvelope { + let mapped: (CLIOutcome, String, String?) + switch rejection { + case .unknownAction: mapped = (.unknownTarget, "unknownAction", "The action was not found.") + case let .invalidParameters(reason): mapped = (.invalidInput, "invalidParameters", reason) + case let .unavailable(reason): mapped = (.unavailable, "actionUnavailable", reason) + case .confirmationDenied: mapped = (.confirmationDenied, "confirmationDenied", nil) + case .confirmationUnavailable: mapped = (.confirmationDenied, "confirmationUnavailable", nil) + case .confirmationTimedOut: mapped = (.timedOut, "confirmationTimedOut", nil) + case .executionTimedOut: mapped = (.timedOut, "executionTimedOut", nil) + case .providerChanged: mapped = (.providerChanged, "providerChanged", nil) + case let .providerFailure(message): mapped = (.failed, "providerFailure", message) + case .backgroundExecutionUnsupported, .foregroundExecutionUnsupported, + .automaticExecutionUnsupported, .confirmationRequiredForAutomaticExecution, + .externalInvocationUnavailable, .systemExposureUnavailable, .actionAlreadyRunning: + mapped = (.unavailable, "actionUnavailable", nil) + } + return .failure( + request: request, + outcome: mapped.0, + category: mapped.1, + message: redacted(mapped.2, values: sensitiveValues), + actionReference: actionReference, + startedAt: startedAt + ) + } + + private func workflowRecord(_ workflow: WorkflowDefinition) -> CLIWorkflowRecord { + let availability = pluginHost.actionRegistry.availability(for: workflow.actionReference) + return CLIWorkflowRecord( + id: workflow.id, + name: workflow.name, + isEnabled: workflow.isEnabled, + stepCount: workflow.steps.count, + actionReference: CLIActionReference( + key: CLIActionKey( + providerID: workflow.actionKey.providerID, + actionID: workflow.actionKey.actionID + ), + schemaVersion: 1 + ), + availability: CLIAvailabilityRecord( + isAvailable: availability.isAvailable, + reason: availability.reason + ) + ) + } + + private func resolveWorkflow(_ nameOrID: String) -> WorkflowDefinition? { + if let id = UUID(uuidString: nameOrID), + let match = pluginHost.automationController.workflows.first(where: { $0.id == id }) { + return match + } + let matches = pluginHost.automationController.workflows.filter { $0.name == nameOrID } + return matches.count == 1 ? matches[0] : nil + } + + private func ambiguousWorkflowMessage(_ name: String) -> String? { + let matches = pluginHost.automationController.workflows.filter { $0.name == name } + guard matches.count > 1 else { return nil } + let identifiers = matches.map { $0.id.uuidString.lowercased() }.sorted() + return "Workflow name is ambiguous. Use one of these IDs: \(identifiers.joined(separator: ", "))." + } + + private func pluginRecord(_ item: PluginManagementItem) -> CLIPluginRecord { + let state: String + let diagnostic: String? + switch item.state { + case .available: state = "available"; diagnostic = nil + case .localDevelopment: state = "localDevelopment"; diagnostic = nil + case .installed: state = "installed"; diagnostic = nil + case .updateAvailable: state = "updateAvailable"; diagnostic = nil + case .restartRequired: state = "restartRequired"; diagnostic = nil + case let .failed(message): state = "failed"; diagnostic = message + case let .incompatible(message): state = "incompatible"; diagnostic = message + case let .revoked(message): state = "revoked"; diagnostic = message + } + return CLIPluginRecord( + id: item.id, + title: item.title, + summary: item.summary, + version: item.version, + state: state, + diagnostic: diagnostic, + requiresRestart: item.requiresRestartToFullyUnload, + permissions: pluginHost.permissionCards + .filter { $0.pluginID == item.id } + .map { + CLIPluginPermissionRecord( + id: $0.permissionID, + title: $0.title, + isGranted: $0.statusTone == .positive, + status: $0.statusText + ) + }, + publishedActionCount: cliCatalogGroups.filter { + $0.key.providerID == item.id + }.count + ) + } + + private func actionParameterValue(_ value: CLIParameterValue) -> ActionParameterValue { + switch value { + case let .string(value): .string(value) + case let .integer(value): .integer(value) + case let .double(value): .double(value) + case let .boolean(value): .boolean(value) + } + } + + private func printable(_ value: CLIParameterValue) -> String { + switch value { + case let .string(value): value + case let .integer(value): String(value) + case let .double(value): String(value) + case let .boolean(value): String(value) + } + } + + private func redacted(_ message: String?, values: [String]) -> String? { + guard var message else { return nil } + for value in values where !value.isEmpty { + message = message.replacingOccurrences(of: value, with: "") + } + return message + } + + private func capabilityNames(_ capabilities: ActionExecutionCapabilities) -> [String] { + var names: [String] = [] + if capabilities.contains(.background) { names.append("background") } + if capabilities.contains(.foregroundInteractive) { names.append("foregroundInteractive") } + if capabilities.contains(.cancellable) { names.append("cancellable") } + if capabilities.contains(.reportsProgress) { names.append("reportsProgress") } + if capabilities.contains(.changesDisplayConfiguration) { names.append("changesDisplayConfiguration") } + if capabilities.contains(.automatic) { names.append("automatic") } + return names + } +} diff --git a/Sources/Core/CLI/CLIPeerIdentityValidator.swift b/Sources/Core/CLI/CLIPeerIdentityValidator.swift new file mode 100644 index 00000000..257e1bd7 --- /dev/null +++ b/Sources/Core/CLI/CLIPeerIdentityValidator.swift @@ -0,0 +1,216 @@ +import Foundation +import Security + +struct CLIPeerIdentity: Equatable { + let processIdentifier: pid_t + let effectiveUserIdentifier: uid_t + let signingIdentifier: String + let teamIdentifier: String +} + +enum CLIPeerRole { + case host + case commandLineTool + case broker +} + +enum CLIHostIdentityAssessment: Equatable { + case accepted + case invalidSignature + case wrongTeam + case wrongRole +} + +struct CLIPeerIdentityValidator { + private let allowsUnverifiedPeersForTesting: Bool + + init() { + allowsUnverifiedPeersForTesting = false + } + + #if DEBUG + init(allowsUnverifiedPeersForTesting: Bool) { + self.allowsUnverifiedPeersForTesting = allowsUnverifiedPeersForTesting + } + #endif + + func identity(for connection: NSXPCConnection) -> CLIPeerIdentity? { + identity( + processIdentifier: connection.processIdentifier, + effectiveUserIdentifier: connection.effectiveUserIdentifier + ) + } + + func currentIdentity() -> CLIPeerIdentity? { + identity(processIdentifier: getpid(), effectiveUserIdentifier: geteuid()) + } + + func acceptsApplication( + at applicationURL: URL, + as role: CLIPeerRole, + relativeTo currentIdentity: CLIPeerIdentity? = nil + ) -> Bool { + applicationIdentityAssessment( + at: applicationURL, + as: role, + relativeTo: currentIdentity + ) == .accepted + } + + func applicationIdentityAssessment( + at applicationURL: URL, + as role: CLIPeerRole, + relativeTo currentIdentity: CLIPeerIdentity? = nil + ) -> CLIHostIdentityAssessment { + guard let currentIdentity = currentIdentity ?? self.currentIdentity() else { + return allowsUnverifiedPeersForTesting ? .accepted : .invalidSignature + } + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(applicationURL as CFURL, [], &staticCode) == errSecSuccess, + let staticCode, + SecStaticCodeCheckValidity( + staticCode, + SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures), + nil + ) == errSecSuccess, + let metadata = signingMetadata(for: staticCode) + else { + return allowsUnverifiedPeersForTesting ? .accepted : .invalidSignature + } + guard metadata.teamIdentifier == currentIdentity.teamIdentifier else { return .wrongTeam } + guard metadata.signingIdentifier == expectedSigningIdentifier( + for: role, + brokerIdentifier: currentIdentity.signingIdentifier + ) else { return .wrongRole } + return .accepted + } + + func accepts( + _ connection: NSXPCConnection, + as role: CLIPeerRole, + brokerIdentity: CLIPeerIdentity? = nil + ) -> Bool { + guard connection.effectiveUserIdentifier == geteuid() else { return false } + if let identity = identity(for: connection), + let brokerIdentity = brokerIdentity ?? currentIdentity() { + return matches(identity, as: role, relativeTo: brokerIdentity) + } + return allowsUnverifiedPeersForTesting + } + + func matches( + _ identity: CLIPeerIdentity, + as role: CLIPeerRole, + relativeTo brokerIdentity: CLIPeerIdentity + ) -> Bool { + identity.effectiveUserIdentifier == brokerIdentity.effectiveUserIdentifier + && identity.teamIdentifier == brokerIdentity.teamIdentifier + && identity.signingIdentifier == expectedSigningIdentifier( + for: role, + brokerIdentifier: brokerIdentity.signingIdentifier + ) + } + + func configure( + _ connection: NSXPCConnection, + toRequire role: CLIPeerRole, + currentIdentity: CLIPeerIdentity? = nil + ) -> Bool { + guard let currentIdentity = currentIdentity ?? self.currentIdentity() else { + return allowsUnverifiedPeersForTesting + } + let identifier = expectedSigningIdentifier( + for: role, + brokerIdentifier: currentIdentity.signingIdentifier + ) + let requirement = requirementString( + signingIdentifier: identifier, + teamIdentifier: currentIdentity.teamIdentifier + ) + connection.setCodeSigningRequirement(requirement) + return true + } + + func brokerListenerRequirement() -> String? { + guard let identity = currentIdentity() else { return nil } + let hostIdentifier = expectedSigningIdentifier( + for: .host, + brokerIdentifier: identity.signingIdentifier + ) + let cliIdentifier = expectedSigningIdentifier( + for: .commandLineTool, + brokerIdentifier: identity.signingIdentifier + ) + let team = escapedRequirementValue(identity.teamIdentifier) + return "anchor apple generic and certificate leaf[subject.OU] = \"\(team)\" and " + + "(identifier \"\(escapedRequirementValue(hostIdentifier))\" or " + + "identifier \"\(escapedRequirementValue(cliIdentifier))\")" + } + + func expectedSigningIdentifier(for role: CLIPeerRole, brokerIdentifier: String) -> String { + let hostIdentifier: String + if brokerIdentifier.hasSuffix(".cli-broker") { + hostIdentifier = String(brokerIdentifier.dropLast(".cli-broker".count)) + } else if brokerIdentifier.hasSuffix(".cli") { + hostIdentifier = String(brokerIdentifier.dropLast(".cli".count)) + } else { + hostIdentifier = brokerIdentifier + } + switch role { + case .host: return hostIdentifier + case .commandLineTool: return "\(hostIdentifier).cli" + case .broker: return "\(hostIdentifier).cli-broker" + } + } + + private func identity( + processIdentifier: pid_t, + effectiveUserIdentifier: uid_t + ) -> CLIPeerIdentity? { + let attributes = [kSecGuestAttributePid: NSNumber(value: processIdentifier)] as CFDictionary + var code: SecCode? + guard SecCodeCopyGuestWithAttributes(nil, attributes, [], &code) == errSecSuccess, + let code, + SecCodeCheckValidity(code, SecCSFlags(rawValue: kSecCSStrictValidate), nil) == errSecSuccess + else { return nil } + + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, + let staticCode else { return nil } + guard let metadata = signingMetadata(for: staticCode) else { return nil } + + return CLIPeerIdentity( + processIdentifier: processIdentifier, + effectiveUserIdentifier: effectiveUserIdentifier, + signingIdentifier: metadata.signingIdentifier, + teamIdentifier: metadata.teamIdentifier + ) + } + + private func signingMetadata( + for staticCode: SecStaticCode + ) -> (signingIdentifier: String, teamIdentifier: String)? { + var information: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) == errSecSuccess, + let values = information as? [CFString: Any], + let signingIdentifier = values[kSecCodeInfoIdentifier] as? String, + let teamIdentifier = values[kSecCodeInfoTeamIdentifier] as? String, + !signingIdentifier.isEmpty, + !teamIdentifier.isEmpty else { return nil } + return (signingIdentifier, teamIdentifier) + } + + private func requirementString(signingIdentifier: String, teamIdentifier: String) -> String { + "anchor apple generic and identifier \"\(escapedRequirementValue(signingIdentifier))\" " + + "and certificate leaf[subject.OU] = \"\(escapedRequirementValue(teamIdentifier))\"" + } + + private func escapedRequirementValue(_ value: String) -> String { + value.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Sources/Core/CLI/CLIProtocolCodec.swift b/Sources/Core/CLI/CLIProtocolCodec.swift new file mode 100644 index 00000000..fd6f3ff2 --- /dev/null +++ b/Sources/Core/CLI/CLIProtocolCodec.swift @@ -0,0 +1,281 @@ +import Foundation + +enum CLIProtocolCodecError: Error, Equatable { + case payloadTooLarge + case responseTooLarge + case invalidObject + case unknownFields([String]) + case duplicateFields([String]) + case encodingFailed +} + +enum CLIProtocolCodec { + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + let format = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(date.formatted(format)) + } + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + let format = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let date = try? format.parse(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected an RFC 3339 timestamp with fractional seconds." + ) + } + return date + } + return decoder + } + + static func timestamp(_ date: Date) -> String { + date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)) + } + + static func encodeRequest(_ value: T) throws -> Data { + let data = try makeEncoder().encode(value) + guard data.count <= CLIProtocolVersion.maximumRequestBytes else { + throw CLIProtocolCodecError.payloadTooLarge + } + return data + } + + static func encodeResponse(_ value: T) throws -> Data { + let data = try makeEncoder().encode(value) + guard data.count <= CLIProtocolVersion.maximumResponseBytes else { + throw CLIProtocolCodecError.responseTooLarge + } + return data + } + + static func decodeRequest( + _ type: T.Type, + from data: Data, + allowedKeys: Set? = nil + ) throws -> T { + guard data.count <= CLIProtocolVersion.maximumRequestBytes else { + throw CLIProtocolCodecError.payloadTooLarge + } + if let allowedKeys { + try rejectUnknownFields(in: data, allowedKeys: allowedKeys) + } + return try makeDecoder().decode(type, from: data) + } + + static func decodeResponse( + _ type: T.Type, + from data: Data, + allowedKeys: Set? = nil + ) throws -> T { + guard data.count <= CLIProtocolVersion.maximumResponseBytes else { + throw CLIProtocolCodecError.responseTooLarge + } + if let allowedKeys { + try rejectUnknownFields(in: data, allowedKeys: allowedKeys) + } + return try makeDecoder().decode(type, from: data) + } + + static func rejectDuplicateTopLevelFields(in data: Data) throws { + let duplicates = Dictionary(grouping: try topLevelKeys(in: data), by: { $0 }) + .filter { $0.value.count > 1 } + .map(\.key) + .sorted() + guard duplicates.isEmpty else { + throw CLIProtocolCodecError.duplicateFields(duplicates) + } + } + + static func rejectDuplicateFieldsRecursively(in data: Data) throws { + var scanner = CLIJSONDuplicateFieldScanner(data: data) + try scanner.scan() + } + + private static func rejectUnknownFields(in data: Data, allowedKeys: Set) throws { + try rejectDuplicateTopLevelFields(in: data) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIProtocolCodecError.invalidObject + } + let unknown = Set(object.keys).subtracting(allowedKeys).sorted() + guard unknown.isEmpty else { + throw CLIProtocolCodecError.unknownFields(unknown) + } + } + + private static func topLevelKeys(in data: Data) throws -> [String] { + let bytes = Array(data) + var keys: [String] = [] + var objectDepth = 0 + var arrayDepth = 0 + var expectingKey = false + var index = 0 + while index < bytes.count { + switch bytes[index] { + case 0x7B: // { + objectDepth += 1 + if objectDepth == 1 { expectingKey = true } + index += 1 + case 0x7D: // } + objectDepth -= 1 + index += 1 + case 0x5B: // [ + arrayDepth += 1 + index += 1 + case 0x5D: // ] + arrayDepth -= 1 + index += 1 + case 0x2C where objectDepth == 1 && arrayDepth == 0: // , + expectingKey = true + index += 1 + case 0x22: // " + let start = index + index += 1 + var escaped = false + while index < bytes.count { + let byte = bytes[index] + if escaped { + escaped = false + } else if byte == 0x5C { + escaped = true + } else if byte == 0x22 { + break + } + index += 1 + } + guard index < bytes.count else { throw CLIProtocolCodecError.invalidObject } + if objectDepth == 1, arrayDepth == 0, expectingKey { + let quoted = Data(bytes[start...index]) + guard let key = try? makeDecoder().decode(String.self, from: quoted) else { + throw CLIProtocolCodecError.invalidObject + } + keys.append(key) + expectingKey = false + } + index += 1 + default: + index += 1 + } + guard objectDepth >= 0, arrayDepth >= 0 else { + throw CLIProtocolCodecError.invalidObject + } + } + return keys + } +} + +private struct CLIJSONDuplicateFieldScanner { + private let bytes: [UInt8] + private var index = 0 + + init(data: Data) { + bytes = Array(data) + } + + mutating func scan() throws { + skipWhitespace() + try scanValue() + skipWhitespace() + guard index == bytes.count else { throw CLIProtocolCodecError.invalidObject } + } + + private mutating func scanValue() throws { + skipWhitespace() + guard index < bytes.count else { throw CLIProtocolCodecError.invalidObject } + switch bytes[index] { + case 0x7B: try scanObject() // { + case 0x5B: try scanArray() // [ + case 0x22: _ = try scanString() // " + default: try scanPrimitive() + } + } + + private mutating func scanObject() throws { + index += 1 + skipWhitespace() + if consume(0x7D) { return } + var keys = Set() + while true { + skipWhitespace() + guard index < bytes.count, bytes[index] == 0x22 else { + throw CLIProtocolCodecError.invalidObject + } + let key = try scanString() + guard keys.insert(key).inserted else { + throw CLIProtocolCodecError.duplicateFields([key]) + } + skipWhitespace() + guard consume(0x3A) else { throw CLIProtocolCodecError.invalidObject } // : + try scanValue() + skipWhitespace() + if consume(0x7D) { return } + guard consume(0x2C) else { throw CLIProtocolCodecError.invalidObject } // , + } + } + + private mutating func scanArray() throws { + index += 1 + skipWhitespace() + if consume(0x5D) { return } + while true { + try scanValue() + skipWhitespace() + if consume(0x5D) { return } + guard consume(0x2C) else { throw CLIProtocolCodecError.invalidObject } + } + } + + private mutating func scanString() throws -> String { + let start = index + index += 1 + var escaped = false + while index < bytes.count { + let byte = bytes[index] + if escaped { + escaped = false + } else if byte == 0x5C { + escaped = true + } else if byte == 0x22 { + let quoted = Data(bytes[start...index]) + index += 1 + guard let value = try? JSONDecoder().decode(String.self, from: quoted) else { + throw CLIProtocolCodecError.invalidObject + } + return value + } + index += 1 + } + throw CLIProtocolCodecError.invalidObject + } + + private mutating func scanPrimitive() throws { + let start = index + while index < bytes.count, + ![0x20, 0x09, 0x0A, 0x0D, 0x2C, 0x5D, 0x7D].contains(bytes[index]) { + index += 1 + } + guard index > start else { throw CLIProtocolCodecError.invalidObject } + } + + private mutating func skipWhitespace() { + while index < bytes.count, + [0x20, 0x09, 0x0A, 0x0D].contains(bytes[index]) { + index += 1 + } + } + + private mutating func consume(_ byte: UInt8) -> Bool { + guard index < bytes.count, bytes[index] == byte else { return false } + index += 1 + return true + } +} diff --git a/Sources/Core/CLI/CLIProtocolModels.swift b/Sources/Core/CLI/CLIProtocolModels.swift new file mode 100644 index 00000000..4f9e6206 --- /dev/null +++ b/Sources/Core/CLI/CLIProtocolModels.swift @@ -0,0 +1,410 @@ +import Foundation + +enum CLIProtocolVersion { + static let minimum = 1 + static let current = 1 + static let maximumRequestBytes = 64 * 1_024 + static let maximumResponseBytes = 4 * 1_024 * 1_024 + static let maximumPageSize = 256 + static let maximumInvocationDepth = 1 +} + +enum CLIProtocolNegotiator { + static func selectedVersion( + clientMinimum: Int, + clientMaximum: Int, + brokerMinimum: Int = CLIProtocolVersion.minimum, + brokerMaximum: Int = CLIProtocolVersion.current, + hostMinimum: Int? = nil, + hostMaximum: Int? = nil + ) -> Int? { + var minimum = max(clientMinimum, brokerMinimum) + var maximum = min(clientMaximum, brokerMaximum) + if let hostMinimum, let hostMaximum { + minimum = max(minimum, hostMinimum) + maximum = min(maximum, hostMaximum) + } else if hostMinimum != nil || hostMaximum != nil { + return nil + } + return minimum <= maximum ? maximum : nil + } +} + +enum CLIOperation: String, Codable, CaseIterable, Sendable { + case doctor + case actionsList = "actions.list" + case actionsDescribe = "actions.describe" + case actionsAvailability = "actions.availability" + case actionsRun = "actions.run" + case workflowsList = "workflows.list" + case workflowsDescribe = "workflows.describe" + case workflowsRun = "workflows.run" + case pluginsList = "plugins.list" + case pluginsDescribe = "plugins.describe" + case pluginsDoctor = "plugins.doctor" +} + +enum CLIOutcome: String, Codable, Sendable { + case completed + case started + case cancelled + case unavailable + case confirmationDenied + case timedOut + case invalidInput + case unknownTarget + case failed + case hostUnavailable + case providerChanged + case protocolIncompatible +} + +enum CLIExitCode: Int32, Codable, Sendable { + case success = 0 + case invalidInput = 2 + case unknownTarget = 3 + case unavailable = 4 + case confirmationFailure = 5 + case actionFailure = 6 + case timeout = 7 + case cancellation = 8 + case transportFailure = 9 + case protocolIncompatible = 10 +} + +struct CLIHandshakeRequest: Codable, Equatable, Sendable { + let minimumProtocolVersion: Int + let maximumProtocolVersion: Int + let clientVersion: String + let clientBuild: String +} + +struct CLIHandshakeResponse: Codable, Equatable, Sendable { + let selectedProtocolVersion: Int? + let brokerVersion: String + let brokerBuild: String + let hostVersion: String? + let hostBuild: String? + let hostReady: Bool + let message: String? +} + +struct CLIHostRegistration: Codable, Equatable, Sendable { + let minimumProtocolVersion: Int + let maximumProtocolVersion: Int + let hostVersion: String + let hostBuild: String +} + +struct CLIRequestEnvelope: Codable, Equatable, Sendable { + let protocolVersion: Int + let requestID: UUID + let operation: CLIOperation + let sentAt: Date + let invocationContext: CLIInvocationContext? + let payload: Data? + + init( + protocolVersion: Int, + requestID: UUID, + operation: CLIOperation, + sentAt: Date, + invocationContext: CLIInvocationContext? = nil, + payload: Data? + ) { + self.protocolVersion = protocolVersion + self.requestID = requestID + self.operation = operation + self.sentAt = sentAt + self.invocationContext = invocationContext + self.payload = payload + } + + func replacingInvocationContext(_ invocationContext: CLIInvocationContext) -> Self { + Self( + protocolVersion: protocolVersion, + requestID: requestID, + operation: operation, + sentAt: sentAt, + invocationContext: invocationContext, + payload: payload + ) + } +} + +struct CLIInvocationContext: Codable, Equatable, Sendable { + static let chainEnvironmentKey = "MACTOOLS_CLI_CHAIN_ID" + static let depthEnvironmentKey = "MACTOOLS_CLI_CHAIN_DEPTH" + + let chainID: UUID + let depth: Int + + static func inherited( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Self? { + let chainValue = environment[chainEnvironmentKey] + let depthValue = environment[depthEnvironmentKey] + guard chainValue != nil || depthValue != nil else { return nil } + guard let chainValue, + let chainID = UUID(uuidString: chainValue), + let depthValue, + let depth = Int(depthValue), + depth > 0, + depth <= CLIProtocolVersion.maximumInvocationDepth else { + throw CLIInvocationContextError.invalidEnvironment + } + return Self(chainID: chainID, depth: depth) + } +} + +enum CLIInvocationContextError: Error, Equatable { + case invalidEnvironment +} + +struct CLIRejection: Codable, Equatable, Sendable { + let category: String + let message: String? +} + +struct CLIResponseEnvelope: Codable, Equatable, Sendable { + let schemaVersion: Int + let protocolVersion: Int? + let requestID: UUID + let operation: CLIOperation + let actionReference: CLIActionReference? + let startedAt: Date + let finishedAt: Date? + let outcome: CLIOutcome + let message: String? + let rejection: CLIRejection? + let payload: Data? + + static func failure( + request: CLIRequestEnvelope, + outcome: CLIOutcome, + category: String, + message: String?, + actionReference: CLIActionReference? = nil, + startedAt: Date = .now + ) -> Self { + Self( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: actionReference, + startedAt: startedAt, + finishedAt: .now, + outcome: outcome, + message: message, + rejection: CLIRejection(category: category, message: message), + payload: nil + ) + } + + func replacingOperation(_ operation: CLIOperation) -> Self { + Self( + schemaVersion: schemaVersion, + protocolVersion: protocolVersion, + requestID: requestID, + operation: operation, + actionReference: actionReference, + startedAt: startedAt, + finishedAt: finishedAt, + outcome: outcome, + message: message, + rejection: rejection, + payload: payload + ) + } +} + +struct CLIActionKey: Codable, Hashable, Sendable { + let providerID: String + let actionID: String + + var id: String { "\(providerID)/\(actionID)" } + + init(providerID: String, actionID: String) { + self.providerID = providerID + self.actionID = actionID + } + + init?(id: String) { + let parts = id.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { return nil } + providerID = String(parts[0]) + actionID = String(parts[1]) + } +} + +struct CLIActionReference: Codable, Hashable, Sendable { + let key: CLIActionKey + let schemaVersion: Int +} + +struct CLIActionParameter: Codable, Equatable, Sendable { + let id: String + let title: String + let kind: String + let isRequired: Bool + let privacy: String + let portability: String +} + +struct CLIActionRecord: Codable, Equatable, Sendable { + let reference: CLIActionReference + let title: String + let subtitle: String? + let description: String + let systemImage: String + let parameters: [CLIActionParameter] + let availability: CLIAvailabilityRecord + let cliEligibility: CLIAvailabilityRecord + let capabilities: [String] + let externalInvocationPolicy: String +} + +struct CLIAvailabilityRecord: Codable, Equatable, Sendable { + let isAvailable: Bool + let reason: String? +} + +struct CLIActionListRequest: Codable, Equatable, Sendable { + let runnableOnly: Bool + let continuationToken: String? +} + +struct CLIListRequest: Codable, Equatable, Sendable { + let continuationToken: String? +} + +struct CLIPage: Codable, Equatable, Sendable { + let records: [Record] + let continuationToken: String? +} + +struct CLIActionTargetRequest: Codable, Equatable, Sendable { + let key: CLIActionKey +} + +enum CLIParameterValue: Codable, Equatable, Sendable { + case string(String) + case integer(Int64) + case double(Double) + case boolean(Bool) + + private enum CodingKeys: String, CodingKey { case kind, value } + private enum Kind: String, Codable { case string, integer, double, boolean } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(Kind.self, forKey: .kind) { + case .string: self = .string(try container.decode(String.self, forKey: .value)) + case .integer: self = .integer(try container.decode(Int64.self, forKey: .value)) + case .double: + let value = try container.decode(Double.self, forKey: .value) + guard value.isFinite else { + throw DecodingError.dataCorruptedError( + forKey: .value, + in: container, + debugDescription: "A double parameter must be finite." + ) + } + self = .double(value) + case .boolean: self = .boolean(try container.decode(Bool.self, forKey: .value)) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .string(value): + try container.encode(Kind.string, forKey: .kind) + try container.encode(value, forKey: .value) + case let .integer(value): + try container.encode(Kind.integer, forKey: .kind) + try container.encode(value, forKey: .value) + case let .double(value): + guard value.isFinite else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "A double parameter must be finite." + ) + ) + } + try container.encode(Kind.double, forKey: .kind) + try container.encode(value, forKey: .value) + case let .boolean(value): + try container.encode(Kind.boolean, forKey: .kind) + try container.encode(value, forKey: .value) + } + } +} + +enum CLIParameterInputSource: String, Codable, Sendable { + case arguments + case standardInput + case protectedFile +} + +struct CLIActionRunRequest: Codable, Equatable, Sendable { + let key: CLIActionKey + let parameters: [String: CLIParameterValue] + let inputSource: CLIParameterInputSource + let noWait: Bool +} + +struct CLIWorkflowTargetRequest: Codable, Equatable, Sendable { + let nameOrID: String +} + +struct CLIWorkflowRunRequest: Codable, Equatable, Sendable { + let nameOrID: String + let noWait: Bool +} + +struct CLIWorkflowRecord: Codable, Equatable, Sendable { + let id: UUID + let name: String + let isEnabled: Bool + let stepCount: Int + let actionReference: CLIActionReference + let availability: CLIAvailabilityRecord +} + + +struct CLIPluginTargetRequest: Codable, Equatable, Sendable { + let pluginID: String +} + +struct CLIPluginRecord: Codable, Equatable, Sendable { + let id: String + let title: String + let summary: String? + let version: String + let state: String + let diagnostic: String? + let requiresRestart: Bool + let permissions: [CLIPluginPermissionRecord] + let publishedActionCount: Int +} + +struct CLIPluginPermissionRecord: Codable, Equatable, Sendable { + let id: String + let title: String + let isGranted: Bool + let status: String +} + +struct CLIDoctorRecord: Codable, Equatable, Sendable { + let hostVersion: String + let hostBuild: String + let protocolVersion: Int + let actionCount: Int + let workflowCount: Int + let pluginCount: Int + let brokerServiceStatus: String +} diff --git a/Sources/Core/CLI/CLIRequestAdmissionState.swift b/Sources/Core/CLI/CLIRequestAdmissionState.swift new file mode 100644 index 00000000..68de0796 --- /dev/null +++ b/Sources/Core/CLI/CLIRequestAdmissionState.swift @@ -0,0 +1,120 @@ +import Foundation + +struct CLIRequestAdmissionState { + enum Rejection: Equatable { + case duplicateRequestID + case clientCapacity + case globalCapacity + case recursiveInvocation + case invalidInvocationContext + case cancelledBeforeAdmission + } + + enum CancellationDisposition: Equatable { + case recorded + case forwardToHost + } + + private(set) var activeRequests: [UUID: ClientID] = [:] + private(set) var activeInvocationContexts: [UUID: CLIInvocationContext] = [:] + private(set) var pendingCancellations: [UUID: ClientID] = [:] + private(set) var activeCancellations: Set = [] + private(set) var forwardedRequests: Set = [] + let maximumRequestsPerClient: Int + let maximumRequestsGlobally: Int + + init(maximumRequestsPerClient: Int = 8, maximumRequestsGlobally: Int = 32) { + self.maximumRequestsPerClient = maximumRequestsPerClient + self.maximumRequestsGlobally = maximumRequestsGlobally + } + + mutating func admit( + requestID: UUID, + clientID: ClientID, + invocationContext: CLIInvocationContext? = nil + ) -> Rejection? { + if pendingCancellations[requestID] == clientID { + pendingCancellations[requestID] = nil + return .cancelledBeforeAdmission + } + guard activeRequests[requestID] == nil else { return .duplicateRequestID } + if let invocationContext { + guard invocationContext.depth > 0, + invocationContext.depth <= CLIProtocolVersion.maximumInvocationDepth else { + return .invalidInvocationContext + } + if activeInvocationContexts.values.contains(where: { + $0.chainID == invocationContext.chainID + }) { + return .recursiveInvocation + } + return .invalidInvocationContext + } + guard activeRequests.count < maximumRequestsGlobally else { return .globalCapacity } + guard activeRequests.values.lazy.filter({ $0 == clientID }).count + < maximumRequestsPerClient else { return .clientCapacity } + activeRequests[requestID] = clientID + activeInvocationContexts[requestID] = CLIInvocationContext( + chainID: UUID(), + depth: 0 + ) + return nil + } + + mutating func finish(requestID: UUID, clientID: ClientID) { + guard activeRequests[requestID] == clientID else { return } + activeRequests[requestID] = nil + activeInvocationContexts[requestID] = nil + activeCancellations.remove(requestID) + forwardedRequests.remove(requestID) + } + + func owns(requestID: UUID, clientID: ClientID) -> Bool { + activeRequests[requestID] == clientID + } + + func invocationContext(requestID: UUID, clientID: ClientID) -> CLIInvocationContext? { + guard owns(requestID: requestID, clientID: clientID) else { return nil } + return activeInvocationContexts[requestID] + } + + /// Atomically transitions an admitted request to host forwarding. A cancellation + /// recorded after admission but before this transition consumes the request instead. + mutating func beginForwarding(requestID: UUID, clientID: ClientID) -> Bool { + guard owns(requestID: requestID, clientID: clientID) else { return false } + if activeCancellations.remove(requestID) != nil { return false } + forwardedRequests.insert(requestID) + return true + } + + /// Records cancellation before admission/forwarding, or identifies work that has already + /// been enqueued to the host and therefore needs an explicit host cancellation message. + mutating func cancel( + requestID: UUID, + clientID: ClientID + ) -> CancellationDisposition? { + if activeRequests[requestID] == clientID { + if forwardedRequests.contains(requestID) { return .forwardToHost } + activeCancellations.insert(requestID) + return .recorded + } + if activeRequests[requestID] != nil { return nil } + if pendingCancellations[requestID] == clientID { return .recorded } + guard pendingCancellations.values.lazy.filter({ $0 == clientID }).count + < maximumRequestsPerClient else { return nil } + pendingCancellations[requestID] = clientID + return .recorded + } + + mutating func removeRequests(clientID: ClientID) -> [UUID] { + let requestIDs = activeRequests.compactMap { requestID, owner in + owner == clientID ? requestID : nil + } + requestIDs.forEach { activeRequests[$0] = nil } + requestIDs.forEach { activeInvocationContexts[$0] = nil } + requestIDs.forEach { activeCancellations.remove($0) } + requestIDs.forEach { forwardedRequests.remove($0) } + pendingCancellations = pendingCancellations.filter { $0.value != clientID } + return requestIDs + } +} diff --git a/Sources/Core/CLI/CLIRequestLifecycleState.swift b/Sources/Core/CLI/CLIRequestLifecycleState.swift new file mode 100644 index 00000000..b5270819 --- /dev/null +++ b/Sources/Core/CLI/CLIRequestLifecycleState.swift @@ -0,0 +1,127 @@ +import Foundation + +final class CLIRequestSendState: @unchecked Sendable { + private let lock = NSLock() + private var didBeginSending = false + private var isCancelled = false + private var didForwardCancellation = false + + func beginSending() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !isCancelled else { return false } + didBeginSending = true + return true + } + + func cancel() { + lock.lock() + isCancelled = true + lock.unlock() + } + + func takeCancellationToForward() -> Bool { + lock.lock() + defer { lock.unlock() } + guard isCancelled, didBeginSending, !didForwardCancellation else { return false } + didForwardCancellation = true + return true + } +} + +final class CLICommandTaskState: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var isCancelled = false + + func install(_ task: Task) { + lock.lock() + self.task = task + let shouldCancel = isCancelled + lock.unlock() + if shouldCancel { task.cancel() } + } + + func cancel() { + lock.lock() + isCancelled = true + let task = task + lock.unlock() + task?.cancel() + } +} + +final class CLISignalState: @unchecked Sendable { + private let lock = NSLock() + private var handled = false + private var finished = false + + func beginHandlingSignal() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !handled, !finished else { return false } + handled = true + return true + } + + func beginFinishing() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !finished else { return false } + finished = true + return true + } +} + +struct CLIHostCancellationRelayState { + enum Disposition: Equatable { + case cancelActive + case recordedBeforeRegistration + case alreadyCompleted + case capacityExceeded + } + + private(set) var pendingRequestIDs: Set = [] + private(set) var completedRequestIDs: Set = [] + private var completedOrder: [UUID] = [] + let maximumTrackedRequestCount: Int + + init(maximumTrackedRequestCount: Int = 32) { + self.maximumTrackedRequestCount = maximumTrackedRequestCount + } + + mutating func shouldBeginHandling(_ requestID: UUID) -> Bool { + completedRequestIDs.remove(requestID) + completedOrder.removeAll { $0 == requestID } + return pendingRequestIDs.remove(requestID) == nil + } + + mutating func cancellationDisposition( + requestID: UUID, + hasActiveTask: Bool + ) -> Disposition { + if hasActiveTask { return .cancelActive } + if completedRequestIDs.contains(requestID) { return .alreadyCompleted } + if pendingRequestIDs.contains(requestID) { return .recordedBeforeRegistration } + guard pendingRequestIDs.count < maximumTrackedRequestCount else { + return .capacityExceeded + } + pendingRequestIDs.insert(requestID) + return .recordedBeforeRegistration + } + + mutating func markCompleted(_ requestID: UUID) { + pendingRequestIDs.remove(requestID) + guard completedRequestIDs.insert(requestID).inserted else { return } + completedOrder.append(requestID) + while completedOrder.count > maximumTrackedRequestCount { + completedRequestIDs.remove(completedOrder.removeFirst()) + } + } + + mutating func reset() { + pendingRequestIDs.removeAll() + completedRequestIDs.removeAll() + completedOrder.removeAll() + } +} diff --git a/Sources/Core/CLI/CLIServiceConfiguration.swift b/Sources/Core/CLI/CLIServiceConfiguration.swift new file mode 100644 index 00000000..333e04de --- /dev/null +++ b/Sources/Core/CLI/CLIServiceConfiguration.swift @@ -0,0 +1,125 @@ +import Darwin +import Foundation + +enum CLIServiceConfiguration { + static let launchAgentPlistName = "app.ggbond.MacTools.cli-broker.plist" + +#if DEBUG + static let testServiceNameEnvironmentKey = "MACTOOLS_CLI_TEST_SERVICE_NAME" + static let testDisableHostLaunchEnvironmentKey = "MACTOOLS_CLI_TEST_DISABLE_HOST_LAUNCH" + static let testSignalReadyEnvironmentKey = "MACTOOLS_CLI_TEST_SIGNAL_READY" + static let testPeerResponseEnvironmentKey = "MACTOOLS_CLI_TEST_PEER_RESPONSE" +#endif + + static func serviceName(bundleIdentifier: String?) -> String { + "\(hostBundleIdentifier(for: bundleIdentifier)).cli-broker" + } + + static func hostBundleIdentifier(for bundleIdentifier: String?) -> String { + let identifier = bundleIdentifier ?? "app.ggbond.MacTools.dev" + if identifier.hasSuffix(".cli-broker") { + return String(identifier.dropLast(".cli-broker".count)) + } + if identifier.hasSuffix(".cli") { + return String(identifier.dropLast(".cli".count)) + } + return identifier + } + + static func containingApplicationURL( + executableURL: URL = resolvedExecutableURL() + ) -> URL? { + var candidate = executableURL.resolvingSymlinksInPath().deletingLastPathComponent() + while candidate.path != "/" { + if candidate.pathExtension == "app" { + return candidate + } + candidate.deleteLastPathComponent() + } + return nil + } + + static func containingApplicationBundle( + executableURL: URL = resolvedExecutableURL() + ) -> Bundle? { + containingApplicationURL(executableURL: executableURL).flatMap(Bundle.init(url:)) + } + + static func resolvedExecutableURL( + executablePath: String = CommandLine.arguments[0], + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) -> URL { + guard !executablePath.contains("/") else { + return URL(fileURLWithPath: executablePath) + } + for directory in (environment["PATH"] ?? "").split( + separator: ":", + omittingEmptySubsequences: false + ) { + let baseURL = directory.isEmpty + ? URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + : URL(fileURLWithPath: String(directory), isDirectory: true) + let candidate = baseURL.appendingPathComponent(executablePath) + if fileManager.isExecutableFile(atPath: candidate.path) { + return candidate + } + } + return URL(fileURLWithPath: executablePath) + } + + static func currentExecutableURL() -> URL { + var size: UInt32 = 0 + _ = _NSGetExecutablePath(nil, &size) + var buffer = [CChar](repeating: 0, count: Int(size)) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { + return resolvedExecutableURL() + } + let end = buffer.firstIndex(of: 0) ?? buffer.endIndex + let path = String(decoding: buffer[.. [String: Any] { + let url = executableURL ?? currentExecutableURL() + return CFBundleCopyInfoDictionaryForURL(url as CFURL) as? [String: Any] ?? [:] + } + + static var runtimeCLIServiceName: String { +#if DEBUG + if let override = ProcessInfo.processInfo.environment[testServiceNameEnvironmentKey], + !override.isEmpty { + return override + } +#endif + return serviceName( + bundleIdentifier: executableInfoDictionary()["CFBundleIdentifier"] as? String + ) + } + + static var runtimeBrokerServiceName: String { +#if DEBUG + if let override = ProcessInfo.processInfo.environment[testServiceNameEnvironmentKey], + !override.isEmpty { + return override + } +#endif + return serviceName( + bundleIdentifier: executableInfoDictionary()["CFBundleIdentifier"] as? String + ) + } + + static func releaseDownloadURL( + version: String?, + repository: String = "ggbond268/MacTools" + ) -> URL { + guard let version, + version.range(of: #"^[0-9A-Za-z][0-9A-Za-z.+-]*$"#, options: .regularExpression) != nil else { + return URL(string: "https://github.com/\(repository)/releases")! + } + let asset = "mactools-cli-\(version)-macos-universal.zip" + return URL(string: "https://github.com/\(repository)/releases/download/v\(version)/\(asset)")! + } +} diff --git a/Sources/Core/CLI/CLIXPCProtocols.swift b/Sources/Core/CLI/CLIXPCProtocols.swift new file mode 100644 index 00000000..d095057c --- /dev/null +++ b/Sources/Core/CLI/CLIXPCProtocols.swift @@ -0,0 +1,12 @@ +import Foundation + +@objc protocol CLIBrokerXPCProtocol { + func handshake(_ request: Data, withReply reply: @escaping (Data) -> Void) + func registerHost(_ registration: Data, withReply reply: @escaping (Data) -> Void) + func send(_ request: Data, withReply reply: @escaping (Data) -> Void) + func cancel(_ requestID: UUID, withReply reply: @escaping (Bool) -> Void) +} +@objc protocol CLIHostXPCProtocol { + func handle(_ request: Data, withReply reply: @escaping (Data) -> Void) + func cancel(_ requestID: UUID, withReply reply: @escaping (Bool) -> Void) +} diff --git a/Sources/Core/Plugins/PluginHost.swift b/Sources/Core/Plugins/PluginHost.swift index d81e2319..df80d267 100644 --- a/Sources/Core/Plugins/PluginHost.swift +++ b/Sources/Core/Plugins/PluginHost.swift @@ -3,6 +3,34 @@ import Foundation import SwiftUI import MacToolsPluginKit +private actor PluginActionCatalogPreparationRace { + private var result: Bool? + private var continuation: CheckedContinuation? + + func wait() async -> Bool { + if let result { return result } + return await withCheckedContinuation { continuation = $0 } + } + + func finish(with result: Bool) { + guard self.result == nil else { return } + self.result = result + continuation?.resume(returning: result) + continuation = nil + } +} + +private struct ExternalActionCatalogPreparationMarker: Equatable { + let generation: UUID + let providerIdentity: ObjectIdentifier +} + +@MainActor +private struct ExternalActionCatalogPreparationOperation { + let marker: ExternalActionCatalogPreparationMarker + let cancel: () -> Void +} + enum FeatureSettingsPane: Hashable { case actionsAndShortcuts case automation @@ -450,10 +478,17 @@ final class PluginHost: ObservableObject { private var visiblePanelSurfaces: Set = [] private var visiblePanelSurfacePluginIDs: [PluginPanelSurface: Set] = [:] private var isolatedPluginFailures: [String: String] = [:] + private var externalActionCatalogPreparationMarkers: [ + String: ExternalActionCatalogPreparationMarker + ] = [:] + private var externalActionCatalogPreparationOperations: [ + String: ExternalActionCatalogPreparationOperation + ] = [:] private var isHandlingPluginAction = false private var didLoadDynamicPlugins = false private var displayTopologyRefreshTask: Task? private var pluginStateChangeRebuildTask: Task? + private var dynamicPluginActionCatalogPreparationTask: Task? private var runtimeLocaleCancellable: AnyCancellable? private var applicationActivityState: PluginApplicationActivityState private var dirtyPluginIDs: Set = [] @@ -2163,6 +2198,183 @@ final class PluginHost: ObservableObject { refreshAll() } + /// Awaits providers whose first externally discoverable actions require asynchronous state, + /// then publishes one synchronous registry snapshot before external transports start. + func prepareActionCatalogForExternalDiscovery( + providerTimeout: Duration = .seconds(5) + ) async { + await prepareActionCatalogForExternalDiscovery( + plugins: activePlugins, + providerTimeout: providerTimeout + ) + } + + private func prepareActionCatalogForExternalDiscovery( + plugins: [any MacToolsPlugin], + providerTimeout: Duration + ) async { + var pending: [( + plugin: any MacToolsPlugin, + marker: ExternalActionCatalogPreparationMarker, + resultTask: Task<(completed: Bool, task: Task), Never> + )] = [] + + for plugin in plugins { + let pluginID = plugin.metadata.id + guard let preparer = plugin as? any PluginActionCatalogPreparing else { + supersedeExternalActionCatalogPreparation(for: pluginID) + continue + } + supersedeExternalActionCatalogPreparation(for: pluginID) + let marker = ExternalActionCatalogPreparationMarker( + generation: UUID(), + providerIdentity: ObjectIdentifier(plugin) + ) + externalActionCatalogPreparationMarkers[pluginID] = marker + let resultTask = Task { @MainActor [weak self] in + guard let self else { + return (completed: false, task: Task {}) + } + return await prepareExternalActionCatalog(preparer, timeout: providerTimeout) + } + externalActionCatalogPreparationOperations[pluginID] = + ExternalActionCatalogPreparationOperation( + marker: marker, + cancel: { resultTask.cancel() } + ) + pending.append((plugin, marker, resultTask)) + } + + let batch = pending + let batchResultTasks = batch.map(\.resultTask) + await withTaskCancellationHandler { + // All provider races are started before awaiting any one of them, so the batch is + // bounded by one provider timeout rather than timeout * provider count. + for item in batch { + let preparation = await item.resultTask.value + if Task.isCancelled { + preparation.task.cancel() + cancelExternalActionCatalogPreparationBatch(batch) + return + } + let pluginID = item.plugin.metadata.id + guard externalActionCatalogPreparationOperations[pluginID]?.marker == item.marker, + externalActionCatalogPreparationMarkers[pluginID] == item.marker, + activePlugins.contains(where: { + ObjectIdentifier($0) == item.marker.providerIdentity + }) else { + preparation.task.cancel() + continue + } + if preparation.completed { + externalActionCatalogPreparationOperations.removeValue(forKey: pluginID) + externalActionCatalogPreparationMarkers.removeValue(forKey: pluginID) + } else { + externalActionCatalogPreparationOperations[pluginID] = + ExternalActionCatalogPreparationOperation( + marker: item.marker, + cancel: { preparation.task.cancel() } + ) + AppLog.pluginHost.error( + "External action catalog preparation timed out for \(pluginID, privacy: .public)" + ) + observeLateExternalActionCatalogPreparation( + preparation.task, + pluginID: pluginID, + marker: item.marker + ) + } + } + + guard !Task.isCancelled else { + cancelExternalActionCatalogPreparationBatch(batch) + return + } + cancelScheduledPluginStateRebuild() + actionRegistry.invalidateAvailability() + rebuildDerivedState() + syncGlobalShortcuts() + } onCancel: { + for resultTask in batchResultTasks { + resultTask.cancel() + } + } + } + + private func prepareExternalActionCatalog( + _ preparer: any PluginActionCatalogPreparing, + timeout: Duration + ) async -> (completed: Bool, task: Task) { + let race = PluginActionCatalogPreparationRace() + let preparationTask = Task { @MainActor in + await preparer.prepareActionCatalogForExternalDiscovery() + await race.finish(with: true) + } + let timeoutTask = Task { + do { + try await Task.sleep(for: timeout) + } catch { + return + } + await race.finish(with: false) + } + let completed = await withTaskCancellationHandler { + await race.wait() + } onCancel: { + preparationTask.cancel() + timeoutTask.cancel() + Task { await race.finish(with: false) } + } + timeoutTask.cancel() + return (completed, preparationTask) + } + + private func supersedeExternalActionCatalogPreparation(for pluginID: String) { + externalActionCatalogPreparationOperations.removeValue(forKey: pluginID)?.cancel() + externalActionCatalogPreparationMarkers.removeValue(forKey: pluginID) + } + + private func cancelExternalActionCatalogPreparationBatch( + _ batch: [( + plugin: any MacToolsPlugin, + marker: ExternalActionCatalogPreparationMarker, + resultTask: Task<(completed: Bool, task: Task), Never> + )] + ) { + for item in batch { + let pluginID = item.plugin.metadata.id + guard externalActionCatalogPreparationOperations[pluginID]?.marker == item.marker else { + continue + } + supersedeExternalActionCatalogPreparation(for: pluginID) + } + } + + private func observeLateExternalActionCatalogPreparation( + _ preparationTask: Task, + pluginID: String, + marker: ExternalActionCatalogPreparationMarker + ) { + Task { @MainActor [weak self] in + await preparationTask.value + guard let self, + externalActionCatalogPreparationMarkers[pluginID] == marker, + activePlugins.contains(where: { + ObjectIdentifier($0) == marker.providerIdentity + }) else { + return + } + externalActionCatalogPreparationMarkers.removeValue(forKey: pluginID) + externalActionCatalogPreparationOperations.removeValue(forKey: pluginID) + AppLog.pluginHost.info( + "Late external action catalog preparation completed for \(pluginID, privacy: .public)" + ) + actionRegistry.invalidateAvailability() + rebuildDerivedState(dirtyPluginIDs: [pluginID]) + syncGlobalShortcuts() + } + } + var hasInstalledDynamicPlugins: Bool { guard let dynamicPluginManager else { return false @@ -2906,7 +3118,17 @@ final class PluginHost: ObservableObject { } } - private func replaceDynamicPlugins(_ plugins: [any MacToolsPlugin]) { + private func replaceDynamicPlugins( + _ plugins: [any MacToolsPlugin], + actionCatalogProviderTimeout: Duration = .seconds(5) + ) { + dynamicPluginActionCatalogPreparationTask?.cancel() + dynamicPluginActionCatalogPreparationTask = nil + let previousDynamicPluginsByID = dynamicPlugins.reduce( + into: [String: any MacToolsPlugin]() + ) { result, plugin in + result[plugin.metadata.id] = plugin + } let previouslyVisibleSurfaces = visiblePanelSurfaces hideAllPanelSurfaces() visiblePanelSurfaces = previouslyVisibleSurfaces @@ -2922,11 +3144,54 @@ final class PluginHost: ObservableObject { return $0.metadata.order < $1.metadata.order } + let newDynamicPluginIDs = Set(dynamicPlugins.map(\.metadata.id)) + let removedPluginIDs = Set(previousDynamicPluginsByID.keys).subtracting(newDynamicPluginIDs) + let changedPlugins = dynamicPlugins.filter { plugin in + guard let previous = previousDynamicPluginsByID[plugin.metadata.id] else { return true } + return ObjectIdentifier(previous) != ObjectIdentifier(plugin) + } + for pluginID in removedPluginIDs.union(changedPlugins.map(\.metadata.id)) { + supersedeExternalActionCatalogPreparation(for: pluginID) + } + let replacementGeneration = UUID() + for plugin in changedPlugins where plugin is any PluginActionCatalogPreparing { + externalActionCatalogPreparationMarkers[plugin.metadata.id] = + ExternalActionCatalogPreparationMarker( + generation: replacementGeneration, + providerIdentity: ObjectIdentifier(plugin) + ) + } configureCallbacks(for: dynamicPlugins) rebuildDerivedState() syncGlobalShortcuts() + guard !changedPlugins.isEmpty else { return } + dynamicPluginActionCatalogPreparationTask = Task { @MainActor [weak self] in + guard let self, !Task.isCancelled else { return } + await prepareActionCatalogForExternalDiscovery( + plugins: changedPlugins, + providerTimeout: actionCatalogProviderTimeout + ) + guard !Task.isCancelled else { return } + dynamicPluginActionCatalogPreparationTask = nil + } } + #if DEBUG + func replaceDynamicPluginsForTests( + _ plugins: [any MacToolsPlugin], + providerTimeout: Duration + ) { + replaceDynamicPlugins( + plugins, + actionCatalogProviderTimeout: providerTimeout + ) + } + + func waitForDynamicPluginActionCatalogPreparationForTests() async { + await dynamicPluginActionCatalogPreparationTask?.value + } + #endif + private func syncPluginManagementState() { dynamicPluginCapabilitiesByID = dynamicPluginManager?.installedCapabilitiesByID() ?? [:] dynamicPluginCategoriesByID = dynamicPluginManager?.installedCategoriesByID() ?? [:] @@ -3507,7 +3772,8 @@ final class PluginHost: ObservableObject { private func synchronizeActionRegistry() { var registrations = [hostActionRegistration()] - for plugin in orderedCorePlugins() { + for plugin in orderedCorePlugins() + where externalActionCatalogPreparationMarkers[plugin.metadata.id] == nil { if let provider = plugin as? any PluginActionProviding { let definitions = guardedValue( for: plugin, diff --git a/Sources/MacToolsCLI/CLIApplication.swift b/Sources/MacToolsCLI/CLIApplication.swift new file mode 100644 index 00000000..dfca62f9 --- /dev/null +++ b/Sources/MacToolsCLI/CLIApplication.swift @@ -0,0 +1,360 @@ +import Foundation + +struct CLIApplication { + let client: CLIBrokerClient + let output = CLIOutput() + + func run(arguments: [String]) async -> Int32 { + let jsonRequested = arguments.contains("--json") + let command: CLICommand + do { + command = try CLIArgumentParser().parse(arguments) + } catch { + emitLocalFailure( + command: commandName(arguments), + outcome: .invalidInput, + category: "invalidCommand", + message: "Invalid command. Run 'mactools help' for usage.", + json: jsonRequested + ) + return CLIExitCode.invalidInput.rawValue + } + do { + switch command { + case .help: + write(helpText) + return CLIExitCode.success.rawValue + case let .version(json): + return try await version(json: json) + case let .request(operation, payload, json): + return try await interruptibleRemoteCommand { + if operation == .doctor { + return try await doctor(payload: payload, json: json) + } + _ = try await client.prepareHost() + return try await execute(operation: operation, payload: payload, json: json) + } + case let .actionRun(arguments): + return try await interruptibleRemoteCommand { + _ = try await client.prepareHost() + return try await executeRun(arguments) + } + } + } catch is CancellationError { + emitLocalFailure( + command: commandName(arguments), + outcome: .cancelled, + category: "cancelled", + message: "The request was cancelled.", + json: jsonRequested + ) + return CLIExitCode.cancellation.rawValue + } catch CLIBrokerClientError.protocolIncompatible { + emitLocalFailure( + command: commandName(arguments), + outcome: .protocolIncompatible, + category: "protocolIncompatible", + message: "The MacTools CLI protocol is incompatible with the installed app.", + json: jsonRequested + ) + return CLIExitCode.protocolIncompatible.rawValue + } catch CLIBrokerClientError.peerContractInvalid { + emitLocalFailure( + command: commandName(arguments), + outcome: .protocolIncompatible, + category: "invalidPeerResponse", + message: "The MacTools broker or host returned an invalid response.", + json: jsonRequested + ) + return CLIExitCode.protocolIncompatible.rawValue + } catch CLIBrokerClientError.replyTimedOut { + emitLocalFailure( + command: commandName(arguments), + outcome: .hostUnavailable, + category: "hostTransportFailure", + message: "Timed out waiting for the broker reply; delivery state is unknown.", + json: jsonRequested + ) + return CLIExitCode.transportFailure.rawValue + } catch let error as CLIBrokerClientError where error.hostFailureDiagnostic != nil { + let diagnostic = error.hostFailureDiagnostic! + emitLocalFailure( + command: commandName(arguments), + outcome: .hostUnavailable, + category: diagnostic.category, + message: diagnostic.message, + json: jsonRequested + ) + return CLIExitCode.transportFailure.rawValue + } catch let CLIBrokerClientError.unavailable(message) { + emitLocalFailure( + command: commandName(arguments), + outcome: .hostUnavailable, + category: "hostTransportFailure", + message: message, + json: jsonRequested + ) + return CLIExitCode.transportFailure.rawValue + } catch let error where error is CLIArgumentError + || error is CLIParameterInputError + || error is CLIInvocationContextError + || error is CLIProtocolCodecError { + emitLocalFailure( + command: commandName(arguments), + outcome: .invalidInput, + category: "invalidInput", + message: "The command input is invalid.", + json: jsonRequested + ) + return CLIExitCode.invalidInput.rawValue + } catch { + emitLocalFailure( + command: commandName(arguments), + outcome: .failed, + category: "cliFailure", + message: "MacTools CLI failed: \(error.localizedDescription)", + json: jsonRequested + ) + return CLIExitCode.actionFailure.rawValue + } + } + + private func version(json: Bool) async throws -> Int32 { + let version = client.cliVersion() + let handshake = try? await client.handshakeWithoutLaunching() + if json { + let object: [String: Any] = [ + "cliVersion": version.version, + "cliBuild": version.build, + "brokerVersion": handshake.map { $0.brokerVersion as Any } ?? NSNull(), + "brokerBuild": handshake.map { $0.brokerBuild as Any } ?? NSNull(), + "hostVersion": handshake?.hostVersion.map { $0 as Any } ?? NSNull(), + "hostBuild": handshake?.hostBuild.map { $0 as Any } ?? NSNull(), + "protocolVersion": handshake?.selectedProtocolVersion.map { $0 as Any } ?? NSNull(), + ] + write(try output.renderLocal( + command: "version", + outcome: .completed, + message: nil, + rejectionCategory: nil, + data: object, + protocolVersion: handshake?.selectedProtocolVersion + )) + } else { + var lines = ["mactools \(version.version) (\(version.build))"] + if let handshake { + lines.append("broker \(handshake.brokerVersion) (\(handshake.brokerBuild))") + if let hostVersion = handshake.hostVersion, let hostBuild = handshake.hostBuild { + lines.append("host \(hostVersion) (\(hostBuild))") + } + if let protocolVersion = handshake.selectedProtocolVersion { + lines.append("protocol \(protocolVersion)") + } + } + write(lines.joined(separator: "\n")) + } + return CLIExitCode.success.rawValue + } + + private func doctor(payload: Data?, json: Bool) async throws -> Int32 { + do { + _ = try await client.prepareHost() + return try await execute(operation: .doctor, payload: payload, json: json) + } catch let error as CLIBrokerClientError where error.hostFailureDiagnostic != nil { + let diagnostic = error.hostFailureDiagnostic! + if json { + write(try output.renderLocal( + command: "doctor", + outcome: .hostUnavailable, + message: diagnostic.message, + rejectionCategory: diagnostic.category, + data: [ + "hostAppPath": diagnostic.applicationURL?.path as Any? ?? NSNull(), + "hostAppSignatureAccepted": diagnostic.signatureAccepted, + "brokerServiceName": CLIServiceConfiguration.runtimeCLIServiceName, + "brokerStatus": diagnostic.category, + "guidance": diagnostic.guidance, + ] + )) + } else { + writeError([diagnostic.message, diagnostic.guidance].joined(separator: "\n")) + } + return CLIExitCode.transportFailure.rawValue + } catch let CLIBrokerClientError.unavailable(message) { + let applicationURL = client.installedHostApplicationURL() + let signatureAccepted = applicationURL.map { + CLIPeerIdentityValidator().acceptsApplication(at: $0, as: .host) + } ?? false + let guidance = "Open System Settings > General > Login Items & Extensions and allow the MacTools background item, then retry." + if json { + write(try output.renderLocal( + command: "doctor", + outcome: .hostUnavailable, + message: message, + rejectionCategory: "brokerUnavailableOrApprovalRequired", + data: [ + "hostAppPath": applicationURL?.path as Any? ?? NSNull(), + "hostAppSignatureAccepted": signatureAccepted, + "brokerServiceName": CLIServiceConfiguration.runtimeCLIServiceName, + "brokerStatus": "unreachableOrApprovalRequired", + "guidance": guidance, + ] + )) + } else { + writeError([message, guidance].joined(separator: "\n")) + } + return CLIExitCode.transportFailure.rawValue + } + } + + private func executeRun(_ arguments: CLIActionRunArguments) async throws -> Int32 { + if arguments.operation == .workflowsRun { + let payload = try CLIProtocolCodec.encodeRequest(CLIWorkflowRunRequest( + nameOrID: arguments.target, + noWait: arguments.noWait + )) + return try await execute(operation: .workflowsRun, payload: payload, json: arguments.json) + } + guard let key = CLIActionKey(id: arguments.target) else { + throw CLIArgumentError.invalidActionKey + } + let values: [String: CLIParameterValue] + let source: CLIParameterInputSource + let definitions: [CLIActionParameter] + if arguments.inputJSONPath != nil || !arguments.rawParameters.isEmpty { + let describePayload = try CLIProtocolCodec.encodeRequest(CLIActionTargetRequest(key: key)) + let describe = try await client.send(operation: .actionsDescribe, payload: describePayload) + guard describe.outcome == .completed, let payload = describe.payload else { + return try emit( + describe.replacingOperation(.actionsRun), + json: arguments.json + ) + } + do { + definitions = try CLIProtocolCodec.decodeResponse( + CLIActionRecord.self, + from: payload + ).parameters + } catch { + throw CLIBrokerClientError.peerContractInvalid + } + } else { + definitions = [] + } + if let path = arguments.inputJSONPath { + let parsed = try CLIParameterInput().json( + path: path, + definitions: definitions + ) + values = parsed.values + source = parsed.source + } else if !arguments.rawParameters.isEmpty { + values = try CLIParameterInput().arguments( + arguments.rawParameters, + definitions: definitions + ) + source = .arguments + } else { + values = [:] + source = .arguments + } + let payload = try CLIProtocolCodec.encodeRequest(CLIActionRunRequest( + key: key, + parameters: values, + inputSource: source, + noWait: arguments.noWait + )) + return try await execute(operation: .actionsRun, payload: payload, json: arguments.json) + } + + private func execute(operation: CLIOperation, payload: Data?, json: Bool) async throws -> Int32 { + let response = try await client.send(operation: operation, payload: payload) + return try emit(response, json: json) + } + + private func interruptibleRemoteCommand( + _ operation: @escaping @Sendable () async throws -> Int32 + ) async throws -> Int32 { + let taskState = CLICommandTaskState() + let signalCoordinator = CLISignalCoordinator { taskState.cancel() } + defer { signalCoordinator.finish() } +#if DEBUG + if ProcessInfo.processInfo.environment[ + CLIServiceConfiguration.testSignalReadyEnvironmentKey + ] == "1" { + FileHandle.standardError.write(Data("MACTOOLS_CLI_SIGNAL_READY\n".utf8)) + } +#endif + let task = Task { try await operation() } + taskState.install(task) + return try await task.value + } + + private func emit(_ response: CLIResponseEnvelope, json: Bool) throws -> Int32 { + let rendered: String + do { + rendered = try output.render(response, json: json) + } catch { + throw CLIBrokerClientError.peerContractInvalid + } + if output.exitCode(for: response) == .success { + write(rendered) + } else { + if json { write(rendered) } else { writeError(rendered) } + } + return output.exitCode(for: response).rawValue + } + + private func write(_ string: String) { + FileHandle.standardOutput.write(Data("\(string)\n".utf8)) + } + + private func writeError(_ string: String) { + FileHandle.standardError.write(Data("\(string)\n".utf8)) + } + + private func emitLocalFailure( + command: String, + outcome: CLIOutcome, + category: String, + message: String, + json: Bool + ) { + if json, + let rendered = try? output.renderLocal( + command: command, + outcome: outcome, + message: message, + rejectionCategory: category + ) { + write(rendered) + } else { + writeError(message) + } + } + + private func commandName(_ arguments: [String]) -> String { + let parts = arguments.prefix(2).filter { !$0.hasPrefix("-") } + return parts.isEmpty ? "unknown" : parts.joined(separator: ".") + } + + private var helpText: String { + """ + Usage: mactools [options] + + version [--json] + doctor [--json] + actions list [--runnable] [--page-token token] [--json] + actions describe [--json] + actions availability [--json] + actions run [--parameter name=value ...] + [--input-json ] [--no-wait] [--json] + workflows list [--page-token token] [--json] + workflows describe [--json] + workflows run [--no-wait] [--json] + plugins list [--page-token token] [--json] + plugins describe [--json] + plugins doctor [--json] + """ + } +} diff --git a/Sources/MacToolsCLI/CLIArgumentParser.swift b/Sources/MacToolsCLI/CLIArgumentParser.swift new file mode 100644 index 00000000..210b2b1f --- /dev/null +++ b/Sources/MacToolsCLI/CLIArgumentParser.swift @@ -0,0 +1,261 @@ +import Foundation + +enum CLICommand: Equatable { + case help + case version(json: Bool) + case request(operation: CLIOperation, payload: Data?, json: Bool) + case actionRun(CLIActionRunArguments) +} + +struct CLIActionRunArguments: Equatable { + let operation: CLIOperation + let target: String + let rawParameters: [String: String] + let inputJSONPath: String? + let noWait: Bool + let json: Bool +} + +enum CLIArgumentError: Error, Equatable { + case invalidCommand + case missingArgument(String) + case invalidActionKey + case duplicateParameter(String) + case conflictingParameterSources + case unexpectedArgument(String) +} + +struct CLIArgumentParser { + func parse(_ arguments: [String]) throws -> CLICommand { + guard let first = arguments.first else { return .help } + if first == "help" || first == "--help" || first == "-h" { return .help } + if first == "version" { + return .version(json: try onlyJSONFlag(Array(arguments.dropFirst()))) + } + if first == "doctor" { + return try simpleRequest(.doctor, arguments: Array(arguments.dropFirst())) + } + guard arguments.count >= 2 else { throw CLIArgumentError.invalidCommand } + switch (first, arguments[1]) { + case ("actions", "list"): + var runnableOnly = false + var json = false + var continuationToken: String? + var index = 2 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--runnable": runnableOnly = true; index += 1 + case "--json": json = true; index += 1 + case "--page-token": + guard index + 1 < arguments.count, continuationToken == nil else { + throw CLIArgumentError.missingArgument("page token") + } + continuationToken = arguments[index + 1] + index += 2 + default: throw CLIArgumentError.unexpectedArgument(argument) + } + } + return try request( + .actionsList, + payload: CLIActionListRequest( + runnableOnly: runnableOnly, + continuationToken: continuationToken + ), + json: json + ) + case ("actions", "describe"): + return try actionTargetRequest(.actionsDescribe, arguments: Array(arguments.dropFirst(2))) + case ("actions", "availability"): + return try actionTargetRequest(.actionsAvailability, arguments: Array(arguments.dropFirst(2))) + case ("actions", "run"): + return try runArguments(.actionsRun, arguments: Array(arguments.dropFirst(2))) + case ("workflows", "list"): + return try listRequest(.workflowsList, arguments: Array(arguments.dropFirst(2))) + case ("workflows", "describe"): + return try workflowTargetRequest(.workflowsDescribe, arguments: Array(arguments.dropFirst(2))) + case ("workflows", "run"): + return try runArguments(.workflowsRun, arguments: Array(arguments.dropFirst(2))) + case ("plugins", "list"): + return try listRequest(.pluginsList, arguments: Array(arguments.dropFirst(2))) + case ("plugins", "describe"): + return try pluginTargetRequest(.pluginsDescribe, arguments: Array(arguments.dropFirst(2))) + case ("plugins", "doctor"): + return try pluginTargetRequest(.pluginsDoctor, arguments: Array(arguments.dropFirst(2))) + default: + throw CLIArgumentError.invalidCommand + } + } + + private func simpleRequest(_ operation: CLIOperation, arguments: [String]) throws -> CLICommand { + .request(operation: operation, payload: nil, json: try onlyJSONFlag(arguments)) + } + + private func listRequest(_ operation: CLIOperation, arguments: [String]) throws -> CLICommand { + var json = false + var continuationToken: String? + var index = 0 + while index < arguments.count { + switch arguments[index] { + case "--json": + json = true + index += 1 + case "--page-token": + guard index + 1 < arguments.count, continuationToken == nil else { + throw CLIArgumentError.missingArgument("page token") + } + continuationToken = arguments[index + 1] + index += 2 + default: + throw CLIArgumentError.unexpectedArgument(arguments[index]) + } + } + return try request( + operation, + payload: CLIListRequest(continuationToken: continuationToken), + json: json + ) + } + + private func actionTargetRequest( + _ operation: CLIOperation, + arguments: [String] + ) throws -> CLICommand { + let parsed = try targetAndJSON(arguments, targetName: "action") + guard let key = CLIActionKey(id: parsed.target) else { + throw CLIArgumentError.invalidActionKey + } + return try request( + operation, + payload: CLIActionTargetRequest(key: key), + json: parsed.json + ) + } + + private func workflowTargetRequest( + _ operation: CLIOperation, + arguments: [String] + ) throws -> CLICommand { + let parsed = try targetAndJSON(arguments, targetName: "workflow") + return try request( + operation, + payload: CLIWorkflowTargetRequest(nameOrID: parsed.target), + json: parsed.json + ) + } + + private func pluginTargetRequest( + _ operation: CLIOperation, + arguments: [String] + ) throws -> CLICommand { + let parsed = try targetAndJSON(arguments, targetName: "plugin") + return try request( + operation, + payload: CLIPluginTargetRequest(pluginID: parsed.target), + json: parsed.json + ) + } + + private func targetAndJSON( + _ arguments: [String], + targetName: String + ) throws -> (target: String, json: Bool) { + guard let target = arguments.first, !target.hasPrefix("--") else { + throw CLIArgumentError.missingArgument(targetName) + } + let json = try onlyJSONFlag(Array(arguments.dropFirst())) + return (target, json) + } + + private func runArguments( + _ operation: CLIOperation, + arguments: [String] + ) throws -> CLICommand { + guard let target = arguments.first, !target.hasPrefix("--") else { + throw CLIArgumentError.missingArgument(operation == .actionsRun ? "action" : "workflow") + } + if operation == .actionsRun, CLIActionKey(id: target) == nil { + throw CLIArgumentError.invalidActionKey + } + var parameters: [String: String] = [:] + var inputJSONPath: String? + var noWait = false + var json = false + var index = 1 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--parameter": + guard operation == .actionsRun else { + throw CLIArgumentError.unexpectedArgument(argument) + } + guard index + 1 < arguments.count else { + throw CLIArgumentError.missingArgument("name=value") + } + let pair = arguments[index + 1] + guard let separator = pair.firstIndex(of: "="), separator != pair.startIndex else { + throw CLIArgumentError.missingArgument("name=value") + } + let name = String(pair[.. Bool { + guard arguments.allSatisfy({ $0 == "--json" }), arguments.count <= 1 else { + throw CLIArgumentError.unexpectedArgument( + arguments.first(where: { $0 != "--json" }) ?? "--json" + ) + } + return arguments.first == "--json" + } + + private func request( + _ operation: CLIOperation, + payload: T, + json: Bool + ) throws -> CLICommand { + .request( + operation: operation, + payload: try CLIProtocolCodec.encodeRequest(payload), + json: json + ) + } +} diff --git a/Sources/MacToolsCLI/CLIBrokerClient.swift b/Sources/MacToolsCLI/CLIBrokerClient.swift new file mode 100644 index 00000000..c7b3a847 --- /dev/null +++ b/Sources/MacToolsCLI/CLIBrokerClient.swift @@ -0,0 +1,1186 @@ +import AppKit +import Foundation + +enum CLIBrokerClientError: Error { + case unavailable(String) + case timedOut + case replyTimedOut + case protocolIncompatible + case peerContractInvalid + case hostDiscovery(CLIHostLocationError) + case hostDiscoveryTimedOut + case brokerVersionIncompatible(expected: String, found: String, applicationURL: URL?) + case hostLaunchFailed(message: String, applicationURL: URL) + case backgroundItemApprovalRequired(applicationURL: URL) +} + +struct CLIHostFailureDiagnostic { + let category: String + let message: String + let applicationURL: URL? + let signatureAccepted: Bool + let guidance: String +} + +extension CLIBrokerClientError { + var hostFailureDiagnostic: CLIHostFailureDiagnostic? { + switch self { + case let .hostDiscovery(error): + let signatureAccepted: Bool + switch error { + case .versionIncompatible: signatureAccepted = true + case .notFound, .teamMismatch, .roleMismatch, .invalidSignature: + signatureAccepted = false + } + return CLIHostFailureDiagnostic( + category: error.category, + message: error.message, + applicationURL: error.candidateURL, + signatureAccepted: signatureAccepted, + guidance: "Install the matching MacTools app release, then retry." + ) + case .hostDiscoveryTimedOut: + return CLIHostFailureDiagnostic( + category: "hostDiscoveryTimedOut", + message: CLIHostDiscoveryError.timedOut.localizedDescription, + applicationURL: nil, + signatureAccepted: false, + guidance: "Remove unavailable MacTools copies or volumes, then retry." + ) + case let .brokerVersionIncompatible(expected, found, applicationURL): + return CLIHostFailureDiagnostic( + category: "brokerVersionIncompatible", + message: "The running broker is \(found); expected \(expected).", + applicationURL: applicationURL, + signatureAccepted: true, + guidance: "Open the matching MacTools app once to refresh its background item." + ) + case let .hostLaunchFailed(message, applicationURL): + return CLIHostFailureDiagnostic( + category: "hostLaunchFailed", + message: message, + applicationURL: applicationURL, + signatureAccepted: true, + guidance: "Open MacTools manually, then retry." + ) + case let .backgroundItemApprovalRequired(applicationURL): + return CLIHostFailureDiagnostic( + category: "brokerApprovalRequired", + message: "The MacTools broker did not become available.", + applicationURL: applicationURL, + signatureAccepted: true, + guidance: "Open System Settings > General > Login Items & Extensions and allow the MacTools background item, then retry." + ) + case .unavailable, .timedOut, .replyTimedOut, .protocolIncompatible, + .peerContractInvalid: + return nil + } + } +} + +final class CLIBrokerClient: @unchecked Sendable { + private let identityValidator = CLIPeerIdentityValidator() + private let hostDiscovery: CLIHostDiscovery + private let hostLauncher: CLIHostApplicationLauncher + private var connection: NSXPCConnection? + private var negotiatedProtocolVersion: Int? + private var selectedHostApplicationURL: URL? + + init( + hostLocator: CLIHostLocator = CLIHostLocator(), + hostDiscovery: CLIHostDiscovery? = nil, + hostLauncher: CLIHostApplicationLauncher = CLIHostApplicationLauncher() + ) { + self.hostDiscovery = hostDiscovery ?? CLIHostDiscovery(locator: hostLocator) + self.hostLauncher = hostLauncher + } + + deinit { + connection?.invalidate() + } + + func prepareHost(launchIfNeeded: Bool = true) async throws -> CLIHandshakeResponse { +#if DEBUG + if ProcessInfo.processInfo.environment[ + CLIServiceConfiguration.testPeerResponseEnvironmentKey + ] != nil { + negotiatedProtocolVersion = CLIProtocolVersion.current + return CLIHandshakeResponse( + selectedProtocolVersion: CLIProtocolVersion.current, + brokerVersion: cliVersion().version, + brokerBuild: cliVersion().build, + hostVersion: cliVersion().version, + hostBuild: cliVersion().build, + hostReady: true, + message: nil + ) + } +#endif + let deadline = CLIStartupDeadline(duration: .seconds(10)) +#if DEBUG + let launchIfNeeded = launchIfNeeded + && ProcessInfo.processInfo.environment[ + CLIServiceConfiguration.testDisableHostLaunchEnvironmentKey + ] != "1" +#endif + var didLaunch = false + var didContactBroker = false + var lastVersionMismatch: CLIBrokerClientError? + var lastMessage = "The MacTools broker is unavailable." + while !deadline.isExpired { + do { + let remaining = deadline.remainingTimeInterval + guard remaining > 0 else { break } + let response = try await connectAndHandshake( + timeout: min(1.5, remaining) + ) + didContactBroker = true + let version = cliVersion() + let brokerMatches = response.brokerVersion == version.version + && response.brokerBuild == version.build + let hostMatches: Bool + if let hostVersion = response.hostVersion, let hostBuild = response.hostBuild { + hostMatches = hostVersion == version.version && hostBuild == version.build + } else { + hostMatches = !response.hostReady + } + let recoveryDecision = CLIHostRecoveryPolicy.decision( + brokerMatches: brokerMatches, + hostMatches: hostMatches, + launchAllowed: launchIfNeeded, + didLaunch: didLaunch + ) + switch recoveryDecision { + case .continueHandshake: + break + case .launchExactHost: + selectedHostApplicationURL = try await launchHost(deadline: deadline) + lastVersionMismatch = versionMismatchError( + response: response, + expectedVersion: version + ) + didLaunch = true + connection?.invalidate() + connection = nil + negotiatedProtocolVersion = nil + try await waitBeforeRetry(deadline: deadline) + continue + case .waitForReplacement: + lastVersionMismatch = versionMismatchError( + response: response, + expectedVersion: version + ) + connection?.invalidate() + connection = nil + negotiatedProtocolVersion = nil + try await waitBeforeRetry(deadline: deadline) + continue + case .rejectBrokerVersion: + throw CLIBrokerClientError.brokerVersionIncompatible( + expected: "\(version.version) (\(version.build))", + found: "\(response.brokerVersion) (\(response.brokerBuild))", + applicationURL: selectedHostApplicationURL + ) + case .rejectHostVersion: + let found = response.hostVersion.map { + ["host \($0) (\(response.hostBuild ?? "unknown"))"] + } ?? ["host unknown"] + throw CLIBrokerClientError.hostDiscovery(.versionIncompatible( + expected: "\(version.version) (\(version.build))", + found: found, + candidate: selectedHostApplicationURL + )) + } + guard let selectedVersion = response.selectedProtocolVersion else { + throw CLIBrokerClientError.protocolIncompatible + } + negotiatedProtocolVersion = selectedVersion + if response.hostReady { return response } + lastMessage = response.message ?? "MacTools is starting." + } catch CLIBrokerClientError.protocolIncompatible { + throw CLIBrokerClientError.protocolIncompatible + } catch CLIBrokerClientError.peerContractInvalid { + throw CLIBrokerClientError.peerContractInvalid + } catch let error as CLIBrokerClientError where error.hostFailureDiagnostic != nil { + throw error + } catch is CancellationError { + throw CancellationError() + } catch { + lastMessage = "The MacTools broker is unavailable." + } + if launchIfNeeded, !didLaunch { + selectedHostApplicationURL = try await launchHost(deadline: deadline) + didLaunch = true + } + try await waitBeforeRetry(deadline: deadline) + } + if let lastVersionMismatch { + throw lastVersionMismatch + } + if let selectedHostApplicationURL, !didContactBroker { + throw CLIBrokerClientError.backgroundItemApprovalRequired( + applicationURL: selectedHostApplicationURL + ) + } + throw CLIBrokerClientError.unavailable(lastMessage) + } + + func handshakeWithoutLaunching() async throws -> CLIHandshakeResponse { + try await connectAndHandshake(timeout: 1) + } + + func send( + operation: CLIOperation, + payload: Data?, + requestID: UUID = UUID() + ) async throws -> CLIResponseEnvelope { + let sendState = CLIRequestSendState() + if connection == nil || negotiatedProtocolVersion == nil { + _ = try await prepareHost() + } + let request = CLIRequestEnvelope( + protocolVersion: negotiatedProtocolVersion ?? CLIProtocolVersion.current, + requestID: requestID, + operation: operation, + sentAt: .now, + invocationContext: try CLIInvocationContext.inherited(), + payload: payload + ) + let data = try CLIProtocolCodec.encodeRequest(request) + let responseData: Data + do { + responseData = try await receiveResponseData( + request: request, + encodedRequest: data, + sendState: sendState + ) + } catch is CancellationError { + // Make the state transition explicit before observing it. The cancellation + // handlers can resume this task from different executor hops. + sendState.cancel() + if sendState.takeCancellationToForward() { + let cancellationTask = Task.detached { [weak self] in + await self?.cancel(requestID: requestID) ?? false + } + _ = await cancellationTask.value + } + throw CancellationError() + } catch CLIBrokerClientError.timedOut { + throw CLIBrokerClientError.replyTimedOut + } + guard !responseData.isEmpty else { throw CLIBrokerClientError.peerContractInvalid } + let response: CLIResponseEnvelope + do { + try CLIResponsePayloadValidator.validateEnvelope(responseData) + response = try CLIProtocolCodec.decodeResponse( + CLIResponseEnvelope.self, + from: responseData, + allowedKeys: [ + "schemaVersion", "protocolVersion", "requestID", "operation", + "actionReference", "startedAt", "finishedAt", "outcome", "message", + "rejection", "payload", + ] + ) + } catch { + throw CLIBrokerClientError.peerContractInvalid + } + guard response.schemaVersion == 1, + response.protocolVersion == request.protocolVersion, + response.requestID == request.requestID, + response.operation == request.operation else { + throw CLIBrokerClientError.peerContractInvalid + } + do { + try CLIResponsePayloadValidator.validate(response) + } catch { + throw CLIBrokerClientError.peerContractInvalid + } + return response + } + + func cancel(requestID: UUID) async -> Bool { + return (try? await awaitReply(timeout: 2) { completion in + guard let broker = brokerProxy(errorHandler: { _ in + completion(.failure(CLIBrokerClientError.unavailable( + "The broker connection was interrupted." + ))) + }) else { + completion(.success(false)) + return + } + broker.cancel(requestID) { completion(.success($0)) } + }) ?? false + } + + private func connectAndHandshake(timeout: TimeInterval) async throws -> CLIHandshakeResponse { + connection?.invalidate() + negotiatedProtocolVersion = nil + let connection = NSXPCConnection(machServiceName: CLIServiceConfiguration.runtimeCLIServiceName) + connection.remoteObjectInterface = NSXPCInterface(with: CLIBrokerXPCProtocol.self) + guard identityValidator.configure(connection, toRequire: .broker) else { + throw CLIBrokerClientError.unavailable("The broker identity could not be verified.") + } + connection.activate() + self.connection = connection + let hello = CLIHandshakeRequest( + minimumProtocolVersion: CLIProtocolVersion.minimum, + maximumProtocolVersion: CLIProtocolVersion.current, + clientVersion: cliVersion().version, + clientBuild: cliVersion().build + ) + let request = try CLIProtocolCodec.encodeRequest(hello) + let responseData: Data = try await awaitReply(timeout: timeout) { completion in + guard let broker = brokerProxy(errorHandler: { _ in + completion(.failure(CLIBrokerClientError.unavailable( + "The broker connection was interrupted." + ))) + }) else { + completion(.failure(CLIBrokerClientError.unavailable( + "The broker interface is unavailable." + ))) + return + } + broker.handshake(request) { completion(.success($0)) } + } + guard identityValidator.accepts(connection, as: .broker) else { + throw CLIBrokerClientError.unavailable("The broker identity could not be verified.") + } + guard !responseData.isEmpty else { throw CLIBrokerClientError.peerContractInvalid } + let response: CLIHandshakeResponse + do { + response = try CLIProtocolCodec.decodeResponse( + CLIHandshakeResponse.self, + from: responseData, + allowedKeys: [ + "selectedProtocolVersion", "brokerVersion", "brokerBuild", "hostVersion", + "hostBuild", "hostReady", "message", + ] + ) + } catch { + throw CLIBrokerClientError.peerContractInvalid + } + if let selectedProtocolVersion = response.selectedProtocolVersion, + !(CLIProtocolVersion.minimum...CLIProtocolVersion.current).contains( + selectedProtocolVersion + ) { + throw CLIBrokerClientError.peerContractInvalid + } + negotiatedProtocolVersion = response.selectedProtocolVersion + return response + } + + private func brokerProxy( + errorHandler: ((Error) -> Void)? + ) -> CLIBrokerXPCProtocol? { + connection?.remoteObjectProxyWithErrorHandler { error in + errorHandler?(error) + } as? CLIBrokerXPCProtocol + } + + private func awaitReply( + timeout: TimeInterval, + start: (@escaping @Sendable (Result) -> Void) -> Void + ) async throws -> T { + try Task.checkCancellation() + let (stream, continuation) = AsyncStream.makeStream(of: Result.self) + start { result in + continuation.yield(result) + continuation.finish() + } + let timeoutTask = Task { + try? await Task.sleep(for: .seconds(timeout)) + guard !Task.isCancelled else { return } + continuation.yield(.failure(CLIBrokerClientError.timedOut)) + continuation.finish() + } + defer { timeoutTask.cancel() } + return try await withTaskCancellationHandler { + for await result in stream { + try Task.checkCancellation() + return try result.get() + } + try Task.checkCancellation() + throw CLIBrokerClientError.unavailable("The broker reply ended unexpectedly.") + } onCancel: { + continuation.yield(.failure(CancellationError())) + continuation.finish() + } + } + + private func receiveResponseData( + request: CLIRequestEnvelope, + encodedRequest: Data, + sendState: CLIRequestSendState + ) async throws -> Data { +#if DEBUG + if let fixture = try testPeerResponseData(request: request) { + return fixture + } +#endif + return try await withTaskCancellationHandler { + try Task.checkCancellation() + return try await awaitReply(timeout: 86_460) { completion in + guard sendState.beginSending() else { + completion(.failure(CancellationError())) + return + } + guard let broker = brokerProxy(errorHandler: { _ in + completion(.failure(CLIBrokerClientError.unavailable( + "The broker or host connection was interrupted." + ))) + }) else { + completion(.failure(CLIBrokerClientError.unavailable( + "The broker interface is unavailable." + ))) + return + } + broker.send(encodedRequest) { completion(.success($0)) } + } + } onCancel: { + sendState.cancel() + } + } + +#if DEBUG + private func testPeerResponseData(request: CLIRequestEnvelope) throws -> Data? { + switch ProcessInfo.processInfo.environment[ + CLIServiceConfiguration.testPeerResponseEnvironmentKey + ] { + case "empty": + return Data() + case "malformed": + return Data("{".utf8) + case "mismatched": + let mismatched = CLIRequestEnvelope( + protocolVersion: request.protocolVersion, + requestID: UUID(), + operation: request.operation, + sentAt: request.sentAt, + invocationContext: request.invocationContext, + payload: request.payload + ) + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope.failure( + request: mismatched, + outcome: .failed, + category: "fixture", + message: "fixture" + )) + case "malformedPayload": + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: nil, + startedAt: .now, + finishedAt: .now, + outcome: .completed, + message: nil, + rejection: nil, + payload: Data("{".utf8) + )) + case "missingPayload": + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: nil, + startedAt: .now, + finishedAt: .now, + outcome: .completed, + message: nil, + rejection: nil, + payload: nil + )) + case "schemaInvalidPayload": + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: nil, + startedAt: .now, + finishedAt: .now, + outcome: .completed, + message: nil, + rejection: nil, + payload: Data("{}".utf8) + )) + case "nestedUnknownPayload": + return try testResponseData( + request: request, + payload: testDiscoveryPayload(operation: request.operation, mutation: .unknown) + ) + case "nestedDuplicatePayload": + return try testResponseData( + request: request, + payload: testDiscoveryPayload(operation: request.operation, mutation: .duplicate) + ) + case "validDiscoveryPayload": + return try testResponseData( + request: request, + payload: testDiscoveryPayload(operation: request.operation, mutation: .none) + ) + case "invalidOutcome": + return try testResponseData( + request: request, + payload: nil, + outcome: .timedOut + ) + case "missingFinishedAt": + return try testResponseData( + request: request, + payload: testDoctorPayload(), + finishedAt: nil + ) + case "unexpectedActionReference": + return try testResponseData( + request: request, + payload: testDoctorPayload(), + actionReference: testActionReference + ) + case "validActionTimeout": + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope.failure( + request: request, + outcome: .timedOut, + category: "executionTimedOut", + message: "The action timed out.", + actionReference: testActionReference + )) + case "oversizedPage": + return try testResponseData( + request: request, + payload: testDiscoveryPayload( + operation: request.operation, + mutation: .none, + recordCount: CLIProtocolVersion.maximumPageSize + 1 + ) + ) + case "duplicateParameterDefinitions": + let parameter = testParameter(id: "value") + return try testResponseData( + request: request, + payload: CLIProtocolCodec.encodeResponse( + testActionRecord(parameters: [parameter, parameter]) + ) + ) + case "invalidParameterID": + return try testResponseData( + request: request, + payload: testDiscoveryPayload( + operation: request.operation, + mutation: .none, + parameters: [testParameter(id: "invalid id")] + ) + ) + case "invalidParameterKind": + return try testResponseData( + request: request, + payload: testDiscoveryPayload( + operation: request.operation, + mutation: .none, + parameters: [testParameter(id: "value", kind: "object")] + ) + ) + case "invalidParameterPrivacy": + return try testResponseData( + request: request, + payload: testDiscoveryPayload( + operation: request.operation, + mutation: .none, + parameters: [testParameter(id: "value", privacy: "secret")] + ) + ) + case "invalidParameterPortability": + return try testResponseData( + request: request, + payload: testDiscoveryPayload( + operation: request.operation, + mutation: .none, + parameters: [testParameter(id: "value", portability: "remote")] + ) + ) + case "validStartedPayload": + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: testActionReference, + startedAt: .now, + finishedAt: nil, + outcome: .started, + message: "Action started.", + rejection: nil, + payload: try CLIProtocolCodec.encodeResponse(["accepted": true]) + )) + case "validFailure": + return try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope.failure( + request: request, + outcome: .failed, + category: "fixtureFailure", + message: "Fixture failed." + )) + case "timeout": + throw CLIBrokerClientError.timedOut + default: + return nil + } + } + + private func testResponseData( + request: CLIRequestEnvelope, + payload: Data?, + actionReference: CLIActionReference? = nil, + finishedAt: Date? = .now, + outcome: CLIOutcome = .completed + ) throws -> Data { + try CLIProtocolCodec.encodeResponse(CLIResponseEnvelope( + schemaVersion: 1, + protocolVersion: request.protocolVersion, + requestID: request.requestID, + operation: request.operation, + actionReference: actionReference, + startedAt: .now, + finishedAt: finishedAt, + outcome: outcome, + message: nil, + rejection: nil, + payload: payload + )) + } + + private enum TestPayloadMutation: Equatable { + case none + case unknown + case duplicate + } + + private func testDiscoveryPayload( + operation: CLIOperation, + mutation: TestPayloadMutation, + recordCount: Int = 1, + parameters: [CLIActionParameter] = [] + ) throws -> Data { + let payload: Data + let duplicateField: String + switch operation { + case .actionsList: + payload = try CLIProtocolCodec.encodeResponse(CLIPage( + records: Array( + repeating: testActionRecord(parameters: parameters), + count: recordCount + ), + continuationToken: nil + )) + duplicateField = #""title":"Fixture action""# + case .workflowsList: + let record = CLIWorkflowRecord( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + name: "Fixture workflow", + isEnabled: true, + stepCount: 1, + actionReference: CLIActionReference( + key: CLIActionKey(providerID: "fixture", actionID: "action"), + schemaVersion: 1 + ), + availability: CLIAvailabilityRecord(isAvailable: true, reason: nil) + ) + payload = try CLIProtocolCodec.encodeResponse(CLIPage( + records: Array(repeating: record, count: recordCount), + continuationToken: nil + )) + duplicateField = #""name":"Fixture workflow""# + case .pluginsList: + let record = CLIPluginRecord( + id: "fixture", + title: "Fixture plugin", + summary: nil, + version: "1.0.0", + state: "active", + diagnostic: nil, + requiresRestart: false, + permissions: [CLIPluginPermissionRecord( + id: "fixture.permission", + title: "Fixture permission", + isGranted: true, + status: "granted" + )], + publishedActionCount: 1 + ) + payload = try CLIProtocolCodec.encodeResponse(CLIPage( + records: Array(repeating: record, count: recordCount), + continuationToken: nil + )) + duplicateField = #""title":"Fixture plugin""# + default: + return Data("{}".utf8) + } + + if mutation == .duplicate { + guard var string = String(data: payload, encoding: .utf8), + let range = string.range(of: duplicateField) else { + throw CLIProtocolCodecError.encodingFailed + } + let key = duplicateField.prefix { $0 != ":" } + string.replaceSubrange(range, with: "\(key):\"duplicate\",\(duplicateField)") + return Data(string.utf8) + } + + if mutation == .none { return payload } + + guard var object = try JSONSerialization.jsonObject(with: payload) as? [String: Any], + var records = object["records"] as? [[String: Any]], + !records.isEmpty else { + throw CLIProtocolCodecError.encodingFailed + } + records[0]["injected"] = true + object["records"] = records + return try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + } + + private var testActionReference: CLIActionReference { + CLIActionReference( + key: CLIActionKey(providerID: "fixture", actionID: "action"), + schemaVersion: 1 + ) + } + + private func testActionRecord( + parameters: [CLIActionParameter] = [] + ) -> CLIActionRecord { + CLIActionRecord( + reference: testActionReference, + title: "Fixture action", + subtitle: nil, + description: "Fixture", + systemImage: "hammer", + parameters: parameters, + availability: CLIAvailabilityRecord(isAvailable: true, reason: nil), + cliEligibility: CLIAvailabilityRecord(isAvailable: true, reason: nil), + capabilities: [], + externalInvocationPolicy: "automatic" + ) + } + + private func testParameter( + id: String, + kind: String = "string", + privacy: String = "publicValue", + portability: String = "portable" + ) -> CLIActionParameter { + CLIActionParameter( + id: id, + title: id, + kind: kind, + isRequired: true, + privacy: privacy, + portability: portability + ) + } + + private func testDoctorPayload() throws -> Data { + try CLIProtocolCodec.encodeResponse(CLIDoctorRecord( + hostVersion: "1.0.0", + hostBuild: "1", + protocolVersion: CLIProtocolVersion.current, + actionCount: 1, + workflowCount: 1, + pluginCount: 1, + brokerServiceStatus: "ready" + )) + } +#endif + + private func launchHost(deadline: CLIStartupDeadline) async throws -> URL { + let applicationURL: URL + do { + let version = cliVersion() + applicationURL = try await hostDiscovery.locate( + bundleIdentifier: hostBundleIdentifier(), + version: version.version, + build: version.build, + deadline: deadline + ) + } catch is CancellationError { + throw CancellationError() + } catch is CLIHostDiscoveryError { + throw CLIBrokerClientError.hostDiscoveryTimedOut + } catch let error as CLIHostLocationError { + throw CLIBrokerClientError.hostDiscovery(error) + } + do { + try await hostLauncher.launch( + applicationURL: applicationURL, + deadline: deadline + ) + } catch is CancellationError { + throw CancellationError() + } catch { + throw CLIBrokerClientError.hostLaunchFailed( + message: error.localizedDescription, + applicationURL: applicationURL + ) + } + return applicationURL + } + + private func waitBeforeRetry(deadline: CLIStartupDeadline) async throws { + try await deadline.sleep(upTo: .milliseconds(200)) + } + + private func versionMismatchError( + response: CLIHandshakeResponse, + expectedVersion: (version: String, build: String) + ) -> CLIBrokerClientError { + let expected = "\(expectedVersion.version) (\(expectedVersion.build))" + let brokerMatches = response.brokerVersion == expectedVersion.version + && response.brokerBuild == expectedVersion.build + if !brokerMatches { + return .brokerVersionIncompatible( + expected: expected, + found: "\(response.brokerVersion) (\(response.brokerBuild))", + applicationURL: selectedHostApplicationURL + ) + } + let found = response.hostVersion.map { + ["host \($0) (\(response.hostBuild ?? "unknown"))"] + } ?? ["host unknown"] + return .hostDiscovery(.versionIncompatible( + expected: expected, + found: found, + candidate: selectedHostApplicationURL + )) + } + + func cliVersion() -> (version: String, build: String) { + let info = CLIServiceConfiguration.executableInfoDictionary() + return ( + info["CFBundleShortVersionString"] as? String ?? "unknown", + info["CFBundleVersion"] as? String ?? "unknown" + ) + } + + func installedHostApplicationURL() -> URL? { + selectedHostApplicationURL + } + + private func hostBundleIdentifier() -> String { + CLIServiceConfiguration.hostBundleIdentifier( + for: CLIServiceConfiguration.executableInfoDictionary()["CFBundleIdentifier"] + as? String ?? "app.ggbond.MacTools.cli" + ) + } +} + +private enum CLIResponsePayloadValidator { + private struct StartedPayload: Decodable, Equatable { + let accepted: Bool + } + + private static let commonOutcomes: Set = [ + .completed, .cancelled, .invalidInput, .hostUnavailable, .protocolIncompatible, + ] + private static let actionTargetOperations: Set = [ + .actionsDescribe, .actionsAvailability, .actionsRun, .workflowsRun, + ] + private static let parameterKinds: Set = [ + "string", "integer", "double", "boolean", + ] + private static let parameterPrivacyValues: Set = [ + "publicValue", "sensitive", + ] + private static let parameterPortabilityValues: Set = [ + "portable", "localOnly", + ] + + static func validate(_ response: CLIResponseEnvelope) throws { + try validateEnvelopeSemantics(response) + + if response.outcome == .started { + guard response.operation == .actionsRun || response.operation == .workflowsRun, + let payload = response.payload else { + throw CLIProtocolCodecError.invalidObject + } + let value = try CLIProtocolCodec.decodeResponse( + StartedPayload.self, + from: payload + ) + try CLIResponseJSONSchema.started.validate(payload) + guard value.accepted else { throw CLIProtocolCodecError.invalidObject } + return + } + + guard response.outcome == .completed else { + guard response.payload == nil else { + throw CLIProtocolCodecError.invalidObject + } + return + } + + switch response.operation { + case .doctor: + _ = try decode( + CLIDoctorRecord.self, + response.payload, + schema: .doctor + ) + case .actionsList: + let page = try decode( + CLIPage.self, + response.payload, + schema: .page(record: .action) + ) + try validatePage(page) + try page.records.forEach(validateActionRecord) + case .actionsDescribe: + let record = try decode( + CLIActionRecord.self, + response.payload, + schema: .action + ) + try validateActionRecord(record) + case .actionsAvailability: + _ = try decode( + CLIAvailabilityRecord.self, + response.payload, + schema: .availability + ) + case .workflowsList: + let page = try decode( + CLIPage.self, + response.payload, + schema: .page(record: .workflow) + ) + try validatePage(page) + case .workflowsDescribe: + _ = try decode( + CLIWorkflowRecord.self, + response.payload, + schema: .workflow + ) + case .pluginsList: + let page = try decode( + CLIPage.self, + response.payload, + schema: .page(record: .plugin) + ) + try validatePage(page) + case .pluginsDescribe, .pluginsDoctor: + _ = try decode( + CLIPluginRecord.self, + response.payload, + schema: .plugin + ) + case .actionsRun, .workflowsRun: + guard response.payload == nil else { + throw CLIProtocolCodecError.invalidObject + } + } + } + + static func validateEnvelope(_ data: Data) throws { + try CLIResponseJSONSchema.envelope.validate(data) + } + + private static func validateEnvelopeSemantics( + _ response: CLIResponseEnvelope + ) throws { + guard allowedOutcomes(for: response.operation).contains(response.outcome), + (response.outcome == .started) == (response.finishedAt == nil), + response.actionReference == nil + || actionTargetOperations.contains(response.operation) else { + throw CLIProtocolCodecError.invalidObject + } + } + + private static func allowedOutcomes(for operation: CLIOperation) -> Set { + switch operation { + case .doctor, .actionsList, .workflowsList, .pluginsList: + return commonOutcomes + case .actionsDescribe, .actionsAvailability, .workflowsDescribe, + .pluginsDescribe, .pluginsDoctor: + return commonOutcomes.union([.unknownTarget]) + case .actionsRun, .workflowsRun: + return Set([ + .completed, .started, .cancelled, .unavailable, .confirmationDenied, + .timedOut, .invalidInput, .unknownTarget, .failed, .hostUnavailable, + .providerChanged, .protocolIncompatible, + ]) + } + } + + private static func validatePage(_ page: CLIPage) throws { + guard page.records.count <= CLIProtocolVersion.maximumPageSize else { + throw CLIProtocolCodecError.invalidObject + } + } + + private static func validateActionRecord(_ record: CLIActionRecord) throws { + var parameterIDs: Set = [] + for parameter in record.parameters { + guard isValidIdentifier(parameter.id), + parameterIDs.insert(parameter.id).inserted, + parameterKinds.contains(parameter.kind), + parameterPrivacyValues.contains(parameter.privacy), + parameterPortabilityValues.contains(parameter.portability) else { + throw CLIProtocolCodecError.invalidObject + } + } + } + + private static func isValidIdentifier(_ value: String) -> Bool { + !value.isEmpty + && value.utf8.count <= 128 + && value.unicodeScalars.allSatisfy { + CharacterSet.alphanumerics.contains($0) || $0 == "." || $0 == "_" || $0 == "-" + } + } + + private static func decode( + _ type: T.Type, + _ payload: Data?, + schema: CLIResponseJSONSchema + ) throws -> T { + guard let payload else { throw CLIProtocolCodecError.invalidObject } + try schema.validate(payload) + return try CLIProtocolCodec.decodeResponse(type, from: payload) + } +} + +private indirect enum CLIResponseJSONSchema: Sendable { + case scalar + case array(CLIResponseJSONSchema) + case object([String: CLIResponseJSONSchema]) + + static let envelope: Self = .object([ + "schemaVersion": .scalar, + "protocolVersion": .scalar, + "requestID": .scalar, + "operation": .scalar, + "actionReference": .actionReference, + "startedAt": .scalar, + "finishedAt": .scalar, + "outcome": .scalar, + "message": .scalar, + "rejection": .object([ + "category": .scalar, + "message": .scalar, + ]), + "payload": .scalar, + ]) + + static let started: Self = .object(["accepted": .scalar]) + + static let doctor: Self = .object([ + "hostVersion": .scalar, + "hostBuild": .scalar, + "protocolVersion": .scalar, + "actionCount": .scalar, + "workflowCount": .scalar, + "pluginCount": .scalar, + "brokerServiceStatus": .scalar, + ]) + + static let action: Self = .object([ + "reference": .actionReference, + "title": .scalar, + "subtitle": .scalar, + "description": .scalar, + "systemImage": .scalar, + "parameters": .array(.object([ + "id": .scalar, + "title": .scalar, + "kind": .scalar, + "isRequired": .scalar, + "privacy": .scalar, + "portability": .scalar, + ])), + "availability": .availability, + "cliEligibility": .availability, + "capabilities": .array(.scalar), + "externalInvocationPolicy": .scalar, + ]) + + static let availability: Self = .object([ + "isAvailable": .scalar, + "reason": .scalar, + ]) + + static let workflow: Self = .object([ + "id": .scalar, + "name": .scalar, + "isEnabled": .scalar, + "stepCount": .scalar, + "actionReference": .actionReference, + "availability": .availability, + ]) + + static let plugin: Self = .object([ + "id": .scalar, + "title": .scalar, + "summary": .scalar, + "version": .scalar, + "state": .scalar, + "diagnostic": .scalar, + "requiresRestart": .scalar, + "permissions": .array(.object([ + "id": .scalar, + "title": .scalar, + "isGranted": .scalar, + "status": .scalar, + ])), + "publishedActionCount": .scalar, + ]) + + static func page(record: Self) -> Self { + .object([ + "records": .array(record), + "continuationToken": .scalar, + ]) + } + + private static let actionReference: Self = .object([ + "key": .object([ + "providerID": .scalar, + "actionID": .scalar, + ]), + "schemaVersion": .scalar, + ]) + + func validate(_ data: Data) throws { + try CLIProtocolCodec.rejectDuplicateFieldsRecursively(in: data) + let value = try JSONSerialization.jsonObject(with: data) + try validate(value, path: "data") + } + + private func validate(_ value: Any, path: String) throws { + switch self { + case .scalar: + return + case let .array(element): + guard let values = value as? [Any] else { + throw CLIProtocolCodecError.invalidObject + } + for (index, value) in values.enumerated() { + try element.validate(value, path: "\(path)[\(index)]") + } + case let .object(fields): + if value is NSNull { return } + guard let object = value as? [String: Any] else { + throw CLIProtocolCodecError.invalidObject + } + let unknown = Set(object.keys).subtracting(fields.keys).sorted() + guard unknown.isEmpty else { + throw CLIProtocolCodecError.unknownFields( + unknown.map { "\(path).\($0)" } + ) + } + for (key, value) in object { + guard let schema = fields[key] else { continue } + try schema.validate(value, path: "\(path).\(key)") + } + } + } +} diff --git a/Sources/MacToolsCLI/CLIOutput.swift b/Sources/MacToolsCLI/CLIOutput.swift new file mode 100644 index 00000000..8d4f0da1 --- /dev/null +++ b/Sources/MacToolsCLI/CLIOutput.swift @@ -0,0 +1,201 @@ +import Foundation + +struct CLIOutput { + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return encoder + }() + + func render(_ response: CLIResponseEnvelope, json: Bool) throws -> String { + if json { return try jsonOutput(response) } + if let message = response.message, response.outcome != .completed { + return message + } + guard let payload = response.payload else { + return response.message ?? humanOutcome(response.outcome) + } + switch response.operation { + case .doctor: + let value = try decode(CLIDoctorRecord.self, payload) + return [ + "MacTools \(value.hostVersion) (\(value.hostBuild))", + "Protocol: \(value.protocolVersion)", + "Broker: \(value.brokerServiceStatus)", + "Actions: \(value.actionCount), workflows: \(value.workflowCount), plugins: \(value.pluginCount)", + ].joined(separator: "\n") + case .actionsList: + let page = try decode(CLIPage.self, payload) + return (page.records.map { record in + let marker = record.cliEligibility.isAvailable ? "available" : "unavailable" + return "\(record.reference.key.id)\t\(record.title)\t\(marker)" + } + nextPageLine(page.continuationToken)).joined(separator: "\n") + case .actionsDescribe: + let record = try decode(CLIActionRecord.self, payload) + let parameters = record.parameters.map { + " \($0.id): \($0.kind)\($0.isRequired ? " (required)" : "") [\($0.privacy)]" + } + return ([ + "\(record.reference.key.id) — \(record.title)", + record.description, + "Available: \(record.availability.isAvailable ? "yes" : "no")", + "CLI eligible: \(record.cliEligibility.isAvailable ? "yes" : "no")", + ] + (parameters.isEmpty ? [] : ["Parameters:"] + parameters)).joined(separator: "\n") + case .actionsAvailability: + let record = try decode(CLIAvailabilityRecord.self, payload) + return record.isAvailable ? "Available" : "Unavailable: \(record.reason ?? "unknown reason")" + case .workflowsList: + let page = try decode(CLIPage.self, payload) + return (page.records.map { + "\($0.id.uuidString.lowercased())\t\($0.name)\t\($0.isEnabled ? "enabled" : "disabled")" + } + nextPageLine(page.continuationToken)).joined(separator: "\n") + case .workflowsDescribe: + let workflow = try decode(CLIWorkflowRecord.self, payload) + return "\(workflow.name)\nID: \(workflow.id.uuidString.lowercased())\nSteps: \(workflow.stepCount)" + case .pluginsList: + let page = try decode(CLIPage.self, payload) + return (page.records.map { + "\($0.id)\t\($0.title)\t\($0.state)" + } + nextPageLine(page.continuationToken)).joined(separator: "\n") + case .pluginsDescribe, .pluginsDoctor: + let plugin = try decode(CLIPluginRecord.self, payload) + let permissions = plugin.permissions.map { + "Permission \($0.title): \($0.status)" + } + return ([ + "\(plugin.title) (\(plugin.id))", + "Version: \(plugin.version)", + "State: \(plugin.state)", + "Published actions: \(plugin.publishedActionCount)", + ] + permissions + (plugin.diagnostic.map { ["Diagnostic: \($0)"] } ?? [])) + .joined(separator: "\n") + case .actionsRun, .workflowsRun: + return response.message ?? humanOutcome(response.outcome) + } + } + + func renderLocal( + command: String, + outcome: CLIOutcome, + message: String?, + rejectionCategory: String?, + data: Any = NSNull(), + protocolVersion: Int? = nil, + requestID: UUID = UUID(), + timestamp: Date = .now + ) throws -> String { + var object: [String: Any] = [ + "schemaVersion": 1, + "protocolVersion": protocolVersion.map { $0 as Any } ?? NSNull(), + "requestID": requestID.uuidString.uppercased(), + "command": command, + "invocationSource": "cli", + "startedAt": CLIProtocolCodec.timestamp(timestamp), + "finishedAt": CLIProtocolCodec.timestamp(timestamp), + "outcome": outcome.rawValue, + "message": message.map { $0 as Any } ?? NSNull(), + "data": data, + ] + if let rejectionCategory { + object["rejection"] = [ + "category": rejectionCategory, + "message": message.map { $0 as Any } ?? NSNull(), + ] + } else { + object["rejection"] = NSNull() + } + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + guard let output = String(data: data, encoding: .utf8) else { + throw CLIProtocolCodecError.encodingFailed + } + return output + } + + func exitCode(for response: CLIResponseEnvelope) -> CLIExitCode { + switch response.outcome { + case .completed, .started: .success + case .invalidInput: .invalidInput + case .unknownTarget: .unknownTarget + case .unavailable: .unavailable + case .confirmationDenied: .confirmationFailure + case .failed, .providerChanged: .actionFailure + case .timedOut: .timeout + case .cancelled: .cancellation + case .hostUnavailable: .transportFailure + case .protocolIncompatible: .protocolIncompatible + } + } + + private func jsonOutput(_ response: CLIResponseEnvelope) throws -> String { + var object: [String: Any] = [ + "schemaVersion": response.schemaVersion, + "requestID": response.requestID.uuidString.uppercased(), + "command": response.operation.rawValue, + "invocationSource": "cli", + "startedAt": CLIProtocolCodec.timestamp(response.startedAt), + "outcome": response.outcome.rawValue, + ] + object["protocolVersion"] = response.protocolVersion.map { $0 as Any } ?? NSNull() + if let reference = response.actionReference { + object["actionReference"] = [ + "providerID": reference.key.providerID, + "actionID": reference.key.actionID, + "schemaVersion": reference.schemaVersion, + ] + } + object["finishedAt"] = response.finishedAt.map { + CLIProtocolCodec.timestamp($0) as Any + } ?? NSNull() + object["message"] = response.message.map { $0 as Any } ?? NSNull() + if let rejection = response.rejection { + object["rejection"] = [ + "category": rejection.category, + "message": rejection.message.map { $0 as Any } ?? NSNull(), + ] + } else { + object["rejection"] = NSNull() + } + if let payload = response.payload { + object["data"] = try JSONSerialization.jsonObject(with: payload) + } else { + object["data"] = NSNull() + } + let data = try JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + guard let output = String(data: data, encoding: .utf8) else { + throw CLIProtocolCodecError.encodingFailed + } + return output + } + + private func decode(_ type: T.Type, _ payload: Data) throws -> T { + try CLIProtocolCodec.decodeResponse(type, from: payload) + } + + private func humanOutcome(_ outcome: CLIOutcome) -> String { + switch outcome { + case .completed: "Completed" + case .started: "Started" + case .cancelled: "Cancelled" + case .unavailable: "Unavailable" + case .confirmationDenied: "Confirmation denied" + case .timedOut: "Timed out" + case .invalidInput: "Invalid input" + case .unknownTarget: "Unknown target" + case .failed: "Failed" + case .hostUnavailable: "Host unavailable" + case .providerChanged: "Provider changed" + case .protocolIncompatible: "Protocol incompatible" + } + } + + private func nextPageLine(_ token: String?) -> [String] { + token.map { ["Next page token: \($0)"] } ?? [] + } +} diff --git a/Sources/MacToolsCLI/CLIParameterInput.swift b/Sources/MacToolsCLI/CLIParameterInput.swift new file mode 100644 index 00000000..e9e46fa6 --- /dev/null +++ b/Sources/MacToolsCLI/CLIParameterInput.swift @@ -0,0 +1,165 @@ +import Darwin +import CoreFoundation +import Foundation + +enum CLIParameterInputError: Error, Equatable { + case unknownParameter(String) + case invalidValue(String) + case sensitiveArgument(String) + case invalidJSON + case invalidFile + case insecureFile + case oversizedInput +} + +struct CLIParameterInput { + func arguments( + _ values: [String: String], + definitions: [CLIActionParameter] + ) throws -> [String: CLIParameterValue] { + let definitionsByID = try definitionsByID(definitions) + return try values.mapValuesWithKeys { name, rawValue in + guard let definition = definitionsByID[name] else { + throw CLIParameterInputError.unknownParameter(name) + } + guard definition.privacy != "sensitive" else { + throw CLIParameterInputError.sensitiveArgument(name) + } + return try parse(rawValue, kind: definition.kind, name: name) + } + } + + func json( + path: String, + definitions: [CLIActionParameter] + ) throws -> (values: [String: CLIParameterValue], source: CLIParameterInputSource) { + let data: Data + let source: CLIParameterInputSource + if path == "-" { + data = FileHandle.standardInput.readDataToEndOfFile() + source = .standardInput + } else { + data = try protectedFileData(path: path) + source = .protectedFile + } + guard data.count <= CLIProtocolVersion.maximumRequestBytes else { + throw CLIParameterInputError.oversizedInput + } + do { + try CLIProtocolCodec.rejectDuplicateTopLevelFields(in: data) + } catch { + throw CLIParameterInputError.invalidJSON + } + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw CLIParameterInputError.invalidJSON + } + let definitionsByID = try definitionsByID(definitions) + var values: [String: CLIParameterValue] = [:] + for (name, value) in object { + guard values[name] == nil else { throw CLIParameterInputError.invalidJSON } + guard let definition = definitionsByID[name] else { + throw CLIParameterInputError.unknownParameter(name) + } + switch definition.kind { + case "string": + guard let value = value as? String else { + throw CLIParameterInputError.invalidValue(name) + } + values[name] = .string(value) + case "boolean": + guard let value = value as? NSNumber, + CFGetTypeID(value) == CFBooleanGetTypeID() else { + throw CLIParameterInputError.invalidValue(name) + } + values[name] = .boolean(value.boolValue) + case "integer": + guard let value = value as? NSNumber, + CFGetTypeID(value) != CFBooleanGetTypeID() else { + throw CLIParameterInputError.invalidValue(name) + } + let decimal = value.decimalValue + var rounded = Decimal() + var decimalToRound = decimal + NSDecimalRound(&rounded, &decimalToRound, 0, .plain) + guard rounded == decimal, + decimal >= Decimal(Int64.min), decimal <= Decimal(Int64.max) else { + throw CLIParameterInputError.invalidValue(name) + } + values[name] = .integer(NSDecimalNumber(decimal: decimal).int64Value) + case "double": + guard let value = value as? NSNumber, + CFGetTypeID(value) != CFBooleanGetTypeID() else { + throw CLIParameterInputError.invalidValue(name) + } + let double = value.doubleValue + guard double.isFinite else { throw CLIParameterInputError.invalidValue(name) } + values[name] = .double(double) + default: + throw CLIParameterInputError.invalidValue(name) + } + } + return (values, source) + } + + private func definitionsByID( + _ definitions: [CLIActionParameter] + ) throws -> [String: CLIActionParameter] { + var values: [String: CLIActionParameter] = [:] + for definition in definitions { + guard values.updateValue(definition, forKey: definition.id) == nil else { + throw CLIParameterInputError.invalidValue(definition.id) + } + } + return values + } + + private func protectedFileData(path: String) throws -> Data { + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { throw CLIParameterInputError.invalidFile } + defer { close(descriptor) } + var metadata = stat() + guard fstat(descriptor, &metadata) == 0, + (metadata.st_mode & S_IFMT) == S_IFREG else { + throw CLIParameterInputError.invalidFile + } + guard metadata.st_uid == geteuid(), metadata.st_mode & 0o077 == 0 else { + throw CLIParameterInputError.insecureFile + } + guard metadata.st_size <= CLIProtocolVersion.maximumRequestBytes else { + throw CLIParameterInputError.oversizedInput + } + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: false).readDataToEndOfFile() + } + + private func parse(_ value: String, kind: String, name: String) throws -> CLIParameterValue { + switch kind { + case "string": return .string(value) + case "integer": + guard let parsed = Int64(value) else { throw CLIParameterInputError.invalidValue(name) } + return .integer(parsed) + case "double": + guard let parsed = Double(value), parsed.isFinite else { + throw CLIParameterInputError.invalidValue(name) + } + return .double(parsed) + case "boolean": + switch value.lowercased() { + case "true", "1", "yes": return .boolean(true) + case "false", "0", "no": return .boolean(false) + default: throw CLIParameterInputError.invalidValue(name) + } + default: + throw CLIParameterInputError.invalidValue(name) + } + } +} + +extension Dictionary { + func mapValuesWithKeys( + _ transform: (Key, Value) throws -> T + ) rethrows -> [Key: T] { + try Dictionary(uniqueKeysWithValues: map { key, value in + (key, try transform(key, value)) + }) + } +} diff --git a/Sources/MacToolsCLI/CLISignalCoordinator.swift b/Sources/MacToolsCLI/CLISignalCoordinator.swift new file mode 100644 index 00000000..81fdc1c6 --- /dev/null +++ b/Sources/MacToolsCLI/CLISignalCoordinator.swift @@ -0,0 +1,68 @@ +import Darwin +import Foundation + +final class CLISignalCoordinator { + private var sources: [DispatchSourceSignal] = [] + private var previousHandlers: [(signal: Int32, handler: sig_t?)] = [] + private let lock = NSLock() + private let state = CLISignalState() + private let installSignalHandler: @Sendable (Int32, sig_t?) -> sig_t? + private let onSignal: @Sendable () -> Void + + init( + signalNumbers: [Int32] = [SIGINT, SIGTERM], + installSignalHandler: @escaping @Sendable (Int32, sig_t?) -> sig_t? = { + signal($0, $1) + }, + onSignal: @escaping @Sendable () -> Void + ) { + self.installSignalHandler = installSignalHandler + self.onSignal = onSignal + for signalNumber in signalNumbers { + let previousHandler = installSignalHandler(signalNumber, SIG_IGN) + previousHandlers.append((signalNumber, previousHandler)) + let source = DispatchSource.makeSignalSource( + signal: signalNumber, + queue: .global(qos: .userInitiated) + ) + source.setEventHandler { [weak self] in + self?.handleSignal() + } + source.resume() + sources.append(source) + } + } + + func finish() { + guard state.beginFinishing() else { return } + let resources = lock.withLock { () -> ( + sources: [DispatchSourceSignal], + handlers: [(signal: Int32, handler: sig_t?)] + ) in + let resources = (sources, previousHandlers) + sources = [] + previousHandlers = [] + return resources + } + resources.sources.forEach { $0.cancel() } + resources.handlers.forEach { + _ = installSignalHandler($0.signal, $0.handler ?? SIG_DFL) + } + } + + deinit { + finish() + } + + private func handleSignal() { + if state.beginHandlingSignal() { onSignal() } + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/Sources/MacToolsCLI/main.swift b/Sources/MacToolsCLI/main.swift new file mode 100644 index 00000000..9933dec9 --- /dev/null +++ b/Sources/MacToolsCLI/main.swift @@ -0,0 +1,6 @@ +import Foundation + +let exitCode = await CLIApplication(client: CLIBrokerClient()).run( + arguments: Array(CommandLine.arguments.dropFirst()) +) +exit(exitCode) diff --git a/Sources/MacToolsCLIBroker/CLIBroker.swift b/Sources/MacToolsCLIBroker/CLIBroker.swift new file mode 100644 index 00000000..44a52537 --- /dev/null +++ b/Sources/MacToolsCLIBroker/CLIBroker.swift @@ -0,0 +1,409 @@ +import Foundation + +final class CLIBroker: NSObject, CLIBrokerXPCProtocol, NSXPCListenerDelegate { + private let identityValidator = CLIPeerIdentityValidator() + private let lock = NSLock() + private var hostConnection: NSXPCConnection? + private var hostRegistration: CLIHostRegistration? + private var clientConnections: Set = [] + private var admissionState = CLIRequestAdmissionState() + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + guard newConnection.effectiveUserIdentifier == geteuid() else { return false } + newConnection.exportedInterface = NSXPCInterface(with: CLIBrokerXPCProtocol.self) + newConnection.exportedObject = self + newConnection.remoteObjectInterface = NSXPCInterface(with: CLIHostXPCProtocol.self) + let identity = ObjectIdentifier(newConnection) + _ = lock.withLock { + clientConnections.insert(identity) + } + newConnection.invalidationHandler = { [weak self, weak newConnection] in + guard let self, let newConnection else { return } + self.connectionInvalidated(newConnection) + } + newConnection.interruptionHandler = { [weak self, weak newConnection] in + guard let self, let newConnection else { return } + self.connectionInvalidated(newConnection) + } + newConnection.activate() + return true + } + + func handshake(_ request: Data, withReply reply: @escaping (Data) -> Void) { + guard let connection = NSXPCConnection.current(), + identityValidator.accepts(connection, as: .commandLineTool) else { + reply(encodedHandshake(message: "Client authentication failed.")) + return + } + let hello: CLIHandshakeRequest + do { + hello = try CLIProtocolCodec.decodeRequest( + CLIHandshakeRequest.self, + from: request, + allowedKeys: [ + "minimumProtocolVersion", + "maximumProtocolVersion", + "clientVersion", + "clientBuild", + ] + ) + } catch { + reply(encodedHandshake(message: "Invalid handshake.")) + return + } + let selected = selectedVersion( + clientMinimum: hello.minimumProtocolVersion, + clientMaximum: hello.maximumProtocolVersion, + hostRegistration: lock.withLock { hostRegistration } + ) + let state = lock.withLock { (hostConnection != nil, hostRegistration) } + reply(encodedHandshake( + selectedVersion: selected, + hostReady: state.0, + hostRegistration: state.1, + message: selected == nil ? "No compatible protocol version." : nil + )) + } + + func registerHost(_ registration: Data, withReply reply: @escaping (Data) -> Void) { + guard let connection = NSXPCConnection.current(), + identityValidator.accepts(connection, as: .host) else { + reply(encodedHandshake(message: "Host authentication failed.")) + return + } + let decoded: CLIHostRegistration + do { + decoded = try CLIProtocolCodec.decodeRequest( + CLIHostRegistration.self, + from: registration, + allowedKeys: [ + "minimumProtocolVersion", + "maximumProtocolVersion", + "hostVersion", + "hostBuild", + ] + ) + } catch { + reply(encodedHandshake(message: "Invalid host registration.")) + return + } + let selected = selectedVersion( + clientMinimum: decoded.minimumProtocolVersion, + clientMaximum: decoded.maximumProtocolVersion, + hostRegistration: decoded + ) + guard selected != nil else { + reply(encodedHandshake(message: "No compatible host protocol version.")) + return + } + lock.withLock { + hostConnection = connection + hostRegistration = decoded + } + reply(encodedHandshake( + selectedVersion: selected, + hostReady: true, + hostRegistration: decoded, + message: nil + )) + } + + func send(_ request: Data, withReply reply: @escaping (Data) -> Void) { + guard let connection = NSXPCConnection.current(), + identityValidator.accepts(connection, as: .commandLineTool) else { + reply(encodedTransportFailure(request, message: "Client authentication failed.")) + return + } + guard request.count <= CLIProtocolVersion.maximumRequestBytes else { + reply(encodedTransportFailure(request, message: "Request is too large.")) + return + } + guard let envelope = try? CLIProtocolCodec.decodeRequest( + CLIRequestEnvelope.self, + from: request, + allowedKeys: [ + "protocolVersion", "requestID", "operation", "sentAt", + "invocationContext", "payload", + ] + ) else { + reply(Data()) + return + } + let clientID = ObjectIdentifier(connection) + let admissionRejection = lock.withLock { + admissionState.admit( + requestID: envelope.requestID, + clientID: clientID, + invocationContext: envelope.invocationContext + ) + } + if let admissionRejection { + reply(encodedAdmissionRejection(envelope, rejection: admissionRejection)) + return + } + guard let invocationContext = lock.withLock({ + admissionState.invocationContext( + requestID: envelope.requestID, + clientID: clientID + ) + }), let forwardedRequest = try? CLIProtocolCodec.encodeRequest( + envelope.replacingInvocationContext(invocationContext) + ) else { + lock.withLock { + admissionState.finish(requestID: envelope.requestID, clientID: clientID) + } + reply(encodedTransportFailure(request, message: "The request context is unavailable.")) + return + } + let finish = BrokerReplyOnce { [weak self] response in + if let self { + self.lock.withLock { + self.admissionState.finish( + requestID: envelope.requestID, + clientID: clientID + ) + } + } + reply(response) + } + guard let hostConnection = lock.withLock({ self.hostConnection }) else { + finish.call(encodedTransportFailure(request, message: "MacTools host is not ready.")) + return + } + guard let hostRegistration = lock.withLock({ self.hostRegistration }), + requestVersionIsSupported(envelope.protocolVersion, by: hostRegistration) else { + finish.call(encodedProtocolIncompatibility(request)) + return + } + let proxy = hostConnection.remoteObjectProxyWithErrorHandler { _ in + finish.call(self.encodedTransportFailure(request, message: "Host transport failed.")) + } + guard let host = proxy as? CLIHostXPCProtocol else { + finish.call(encodedTransportFailure(request, message: "Host interface is unavailable.")) + return + } + let didBeginForwarding = lock.withLock { + admissionState.beginForwarding( + requestID: envelope.requestID, + clientID: clientID + ) + } + if didBeginForwarding { + // Never invoke an XPC proxy while holding the broker state lock. If cancel + // overtakes this send, the host's bounded pre-registration relay records it + // and consumes the request before creating its execution task. + host.handle(forwardedRequest) { response in + guard response.count <= CLIProtocolVersion.maximumResponseBytes else { + finish.call(self.encodedTransportFailure( + request, + message: "Host response is too large." + )) + return + } + finish.call(response) + } + } else { + finish.call(encodedAdmissionRejection( + envelope, + rejection: .cancelledBeforeAdmission + )) + } + } + + func cancel(_ requestID: UUID, withReply reply: @escaping (Bool) -> Void) { + guard let connection = NSXPCConnection.current(), + identityValidator.accepts(connection, as: .commandLineTool) else { + reply(false) + return + } + let cancellation = lock.withLock { + admissionState.cancel( + requestID: requestID, + clientID: ObjectIdentifier(connection) + ) + } + guard let cancellation else { + reply(false) + return + } + guard cancellation == .forwardToHost else { + reply(true) + return + } + guard let hostConnection = lock.withLock({ self.hostConnection }) else { + reply(false) + return + } + let proxy = hostConnection.remoteObjectProxyWithErrorHandler { _ in reply(false) } + guard let host = proxy as? CLIHostXPCProtocol else { + reply(false) + return + } + host.cancel(requestID, withReply: reply) + } + + private func connectionInvalidated(_ connection: NSXPCConnection) { + let cancelledRequestIDs = lock.withLock { () -> [UUID] in + clientConnections.remove(ObjectIdentifier(connection)) + let requestIDs = admissionState.removeRequests( + clientID: ObjectIdentifier(connection) + ) + if hostConnection === connection { + hostConnection = nil + hostRegistration = nil + } + return requestIDs + } + guard !cancelledRequestIDs.isEmpty, + let hostConnection = lock.withLock({ self.hostConnection }), + let host = hostConnection.remoteObjectProxy as? CLIHostXPCProtocol else { return } + cancelledRequestIDs.forEach { host.cancel($0) { _ in } } + } + + private func selectedVersion( + clientMinimum: Int, + clientMaximum: Int, + hostRegistration: CLIHostRegistration? + ) -> Int? { + CLIProtocolNegotiator.selectedVersion( + clientMinimum: clientMinimum, + clientMaximum: clientMaximum, + hostMinimum: hostRegistration?.minimumProtocolVersion, + hostMaximum: hostRegistration?.maximumProtocolVersion + ) + } + + private func requestVersionIsSupported( + _ version: Int, + by registration: CLIHostRegistration + ) -> Bool { + (CLIProtocolVersion.minimum...CLIProtocolVersion.current).contains(version) + && (registration.minimumProtocolVersion...registration.maximumProtocolVersion).contains(version) + } + + private func encodedProtocolIncompatibility(_ requestData: Data) -> Data { + guard let request = try? CLIProtocolCodec.decodeRequest( + CLIRequestEnvelope.self, + from: requestData, + allowedKeys: [ + "protocolVersion", "requestID", "operation", "sentAt", + "invocationContext", "payload", + ] + ) else { return Data() } + return (try? CLIProtocolCodec.encodeResponse(CLIResponseEnvelope.failure( + request: request, + outcome: .protocolIncompatible, + category: "protocolIncompatible", + message: "The registered host does not support this protocol version." + ))) ?? Data() + } + + private func encodedHandshake( + selectedVersion: Int? = nil, + hostReady: Bool = false, + hostRegistration: CLIHostRegistration? = nil, + message: String? + ) -> Data { + let info = CLIServiceConfiguration.executableInfoDictionary() + let response = CLIHandshakeResponse( + selectedProtocolVersion: selectedVersion, + brokerVersion: info["CFBundleShortVersionString"] as? String + ?? hostRegistration?.hostVersion + ?? "unknown", + brokerBuild: info["CFBundleVersion"] as? String + ?? hostRegistration?.hostBuild + ?? "unknown", + hostVersion: hostRegistration?.hostVersion, + hostBuild: hostRegistration?.hostBuild, + hostReady: hostReady, + message: message + ) + return (try? CLIProtocolCodec.encodeResponse(response)) ?? Data() + } + + private func encodedTransportFailure(_ requestData: Data, message: String) -> Data { + guard let request = try? CLIProtocolCodec.decodeRequest( + CLIRequestEnvelope.self, + from: requestData, + allowedKeys: [ + "protocolVersion", "requestID", "operation", "sentAt", + "invocationContext", "payload", + ] + ) else { return Data() } + return (try? CLIProtocolCodec.encodeResponse(CLIResponseEnvelope.failure( + request: request, + outcome: .hostUnavailable, + category: "hostTransportFailure", + message: message + ))) ?? Data() + } + + private func encodedAdmissionRejection( + _ request: CLIRequestEnvelope, + rejection: CLIRequestAdmissionState.Rejection + ) -> Data { + let outcome: CLIOutcome + let category: String + let message: String + switch rejection { + case .duplicateRequestID: + outcome = .hostUnavailable + category = "duplicateRequestID" + message = "The request identifier is already active." + case .globalCapacity: + outcome = .hostUnavailable + category = "globalCapacity" + message = "The broker request limit has been reached." + case .clientCapacity: + outcome = .hostUnavailable + category = "clientCapacity" + message = "The client request limit has been reached." + case .recursiveInvocation: + outcome = .invalidInput + category = "recursiveInvocation" + message = "Recursive CLI invocation is not allowed." + case .invalidInvocationContext: + outcome = .invalidInput + category = "invalidInvocationContext" + message = "The CLI invocation context is invalid." + case .cancelledBeforeAdmission: + outcome = .cancelled + category = "cancelled" + message = "The request was cancelled." + } + return (try? CLIProtocolCodec.encodeResponse(CLIResponseEnvelope.failure( + request: request, + outcome: outcome, + category: category, + message: message + ))) ?? Data() + } +} + +private final class BrokerReplyOnce: @unchecked Sendable { + private let lock = NSLock() + private var reply: ((Value) -> Void)? + + init(_ reply: @escaping (Value) -> Void) { + self.reply = reply + } + + func call(_ value: Value) { + let reply = lock.withLock { () -> ((Value) -> Void)? in + defer { self.reply = nil } + return self.reply + } + reply?(value) + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/Sources/MacToolsCLIBroker/main.swift b/Sources/MacToolsCLIBroker/main.swift new file mode 100644 index 00000000..43ede679 --- /dev/null +++ b/Sources/MacToolsCLIBroker/main.swift @@ -0,0 +1,15 @@ +import Dispatch +import Foundation + +let broker = CLIBroker() +let listener = NSXPCListener(machServiceName: CLIServiceConfiguration.runtimeBrokerServiceName) +guard let requirement = CLIPeerIdentityValidator().brokerListenerRequirement() else { + exit(CLIExitCode.transportFailure.rawValue) +} +listener.setConnectionCodeSigningRequirement(requirement) +listener.delegate = broker +listener.resume() + +// A standalone LaunchAgent does not get the implicit process lifetime of an XPC service bundle. +// Keep its dispatch-backed XPC listener alive after the top-level entry point finishes setup. +dispatchMain() diff --git a/Sources/MacToolsPluginKit/ActionModels.swift b/Sources/MacToolsPluginKit/ActionModels.swift index 0d682d40..bc2210fa 100644 --- a/Sources/MacToolsPluginKit/ActionModels.swift +++ b/Sources/MacToolsPluginKit/ActionModels.swift @@ -14,6 +14,26 @@ public struct ActionKey: Hashable, Codable, Sendable, Identifiable { } } +/// Opaque context attached by the trusted CLI broker while an external request is active. +/// Command-running plugins propagate the child environment values, but do not interpret or +/// generate the chain identifier themselves. +public struct PluginCLIInvocationContext: Equatable, Sendable { + public static let chainEnvironmentKey = "MACTOOLS_CLI_CHAIN_ID" + public static let depthEnvironmentKey = "MACTOOLS_CLI_CHAIN_DEPTH" + + public let chainID: UUID + public let depth: Int + + public init(chainID: UUID, depth: Int) { + self.chainID = chainID + self.depth = depth + } +} + +public enum PluginActionExecutionContext { + @TaskLocal public static var cliInvocation: PluginCLIInvocationContext? +} + public enum ActionParameterValue: Hashable, Codable, Sendable { case string(String) case integer(Int64) @@ -429,6 +449,7 @@ public struct ActionExposureSurface: RawRepresentable, Hashable, Codable, Sendab public let rawValue: String public static let appIntents = ActionExposureSurface(rawValue: "app-intents") + public static let cli = ActionExposureSurface(rawValue: "cli") public init(rawValue: String) { self.rawValue = rawValue @@ -443,17 +464,34 @@ public enum ActionExposurePolicy: String, Hashable, Codable, Sendable { case excluded } -public enum ActionExecutionSource: String, Hashable, Codable, Sendable { - case unifiedSearch - case globalShortcut - case runLink - case workflow - case automaticRule - case actionGrid - case trackpadGesture - case appIntent - case manual - case test +public struct ActionExecutionSource: RawRepresentable, Hashable, Codable, Sendable { + public let rawValue: String + + public static let unifiedSearch = ActionExecutionSource(rawValue: "unifiedSearch") + public static let globalShortcut = ActionExecutionSource(rawValue: "globalShortcut") + public static let runLink = ActionExecutionSource(rawValue: "runLink") + public static let workflow = ActionExecutionSource(rawValue: "workflow") + public static let automaticRule = ActionExecutionSource(rawValue: "automaticRule") + public static let actionGrid = ActionExecutionSource(rawValue: "actionGrid") + public static let trackpadGesture = ActionExecutionSource(rawValue: "trackpadGesture") + public static let appIntent = ActionExecutionSource(rawValue: "appIntent") + public static let cli = ActionExecutionSource(rawValue: "cli") + public static let manual = ActionExecutionSource(rawValue: "manual") + public static let test = ActionExecutionSource(rawValue: "test") + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.init(rawValue: try container.decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } public enum ActionExecutionMode: String, Hashable, Codable, Sendable { @@ -539,6 +577,15 @@ public protocol PluginActionProviding: AnyObject { func beginAction(_ invocation: ActionInvocation) throws -> ActionExecutionHandle } +/// Optional initial preparation for providers whose published action definitions or catalog +/// entries depend on asynchronous discovery. The host awaits every conforming provider before +/// exposing its action registry to external processes, then rebuilds the registry synchronously. +/// Later catalog changes continue to use `onStateChange` like any other plugin state update. +@MainActor +public protocol PluginActionCatalogPreparing: AnyObject { + func prepareActionCatalogForExternalDiscovery() async +} + /// Describes a host-rendered shortcut section for a subset of a plugin's canonical actions. /// /// This companion contract keeps shortcut persistence, conflict handling, and registration in diff --git a/Sources/MacToolsPluginKit/PluginKitCompatibility.swift b/Sources/MacToolsPluginKit/PluginKitCompatibility.swift index 9b75c315..fcfae4ed 100644 --- a/Sources/MacToolsPluginKit/PluginKitCompatibility.swift +++ b/Sources/MacToolsPluginKit/PluginKitCompatibility.swift @@ -1,5 +1,5 @@ import Foundation public enum PluginKitCompatibility { - public static let currentVersion = 5 + public static let currentVersion = 6 } diff --git a/Sources/Resources/Localization/Settings.xcstrings b/Sources/Resources/Localization/Settings.xcstrings index 207970ef..b22d5e9d 100644 --- a/Sources/Resources/Localization/Settings.xcstrings +++ b/Sources/Resources/Localization/Settings.xcstrings @@ -1,6 +1,46 @@ { "sourceLanguage": "zh-Hans", "strings": { + "general.section.commandLine": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Command Line" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令行" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "命令列" } } + } }, + "commandLine.title": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "MacTools Command Line" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "MacTools 命令行" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "MacTools 命令列" } } + } }, + "commandLine.approve": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Allow in Background" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "允许后台运行" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "允許背景執行" } } + } }, + "commandLine.download": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Download CLI" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "下载 CLI" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "下載 CLI" } } + } }, + "commandLine.enable": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Enable" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "启用" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "啟用" } } + } }, + "commandLine.enabled": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The separately installed mactools-cli may connect to MacTools." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已允许单独安装的 mactools-cli 连接到 MacTools。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "已允許單獨安裝的 mactools-cli 連接到 MacTools。" } } + } }, + "commandLine.requiresApproval": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Allow the MacTools command-line broker to run in the background in System Settings." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请在系统设置中允许 MacTools 命令行代理后台运行。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "請在系統設定中允許 MacTools 命令列代理程式在背景執行。" } } + } }, + "commandLine.description": { "extractionState": "manual", "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "After installing mactools-cli separately, enable local command-line integration here." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "单独安装 mactools-cli 后,在此启用本机命令行集成。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "單獨安裝 mactools-cli 後,在此啟用本機命令列整合。" } } + } }, "appIntents.title": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", "value": "اختصارات Apple وSiri وSpotlight" } }, "de": { "stringUnit": { "state": "translated", "value": "Apple-Kurzbefehle, Siri und Spotlight" } }, diff --git a/Tests/App/AutomationStartupCoordinatorTests.swift b/Tests/App/AutomationStartupCoordinatorTests.swift index 27f843bc..abeb334b 100644 --- a/Tests/App/AutomationStartupCoordinatorTests.swift +++ b/Tests/App/AutomationStartupCoordinatorTests.swift @@ -3,6 +3,26 @@ import XCTest @MainActor final class AutomationStartupCoordinatorTests: XCTestCase { + func testCancelledPreparationDoesNotStartAutomaticRules() async { + var startCount = 0 + let coordinator = AutomationStartupCoordinator { startCount += 1 } + + let preparation = Task { @MainActor in + await coordinator.startAfterActionRegistryPreparation { + try? await Task.sleep(for: .seconds(10)) + } + } + for _ in 0 ..< 100 where !coordinator.isPreparing { + await Task.yield() + } + preparation.cancel() + await preparation.value + + XCTAssertFalse(coordinator.isPreparing) + XCTAssertFalse(coordinator.hasStarted) + XCTAssertEqual(startCount, 0) + } + func testTriggerSourceDoesNotStartDuringDelayedActionRegistryPreparation() async { var triggerHandler: (() -> Void)? var startCount = 0 diff --git a/Tests/Core/Actions/ActionExecutorTests.swift b/Tests/Core/Actions/ActionExecutorTests.swift index 7c9df4ce..bc6a6b9d 100644 --- a/Tests/Core/Actions/ActionExecutorTests.swift +++ b/Tests/Core/Actions/ActionExecutorTests.swift @@ -145,6 +145,25 @@ final class ActionExecutorTests: XCTestCase { XCTAssertEqual(provider.beginCount, 0) } + func testCLIExecutionEnforcesProviderExposurePolicy() async { + let registry = ActionRegistry() + let provider = ActionExecutorTestProvider() + provider.exposurePolicy = .excluded + let definition = makeActionDefinition() + registry.synchronize([provider.registration(definition: definition)]) + + let outcome = await ActionExecutor(registry: registry).execute( + ActionInvocation( + reference: ActionReference(key: definition.key), + source: .cli, + mode: .foreground + ) + ) + + XCTAssertEqual(outcome, .rejected(.systemExposureUnavailable)) + XCTAssertEqual(provider.beginCount, 0) + } + func testAppIntentExecutionRechecksExposurePolicyAfterConfirmation() async { let registry = ActionRegistry() let provider = ActionExecutorTestProvider() diff --git a/Tests/Core/Actions/PluginHostActionRegistryTests.swift b/Tests/Core/Actions/PluginHostActionRegistryTests.swift index e7649afd..f456e576 100644 --- a/Tests/Core/Actions/PluginHostActionRegistryTests.swift +++ b/Tests/Core/Actions/PluginHostActionRegistryTests.swift @@ -7,6 +7,272 @@ import XCTest @MainActor final class PluginHostActionRegistryTests: XCTestCase { + func testExternalDiscoveryPreparationAwaitsProviderAndPublishesFinalCatalog() async { + let plugin = PreparingActionTestPlugin() + let host = makePluginHostForTests(plugins: [plugin]) + XCTAssertNil(host.actionRegistry.definition(for: plugin.actionKey)) + + let preparation = Task { @MainActor in + await host.prepareActionCatalogForExternalDiscovery() + } + for _ in 0 ..< 100 where !plugin.isPreparing { + await Task.yield() + } + + XCTAssertTrue(plugin.isPreparing) + XCTAssertNil(host.actionRegistry.definition(for: plugin.actionKey)) + + plugin.finishPreparation() + await preparation.value + + XCTAssertEqual(host.actionRegistry.definition(for: plugin.actionKey), plugin.definition) + XCTAssertEqual( + host.actionRegistry.catalogEntries.map(\.reference.key).filter { + $0 == plugin.actionKey + }, + [plugin.actionKey] + ) + } + + func testExternalDiscoveryPreparationTimesOutStalledProviderAndContinues() async { + let stalledPlugin = PreparingActionTestPlugin( + id: "stalled-provider", + initiallyPrepared: true + ) + let readyPlugin = PreparingActionTestPlugin(id: "ready-provider") + let host = makePluginHostForTests(plugins: [stalledPlugin, readyPlugin]) + XCTAssertEqual(host.actionRegistry.definition(for: stalledPlugin.actionKey), stalledPlugin.definition) + + let preparation = Task { @MainActor in + await host.prepareActionCatalogForExternalDiscovery( + providerTimeout: .milliseconds(50) + ) + } + for _ in 0 ..< 100 where !stalledPlugin.isPreparing { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertTrue(stalledPlugin.isPreparing) + + for _ in 0 ..< 100 where !readyPlugin.isPreparing { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertTrue(readyPlugin.isPreparing) + readyPlugin.finishPreparation() + await preparation.value + + XCTAssertNil(host.actionRegistry.definition(for: stalledPlugin.actionKey)) + XCTAssertEqual(host.actionRegistry.definition(for: readyPlugin.actionKey), readyPlugin.definition) + stalledPlugin.finishPreparation() + for _ in 0 ..< 100 + where host.actionRegistry.definition(for: stalledPlugin.actionKey) == nil { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertEqual(host.actionRegistry.definition(for: stalledPlugin.actionKey), stalledPlugin.definition) + } + + func testMultipleStalledProvidersShareOneOverallTimeoutWindow() async { + let firstPlugin = PreparingActionTestPlugin(id: "first-stalled-provider") + let secondPlugin = PreparingActionTestPlugin(id: "second-stalled-provider") + let host = makePluginHostForTests(plugins: [firstPlugin, secondPlugin]) + let clock = ContinuousClock() + let start = clock.now + + await host.prepareActionCatalogForExternalDiscovery( + providerTimeout: .milliseconds(50) + ) + + XCTAssertLessThan(start.duration(to: clock.now), .milliseconds(150)) + XCTAssertEqual(firstPlugin.preparationCount, 1) + XCTAssertEqual(secondPlugin.preparationCount, 1) + firstPlugin.finishPreparation() + secondPlugin.finishPreparation() + } + + func testStalePreparationCompletionCannotClearNewerTimeoutForSameProvider() async { + let plugin = PreparingActionTestPlugin( + id: "reprepared-provider", + initiallyPrepared: true + ) + let host = makePluginHostForTests(plugins: [plugin]) + + await host.prepareActionCatalogForExternalDiscovery( + providerTimeout: .milliseconds(20) + ) + await host.prepareActionCatalogForExternalDiscovery( + providerTimeout: .milliseconds(20) + ) + XCTAssertEqual(plugin.preparationCount, 2) + XCTAssertNil(host.actionRegistry.definition(for: plugin.actionKey)) + + plugin.finishPreparation() + for _ in 0 ..< 20 { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertNil(host.actionRegistry.definition(for: plugin.actionKey)) + + plugin.finishPreparation() + for _ in 0 ..< 100 + where host.actionRegistry.definition(for: plugin.actionKey) == nil { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertEqual(host.actionRegistry.definition(for: plugin.actionKey), plugin.definition) + } + + func testSupersededPreparationCancelsEveryObsoleteProviderRace() async { + let firstPlugin = PreparingActionTestPlugin(id: "first-overlap-provider") + let secondPlugin = PreparingActionTestPlugin(id: "second-overlap-provider") + let host = makePluginHostForTests(plugins: [firstPlugin, secondPlugin]) + + let firstPass = Task { @MainActor in + await host.prepareActionCatalogForExternalDiscovery(providerTimeout: .seconds(1)) + } + for _ in 0 ..< 100 + where firstPlugin.preparationCount < 1 || secondPlugin.preparationCount < 1 { + try? await Task.sleep(for: .milliseconds(2)) + } + let secondPass = Task { @MainActor in + await host.prepareActionCatalogForExternalDiscovery(providerTimeout: .seconds(1)) + } + for _ in 0 ..< 100 + where firstPlugin.preparationCount < 2 || secondPlugin.preparationCount < 2 { + try? await Task.sleep(for: .milliseconds(2)) + } + + await firstPass.value + for _ in 0 ..< 100 + where firstPlugin.cancellationCount < 1 || secondPlugin.cancellationCount < 1 { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertEqual(firstPlugin.cancellationCount, 1) + XCTAssertEqual(secondPlugin.cancellationCount, 1) + + firstPlugin.finishPreparation() + secondPlugin.finishPreparation() + firstPlugin.finishPreparation() + secondPlugin.finishPreparation() + await secondPass.value + } + + func testDirectPreparationCancellationStopsProviderWithoutLatePublication() async { + let plugin = PreparingActionTestPlugin(id: "direct-cancellation-provider") + let host = makePluginHostForTests(plugins: [plugin]) + let preparation = Task { @MainActor in + await host.prepareActionCatalogForExternalDiscovery(providerTimeout: .seconds(1)) + } + for _ in 0 ..< 100 where plugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + let clock = ContinuousClock() + let cancellationStarted = clock.now + + preparation.cancel() + await preparation.value + + XCTAssertLessThan( + cancellationStarted.duration(to: clock.now), + .milliseconds(150) + ) + for _ in 0 ..< 100 where plugin.cancellationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertEqual(plugin.cancellationCount, 1) + XCTAssertNil(host.actionRegistry.definition(for: plugin.actionKey)) + + plugin.finishPreparation() + for _ in 0 ..< 20 { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertNil(host.actionRegistry.definition(for: plugin.actionKey)) + } + + func testDynamicReplacementDoesNotReprepareUnrelatedProvider() async { + let unrelatedPlugin = PreparingActionTestPlugin(id: "unrelated-provider") + let firstDynamicPlugin = PreparingActionTestPlugin(id: "changed-dynamic-provider") + let replacementPlugin = PreparingActionTestPlugin(id: "changed-dynamic-provider") + let host = makePluginHostForTests(plugins: [unrelatedPlugin]) + + host.replaceDynamicPluginsForTests([firstDynamicPlugin], providerTimeout: .seconds(1)) + for _ in 0 ..< 100 where firstDynamicPlugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + host.replaceDynamicPluginsForTests([replacementPlugin], providerTimeout: .seconds(1)) + for _ in 0 ..< 100 where replacementPlugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + + XCTAssertEqual(unrelatedPlugin.preparationCount, 0) + firstDynamicPlugin.finishPreparation() + replacementPlugin.finishPreparation() + await host.waitForDynamicPluginActionCatalogPreparationForTests() + } + + func testDynamicReplacementBeforeOldPreparationCompletesPublishesOnlyReplacement() async { + let oldPlugin = PreparingActionTestPlugin( + id: "replaceable-provider", + initiallyPrepared: true, + definitionTitle: "Old Action" + ) + let newPlugin = PreparingActionTestPlugin( + id: "replaceable-provider", + definitionTitle: "New Action" + ) + let host = makePluginHostForTests(plugins: []) + + host.replaceDynamicPluginsForTests([oldPlugin], providerTimeout: .seconds(1)) + for _ in 0 ..< 100 where oldPlugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + host.replaceDynamicPluginsForTests([newPlugin], providerTimeout: .seconds(1)) + for _ in 0 ..< 100 where newPlugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + + oldPlugin.finishPreparation() + for _ in 0 ..< 20 { + try? await Task.sleep(for: .milliseconds(2)) + } + XCTAssertNil(host.actionRegistry.definition(for: newPlugin.actionKey)) + + newPlugin.finishPreparation() + await host.waitForDynamicPluginActionCatalogPreparationForTests() + XCTAssertEqual( + host.actionRegistry.definition(for: newPlugin.actionKey)?.title, + "New Action" + ) + } + + func testDynamicReplacementBeforeOldTimeoutDoesNotQuarantineReplacement() async { + let oldPlugin = PreparingActionTestPlugin( + id: "timeout-replacement-provider", + initiallyPrepared: true, + definitionTitle: "Old Action" + ) + let newPlugin = PreparingActionTestPlugin( + id: "timeout-replacement-provider", + definitionTitle: "New Action" + ) + let host = makePluginHostForTests(plugins: []) + + host.replaceDynamicPluginsForTests([oldPlugin], providerTimeout: .milliseconds(30)) + for _ in 0 ..< 100 where oldPlugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + host.replaceDynamicPluginsForTests([newPlugin], providerTimeout: .seconds(1)) + for _ in 0 ..< 100 where newPlugin.preparationCount == 0 { + try? await Task.sleep(for: .milliseconds(2)) + } + + try? await Task.sleep(for: .milliseconds(50)) + XCTAssertNil(host.actionRegistry.definition(for: newPlugin.actionKey)) + newPlugin.finishPreparation() + await host.waitForDynamicPluginActionCatalogPreparationForTests() + XCTAssertEqual( + host.actionRegistry.definition(for: newPlugin.actionKey)?.title, + "New Action" + ) + oldPlugin.finishPreparation() + } + func testHostDistributesInputGestureClaimsOnlyToOtherPlugins() { let owner = InputGestureClaimTestPlugin(id: "gesture-owner", claims: [ PluginInputGestureClaim(id: "trackpad.tap.3", title: "Three-Finger Tap"), @@ -1338,6 +1604,79 @@ final class PluginHostActionRegistryTests: XCTestCase { } } +@MainActor +private final class PreparingActionTestPlugin: + MacToolsPlugin, + PluginActionProviding, + PluginActionCatalogPreparing +{ + let metadata: PluginMetadata + let actionKey: ActionKey + let definition: ActionDefinition + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + private(set) var isPreparing = false + private(set) var preparationCount = 0 + private(set) var cancellationCount = 0 + private var isPrepared: Bool + private var continuations: [CheckedContinuation] = [] + + init( + id: String = "preparing-action-provider", + initiallyPrepared: Bool = false, + definitionTitle: String = "Prepared Action" + ) { + metadata = PluginMetadata( + id: id, + title: "Preparing Actions", + iconName: "clock", + iconTint: .blue, + order: 0, + defaultDescription: "Tests external discovery preparation" + ) + actionKey = ActionKey(providerID: id, actionID: "prepared") + definition = ActionDefinition( + key: actionKey, + title: definitionTitle, + description: "Appears after asynchronous preparation.", + systemImage: "clock", + externalInvocationPolicy: .allowed, + capabilities: [.background] + ) + isPrepared = initiallyPrepared + } + + var actionDefinitions: [ActionDefinition] { isPrepared ? [definition] : [] } + var actionCatalogEntries: [ActionCatalogEntry] { + isPrepared + ? [ActionCatalogEntry(reference: ActionReference(key: actionKey), title: definition.title)] + : [] + } + + func prepareActionCatalogForExternalDiscovery() async { + isPreparing = true + preparationCount += 1 + await withTaskCancellationHandler { + await withCheckedContinuation { continuations.append($0) } + } onCancel: { + Task { @MainActor [weak self] in + self?.cancellationCount += 1 + } + } + } + + func finishPreparation() { + isPrepared = true + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func beginAction(_ invocation: ActionInvocation) throws -> ActionExecutionHandle { + ActionExecutionHandle { .succeeded() } + } +} + @MainActor private final class InputGestureClaimTestPlugin: MacToolsPlugin, PluginInputGestureClaimProviding { let metadata: PluginMetadata diff --git a/Tests/Core/CLI/CLIActionCatalogProjectionTests.swift b/Tests/Core/CLI/CLIActionCatalogProjectionTests.swift new file mode 100644 index 00000000..e852e156 --- /dev/null +++ b/Tests/Core/CLI/CLIActionCatalogProjectionTests.swift @@ -0,0 +1,49 @@ +import MacToolsPluginKit +import XCTest +@testable import MacTools + +final class CLIActionCatalogProjectionTests: XCTestCase { + func testGroupsKeepEveryPresetUnderOneStableActionInCatalogOrder() throws { + let enabled = try ActionParameterSet(["enabled": .boolean(true)]) + let disabled = try ActionParameterSet(["enabled": .boolean(false)]) + let appearanceKey = ActionKey(providerID: "appearance", actionID: "set-enabled") + let otherKey = ActionKey(providerID: "appearance", actionID: "toggle") + let entries = [ + ActionCatalogEntry( + reference: ActionReference(key: appearanceKey, parameters: enabled), + title: "Enable Dark Mode" + ), + ActionCatalogEntry( + reference: ActionReference(key: appearanceKey, parameters: disabled), + title: "Enable Light Mode" + ), + ActionCatalogEntry( + reference: ActionReference(key: otherKey), + title: "Toggle Appearance" + ) + ] + + let projected = CLIActionCatalogProjection.groups(entries) + + XCTAssertEqual(projected.map(\.key), [appearanceKey, otherKey]) + XCTAssertEqual(projected.map(\.entries.count), [2, 1]) + XCTAssertEqual( + projected[0].entries.map(\.title), + ["Enable Dark Mode", "Enable Light Mode"] + ) + } + + func testGroupsDoNotMergeMatchingActionIDsAcrossProviders() { + let firstKey = ActionKey(providerID: "first", actionID: "set-enabled") + let secondKey = ActionKey(providerID: "second", actionID: "set-enabled") + let entries = [ + ActionCatalogEntry(reference: ActionReference(key: firstKey), title: "First"), + ActionCatalogEntry(reference: ActionReference(key: secondKey), title: "Second") + ] + + XCTAssertEqual( + CLIActionCatalogProjection.groups(entries).map(\.key), + [firstKey, secondKey] + ) + } +} diff --git a/Tests/Core/CLI/CLIArgumentParserTests.swift b/Tests/Core/CLI/CLIArgumentParserTests.swift new file mode 100644 index 00000000..0101a4e1 --- /dev/null +++ b/Tests/Core/CLI/CLIArgumentParserTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import MacTools + +final class CLIArgumentParserTests: XCTestCase { + func testParsesActionRunWithTypedInputOptions() throws { + let command = try CLIArgumentParser().parse([ + "actions", "run", "display/brightness", + "--parameter", "level=50", "--no-wait", "--json", + ]) + guard case let .actionRun(arguments) = command else { + return XCTFail("Expected action run") + } + XCTAssertEqual(arguments.target, "display/brightness") + XCTAssertEqual(arguments.rawParameters, ["level": "50"]) + XCTAssertTrue(arguments.noWait) + XCTAssertTrue(arguments.json) + } + + func testRejectsDuplicateOrConflictingInputSources() throws { + XCTAssertThrowsError(try CLIArgumentParser().parse([ + "actions", "run", "provider/action", + "--input-json", "one.json", "--input-json", "two.json", + ])) + XCTAssertThrowsError(try CLIArgumentParser().parse([ + "actions", "run", "provider/action", + "--parameter", "name=value", "--input-json", "input.json", + ])) + XCTAssertThrowsError(try CLIArgumentParser().parse([ + "workflows", "run", "Daily", "--parameter", "name=value", + ])) + } + + func testRejectsInvalidActionIdentity() { + XCTAssertThrowsError(try CLIArgumentParser().parse([ + "actions", "describe", "not-an-action", + ])) + } +} diff --git a/Tests/Core/CLI/CLIBrokerServiceControllerTests.swift b/Tests/Core/CLI/CLIBrokerServiceControllerTests.swift new file mode 100644 index 00000000..d87e2a9f --- /dev/null +++ b/Tests/Core/CLI/CLIBrokerServiceControllerTests.swift @@ -0,0 +1,267 @@ +import ServiceManagement +import XCTest +@testable import MacTools + +@MainActor +final class CLIBrokerServiceControllerTests: XCTestCase { + func testUnregisterFailureRemainsEnabledAndReportsFailure() { + let service = FakeCLIBrokerService(status: .enabled) + service.unregisterError = FakeCLIBrokerServiceError.refused + let controller = makeController(service: service, registeredFingerprint: "current") + + XCTAssertFalse(controller.unregister()) + XCTAssertEqual(controller.status, .enabled) + XCTAssertNotNil(controller.lastError) + XCTAssertEqual(service.unregisterCallCount, 1) + } + + func testSuccessfulUnregisterReportsNotRegistered() { + let service = FakeCLIBrokerService(status: .enabled) + let controller = makeController(service: service, registeredFingerprint: "current") + + XCTAssertTrue(controller.unregister()) + XCTAssertEqual(controller.status, .notRegistered) + XCTAssertNil(controller.lastError) + } + + func testRegistrationFailureReportsFailure() { + let service = FakeCLIBrokerService(status: .notRegistered) + service.registerError = FakeCLIBrokerServiceError.refused + let controller = makeController(service: service) + + XCTAssertFalse(controller.ensureRegistered()) + XCTAssertEqual(controller.status, .registrationFailed) + XCTAssertNotNil(controller.lastError) + } + + func testSuccessfulRegistrationRecordsCurrentFingerprint() { + let service = FakeCLIBrokerService(status: .notRegistered) + let store = FakeCLIBrokerRegistrationStore(registeredFingerprint: nil) + let controller = makeController(service: service, store: store) + + XCTAssertTrue(controller.ensureRegistered()) + + XCTAssertEqual(service.registerCallCount, 1) + XCTAssertEqual(store.registeredFingerprint, "current") + XCTAssertEqual(controller.status, .enabled) + } + + func testReconcileCyclesRegisteredServiceAfterAppUpgrade() { + let service = FakeCLIBrokerService(status: .enabled) + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "old", + enabledIntent: true + ) + let controller = makeController(service: service, store: store) + + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.unregisterCallCount, 1) + XCTAssertEqual(service.registerCallCount, 1) + XCTAssertEqual(store.registeredFingerprint, "current") + XCTAssertEqual(controller.status, .enabled) + } + + func testReconcileKeepsCurrentRegisteredServiceRunning() { + let service = FakeCLIBrokerService(status: .enabled) + let controller = makeController(service: service, registeredFingerprint: "current") + + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.unregisterCallCount, 0) + XCTAssertEqual(service.registerCallCount, 0) + } + + func testReconcileDoesNotEnableAnUnregisteredService() { + let service = FakeCLIBrokerService(status: .notRegistered) + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "old", + enabledIntent: false + ) + let controller = makeController(service: service, store: store) + + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.unregisterCallCount, 0) + XCTAssertEqual(service.registerCallCount, 0) + XCTAssertEqual(controller.status, .notRegistered) + XCTAssertEqual(store.enabledIntent, false) + } + + func testReconcileReportsReregistrationFailure() { + let service = FakeCLIBrokerService(status: .enabled) + service.registerError = FakeCLIBrokerServiceError.refused + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "old", + enabledIntent: true + ) + let controller = makeController(service: service, store: store) + + XCTAssertFalse(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.unregisterCallCount, 1) + XCTAssertEqual(service.registerCallCount, 1) + XCTAssertNil(store.registeredFingerprint) + XCTAssertEqual(controller.status, .registrationFailed) + XCTAssertNotNil(controller.lastError) + } + + func testReconcilePreservesOldFingerprintWhenUnregisterFails() { + let service = FakeCLIBrokerService(status: .enabled) + service.unregisterError = FakeCLIBrokerServiceError.refused + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "old", + enabledIntent: true + ) + let controller = makeController(service: service, store: store) + + XCTAssertFalse(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.unregisterCallCount, 1) + XCTAssertEqual(service.registerCallCount, 0) + XCTAssertEqual(store.registeredFingerprint, "old") + XCTAssertEqual(controller.status, .enabled) + XCTAssertNotNil(controller.lastError) + } + + func testReconcileRetriesRegistrationAfterReplacementFailure() { + let service = FakeCLIBrokerService(status: .enabled) + service.registerError = FakeCLIBrokerServiceError.refused + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "old", + enabledIntent: true + ) + var controller = makeController(service: service, store: store) + + XCTAssertFalse(controller.reconcileRegisteredService()) + XCTAssertEqual(service.status, .notRegistered) + XCTAssertEqual(store.enabledIntent, true) + + service.registerError = nil + controller = makeController(service: service, store: store) + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.registerCallCount, 2) + XCTAssertEqual(store.registeredFingerprint, "current") + XCTAssertEqual(store.enabledIntent, true) + XCTAssertEqual(controller.status, .enabled) + } + + func testLegacyRegisteredServiceMigratesToEnabledIntent() { + let service = FakeCLIBrokerService(status: .enabled) + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "current", + enabledIntent: nil + ) + let controller = makeController(service: service, store: store) + + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertEqual(store.enabledIntent, true) + XCTAssertEqual(service.registerCallCount, 0) + XCTAssertEqual(service.unregisterCallCount, 0) + } + + func testLegacyUnregisteredServiceRemainsDisabledWithoutIntent() { + let service = FakeCLIBrokerService(status: .notRegistered) + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: nil, + enabledIntent: nil + ) + let controller = makeController(service: service, store: store) + + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertNil(store.enabledIntent) + XCTAssertEqual(service.registerCallCount, 0) + XCTAssertEqual(service.unregisterCallCount, 0) + XCTAssertEqual(controller.status, .notRegistered) + } + + func testReconcileRetriesExplicitDisableAfterUnregisterFailure() { + let service = FakeCLIBrokerService(status: .enabled) + service.unregisterError = FakeCLIBrokerServiceError.refused + let store = FakeCLIBrokerRegistrationStore( + registeredFingerprint: "current", + enabledIntent: true + ) + var controller = makeController(service: service, store: store) + + XCTAssertFalse(controller.unregister()) + XCTAssertEqual(store.enabledIntent, false) + + service.unregisterError = nil + controller = makeController(service: service, store: store) + XCTAssertTrue(controller.reconcileRegisteredService()) + + XCTAssertEqual(service.unregisterCallCount, 2) + XCTAssertEqual(store.registeredFingerprint, nil) + XCTAssertEqual(store.enabledIntent, false) + XCTAssertEqual(controller.status, .notRegistered) + } + + private func makeController( + service: FakeCLIBrokerService, + registeredFingerprint: String? = nil + ) -> CLIBrokerServiceController { + makeController( + service: service, + store: FakeCLIBrokerRegistrationStore( + registeredFingerprint: registeredFingerprint, + enabledIntent: service.status == .enabled + || service.status == .requiresApproval + ) + ) + } + + private func makeController( + service: FakeCLIBrokerService, + store: FakeCLIBrokerRegistrationStore + ) -> CLIBrokerServiceController { + CLIBrokerServiceController( + service: service, + registrationStore: store, + currentRegistrationFingerprint: { "current" } + ) + } +} + +@MainActor +private final class FakeCLIBrokerService: CLIBrokerServicing { + var status: SMAppService.Status + var registerError: Error? + var unregisterError: Error? + private(set) var registerCallCount = 0 + private(set) var unregisterCallCount = 0 + + init(status: SMAppService.Status) { + self.status = status + } + + func register() throws { + registerCallCount += 1 + if let registerError { throw registerError } + status = .enabled + } + + func unregister() throws { + unregisterCallCount += 1 + if let unregisterError { throw unregisterError } + status = .notRegistered + } +} + +@MainActor +private final class FakeCLIBrokerRegistrationStore: CLIBrokerRegistrationStoring { + var registeredFingerprint: String? + var enabledIntent: Bool? + + init(registeredFingerprint: String?, enabledIntent: Bool? = nil) { + self.registeredFingerprint = registeredFingerprint + self.enabledIntent = enabledIntent + } +} + +private enum FakeCLIBrokerServiceError: Error { + case refused +} diff --git a/Tests/Core/CLI/CLIExecutableTests.swift b/Tests/Core/CLI/CLIExecutableTests.swift new file mode 100644 index 00000000..1fa23e69 --- /dev/null +++ b/Tests/Core/CLI/CLIExecutableTests.swift @@ -0,0 +1,629 @@ +import Darwin +import XCTest +@testable import MacTools + +final class CLIExecutableTests: XCTestCase { + func testHelpAndVersionDoNotRequireBroker() throws { + let help = try runCLI(["help"]) + XCTAssertEqual(help.status, 0) + XCTAssertTrue(help.output.contains("actions run ")) + XCTAssertTrue(help.output.contains("plugins doctor")) + + let version = try runCLI(["version", "--json"]) + XCTAssertEqual(version.status, 0) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(version.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["schemaVersion"] as? Int, 1) + let data = try XCTUnwrap(object["data"] as? [String: Any]) + XCTAssertNotEqual(data["cliVersion"] as? String, "unknown") + XCTAssertNotEqual(data["cliBuild"] as? String, "unknown") + } + + func testVersionIgnoresUnrelatedContainingAppBundle() throws { + let original = try runCLI(["version", "--json"]) + let originalObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(original.output.utf8)) as? [String: Any] + ) + let originalData = try XCTUnwrap(originalObject["data"] as? [String: Any]) + + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let appURL = root.appendingPathComponent("Unrelated.app") + let executableURL = appURL.appendingPathComponent("Tools/MacToolsCLI") + let infoURL = appURL.appendingPathComponent("Contents/Info.plist") + try FileManager.default.createDirectory( + at: executableURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: infoURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.copyItem(at: cliExecutableURL, to: executableURL) + let foreignInfo: [String: Any] = [ + "CFBundleIdentifier": "example.Unrelated", + "CFBundleExecutable": "Unrelated", + "CFBundleShortVersionString": "99.0", + "CFBundleVersion": "999", + "CFBundlePackageType": "APPL", + ] + try PropertyListSerialization.data( + fromPropertyList: foreignInfo, + format: .xml, + options: 0 + ).write(to: infoURL) + defer { try? FileManager.default.removeItem(at: root) } + + let nested = try runCLI(["version", "--json"], executableURL: executableURL) + XCTAssertEqual(nested.status, 0) + let nestedObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(nested.output.utf8)) as? [String: Any] + ) + let nestedData = try XCTUnwrap(nestedObject["data"] as? [String: Any]) + XCTAssertEqual(nestedData["cliVersion"] as? String, originalData["cliVersion"] as? String) + XCTAssertEqual(nestedData["cliBuild"] as? String, originalData["cliBuild"] as? String) + XCTAssertNotEqual(nestedData["cliVersion"] as? String, "99.0") + } + + func testInvalidCommandUsesStableExitCode() throws { + let result = try runCLI(["actions", "run", "invalid-key"]) + XCTAssertEqual(result.status, CLIExitCode.invalidInput.rawValue) + XCTAssertTrue(result.error.contains("Invalid command")) + } + + func testInvalidCommandPreservesJSONEnvelope() throws { + let result = try runCLI(["actions", "run", "invalid-key", "--json"]) + XCTAssertEqual(result.status, CLIExitCode.invalidInput.rawValue) + XCTAssertTrue(result.error.isEmpty) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["schemaVersion"] as? Int, 1) + XCTAssertEqual(object["command"] as? String, "actions.run") + XCTAssertEqual(object["outcome"] as? String, "invalidInput") + } + + func testInvalidPeerResponsesUseProtocolExitCodeForTextAndJSON() throws { + for fixture in [ + "empty", "malformed", "mismatched", "malformedPayload", + "missingPayload", "schemaInvalidPayload", + ] { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: fixture, + ] + let text = try runCLI(["actions", "list"], environment: environment) + XCTAssertEqual( + text.status, + CLIExitCode.protocolIncompatible.rawValue, + fixture + ) + XCTAssertTrue(text.output.isEmpty, fixture) + XCTAssertTrue(text.error.contains("invalid response"), fixture) + + let json = try runCLI( + ["actions", "list", "--json"], + environment: environment + ) + XCTAssertEqual( + json.status, + CLIExitCode.protocolIncompatible.rawValue, + fixture + ) + XCTAssertTrue(json.error.isEmpty, fixture) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "protocolIncompatible", fixture) + XCTAssertEqual( + (object["rejection"] as? [String: Any])?["category"] as? String, + "invalidPeerResponse", + fixture + ) + } + } + + func testResponseSemanticViolationsUseProtocolExitCodeForTextAndJSON() throws { + for fixture in ["invalidOutcome", "missingFinishedAt", "unexpectedActionReference"] { + try assertInvalidPeerResponse(["doctor"], fixture: fixture) + } + } + + func testActionTimeoutRemainsAValidExecutionOutcomeForTextAndJSON() throws { + for arguments in [ + ["actions", "run", "fixture/action"], + ["workflows", "run", "fixture"], + ] { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: "validActionTimeout", + ] + let command = arguments.joined(separator: " ") + let text = try runCLI(arguments, environment: environment) + XCTAssertEqual(text.status, CLIExitCode.timeout.rawValue, command) + XCTAssertTrue(text.output.isEmpty, command) + XCTAssertTrue(text.error.contains("timed out"), command) + + let json = try runCLI(arguments + ["--json"], environment: environment) + XCTAssertEqual(json.status, CLIExitCode.timeout.rawValue, command) + XCTAssertTrue(json.error.isEmpty, command) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "timedOut", command) + } + } + + func testOversizedDiscoveryPagesUseProtocolExitCodeForTextAndJSON() throws { + for arguments in [ + ["actions", "list"], + ["workflows", "list"], + ["plugins", "list"], + ] { + try assertInvalidPeerResponse(arguments, fixture: "oversizedPage") + } + } + + func testInvalidActionParameterDefinitionsUseProtocolExitCodeForTextAndJSON() throws { + for fixture in [ + "invalidParameterID", "invalidParameterKind", + "invalidParameterPrivacy", "invalidParameterPortability", + ] { + try assertInvalidPeerResponse(["actions", "list"], fixture: fixture) + } + try assertInvalidPeerResponse( + ["actions", "describe", "fixture/action"], + fixture: "duplicateParameterDefinitions" + ) + } + + func testRunCommandsRejectForbiddenPayloadForTextAndJSON() throws { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: "malformedPayload", + ] + for arguments in [ + ["actions", "run", "fixture/action"], + ["workflows", "run", "fixture"], + ] { + let command = arguments.joined(separator: " ") + let text = try runCLI(arguments, environment: environment) + XCTAssertEqual(text.status, CLIExitCode.protocolIncompatible.rawValue, command) + XCTAssertTrue(text.output.isEmpty, command) + XCTAssertTrue(text.error.contains("invalid response"), command) + + let json = try runCLI(arguments + ["--json"], environment: environment) + XCTAssertEqual(json.status, CLIExitCode.protocolIncompatible.rawValue, command) + XCTAssertTrue(json.error.isEmpty, command) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "protocolIncompatible", command) + XCTAssertEqual( + (object["rejection"] as? [String: Any])?["category"] as? String, + "invalidPeerResponse", + command + ) + } + } + + func testDiscoveryCommandsRejectNestedSchemaMutationsForTextAndJSON() throws { + for fixture in ["nestedUnknownPayload", "nestedDuplicatePayload"] { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: fixture, + ] + for arguments in [ + ["actions", "list"], + ["workflows", "list"], + ["plugins", "list"], + ] { + let command = arguments.joined(separator: " ") + let text = try runCLI(arguments, environment: environment) + XCTAssertEqual(text.status, CLIExitCode.protocolIncompatible.rawValue, command) + XCTAssertTrue(text.output.isEmpty, command) + XCTAssertTrue(text.error.contains("invalid response"), command) + + let json = try runCLI(arguments + ["--json"], environment: environment) + XCTAssertEqual(json.status, CLIExitCode.protocolIncompatible.rawValue, command) + XCTAssertTrue(json.error.isEmpty, command) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) + as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "protocolIncompatible", command) + XCTAssertEqual( + (object["rejection"] as? [String: Any])?["category"] as? String, + "invalidPeerResponse", + command + ) + } + } + } + + func testValidPayloadContractsRemainAcceptedForTextAndJSON() throws { + let discoveryEnvironment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: "validDiscoveryPayload", + ] + for arguments in [ + ["actions", "list"], + ["workflows", "list"], + ["plugins", "list"], + ] { + let command = arguments.joined(separator: " ") + let text = try runCLI(arguments, environment: discoveryEnvironment) + XCTAssertEqual(text.status, CLIExitCode.success.rawValue, command) + XCTAssertFalse(text.output.isEmpty, command) + XCTAssertTrue(text.error.isEmpty, command) + + let json = try runCLI(arguments + ["--json"], environment: discoveryEnvironment) + XCTAssertEqual(json.status, CLIExitCode.success.rawValue, command) + XCTAssertTrue(json.error.isEmpty, command) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "completed", command) + } + + for arguments in [ + ["actions", "run", "fixture/action"], + ["workflows", "run", "fixture"], + ] { + let command = arguments.joined(separator: " ") + for (fixture, expectedOutcome, expectedExit) in [ + ("missingPayload", "completed", CLIExitCode.success), + ("validStartedPayload", "started", CLIExitCode.success), + ("validFailure", "failed", CLIExitCode.actionFailure), + ] { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: fixture, + ] + let text = try runCLI(arguments, environment: environment) + XCTAssertEqual(text.status, expectedExit.rawValue, "\(command): \(fixture)") + + let json = try runCLI(arguments + ["--json"], environment: environment) + XCTAssertEqual(json.status, expectedExit.rawValue, "\(command): \(fixture)") + XCTAssertTrue(json.error.isEmpty, "\(command): \(fixture)") + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) + as? [String: Any] + ) + XCTAssertEqual( + object["outcome"] as? String, + expectedOutcome, + "\(command): \(fixture)" + ) + } + } + } + + func testUncertainPeerTimeoutUsesTransportExitCodeForTextAndJSON() throws { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: "timeout", + ] + let text = try runCLI(["actions", "list"], environment: environment) + XCTAssertEqual(text.status, CLIExitCode.transportFailure.rawValue) + XCTAssertTrue(text.output.isEmpty) + XCTAssertTrue(text.error.contains("delivery state is unknown")) + + let json = try runCLI( + ["actions", "list", "--json"], + environment: environment + ) + XCTAssertEqual(json.status, CLIExitCode.transportFailure.rawValue) + XCTAssertTrue(json.error.isEmpty) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "hostUnavailable") + XCTAssertEqual( + (object["rejection"] as? [String: Any])?["category"] as? String, + "hostTransportFailure" + ) + + let doctorText = try runCLI(["doctor"], environment: environment) + XCTAssertEqual(doctorText.status, CLIExitCode.transportFailure.rawValue) + XCTAssertTrue(doctorText.output.isEmpty) + XCTAssertTrue(doctorText.error.contains("delivery state is unknown")) + + let doctorJSON = try runCLI(["doctor", "--json"], environment: environment) + XCTAssertEqual(doctorJSON.status, CLIExitCode.transportFailure.rawValue) + XCTAssertTrue(doctorJSON.error.isEmpty) + let doctorObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(doctorJSON.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(doctorObject["outcome"] as? String, "hostUnavailable") + XCTAssertEqual( + (doctorObject["rejection"] as? [String: Any])?["category"] as? String, + "hostTransportFailure" + ) + } + + func testRequestSendStatePersistsCancellationAcrossAdmissionRace() { + let cancelledBeforeSending = CLIRequestSendState() + cancelledBeforeSending.cancel() + XCTAssertFalse(cancelledBeforeSending.beginSending()) + XCTAssertFalse(cancelledBeforeSending.takeCancellationToForward()) + + let cancelledAfterSending = CLIRequestSendState() + XCTAssertTrue(cancelledAfterSending.beginSending()) + cancelledAfterSending.cancel() + XCTAssertTrue(cancelledAfterSending.takeCancellationToForward()) + XCTAssertFalse(cancelledAfterSending.takeCancellationToForward()) + } + + func testCommandTaskStatePersistsSignalBeforeAndAfterTaskInstallation() async { + let cancelledBeforeInstall = CLICommandTaskState() + cancelledBeforeInstall.cancel() + let firstTask = Task { + try Task.checkCancellation() + return 0 + } + cancelledBeforeInstall.install(firstTask) + await XCTAssertThrowsCancellation(firstTask) + + let cancelledAfterInstall = CLICommandTaskState() + let secondTask = Task { + try await Task.sleep(for: .seconds(30)) + return 0 + } + cancelledAfterInstall.install(secondTask) + cancelledAfterInstall.cancel() + await XCTAssertThrowsCancellation(secondTask) + } + + func testSignalStateHandlesOnceAndStopsAfterFinish() { + let state = CLISignalState() + XCTAssertTrue(state.beginHandlingSignal()) + XCTAssertFalse(state.beginHandlingSignal()) + XCTAssertTrue(state.beginFinishing()) + XCTAssertFalse(state.beginHandlingSignal()) + XCTAssertFalse(state.beginFinishing()) + } + + func testSignalCoordinatorRestoresEveryPreviousDispositionExactlyOnce() { + let recorder = CLISignalInstallRecorder() + let coordinator = CLISignalCoordinator( + signalNumbers: [SIGINT, SIGTERM], + installSignalHandler: { signalNumber, _ in + recorder.record(signalNumber) + return SIG_DFL + }, + onSignal: {} + ) + + XCTAssertEqual(recorder.signalNumbers, [SIGINT, SIGTERM]) + coordinator.finish() + coordinator.finish() + XCTAssertEqual(recorder.signalNumbers, [SIGINT, SIGTERM, SIGINT, SIGTERM]) + } + + func testSIGINTAndSIGTERMCancelRunningColdStartWithOneJSONResult() throws { + for signalNumber in [SIGINT, SIGTERM] { + let result = try runInterruptedCLI(signalNumber: signalNumber) + XCTAssertEqual(result.status, CLIExitCode.cancellation.rawValue) + XCTAssertTrue(result.error.isEmpty) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(result.output.utf8)) + as? [String: Any] + ) + XCTAssertEqual(object["schemaVersion"] as? Int, 1) + XCTAssertEqual(object["command"] as? String, "doctor") + XCTAssertEqual(object["outcome"] as? String, "cancelled") + XCTAssertEqual( + (object["rejection"] as? [String: Any])?["category"] as? String, + "cancelled" + ) + + let subsequent = try runCLI(["help"]) + XCTAssertEqual(subsequent.status, CLIExitCode.success.rawValue) + XCTAssertTrue(subsequent.output.contains("Usage: mactools")) + } + } + + func testHostCancellationRelayClosesRegistrationRaceAndBoundsLateState() { + var state = CLIHostCancellationRelayState(maximumTrackedRequestCount: 2) + let early = UUID() + + XCTAssertEqual( + state.cancellationDisposition(requestID: early, hasActiveTask: false), + .recordedBeforeRegistration + ) + XCTAssertFalse(state.shouldBeginHandling(early)) + state.markCompleted(early) + XCTAssertEqual( + state.cancellationDisposition(requestID: early, hasActiveTask: false), + .alreadyCompleted + ) + + let active = UUID() + XCTAssertTrue(state.shouldBeginHandling(active)) + XCTAssertEqual( + state.cancellationDisposition(requestID: active, hasActiveTask: true), + .cancelActive + ) + state.markCompleted(active) + + let newest = UUID() + XCTAssertTrue(state.shouldBeginHandling(newest)) + state.markCompleted(newest) + XCTAssertFalse(state.completedRequestIDs.contains(early)) + XCTAssertTrue(state.completedRequestIDs.contains(active)) + XCTAssertTrue(state.completedRequestIDs.contains(newest)) + } + + private func runCLI( + _ arguments: [String], + executableURL: URL? = nil, + environment: [String: String] = [:] + ) throws -> (status: Int32, output: String, error: String) { + let process = Process() + process.executableURL = executableURL ?? cliExecutableURL + process.arguments = arguments + if !environment.isEmpty { + process.environment = ProcessInfo.processInfo.environment.merging( + environment, + uniquingKeysWith: { _, override in override } + ) + } + let output = Pipe() + let error = Pipe() + process.standardOutput = output + process.standardError = error + try process.run() + process.waitUntilExit() + return ( + process.terminationStatus, + String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self), + String(decoding: error.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + ) + } + + private func assertInvalidPeerResponse( + _ arguments: [String], + fixture: String + ) throws { + let environment = [ + CLIServiceConfiguration.testPeerResponseEnvironmentKey: fixture, + ] + let context = "\(arguments.joined(separator: " ")): \(fixture)" + let text = try runCLI(arguments, environment: environment) + XCTAssertEqual(text.status, CLIExitCode.protocolIncompatible.rawValue, context) + XCTAssertTrue(text.output.isEmpty, context) + XCTAssertTrue(text.error.contains("invalid response"), context) + + let json = try runCLI(arguments + ["--json"], environment: environment) + XCTAssertEqual(json.status, CLIExitCode.protocolIncompatible.rawValue, context) + XCTAssertTrue(json.error.isEmpty, context) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(json.output.utf8)) as? [String: Any] + ) + XCTAssertEqual(object["outcome"] as? String, "protocolIncompatible", context) + XCTAssertEqual( + (object["rejection"] as? [String: Any])?["category"] as? String, + "invalidPeerResponse", + context + ) + } + + private func runInterruptedCLI( + signalNumber: Int32 + ) throws -> (status: Int32, output: String, error: String) { + let process = Process() + process.executableURL = cliExecutableURL + process.arguments = ["doctor", "--json"] + var environment = [ + "HOME": NSHomeDirectory(), + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "TMPDIR": NSTemporaryDirectory(), + ] + environment[CLIServiceConfiguration.testServiceNameEnvironmentKey] = + "app.ggbond.MacTools.tests.\(UUID().uuidString).cli-broker" + environment[CLIServiceConfiguration.testDisableHostLaunchEnvironmentKey] = "1" + environment[CLIServiceConfiguration.testSignalReadyEnvironmentKey] = "1" + process.environment = environment + let output = Pipe() + let error = Pipe() + let ready = expectation(description: "CLI signal handlers installed") + let readyData = CLIDataRecorder() + error.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if readyData.appendAndContainsReadyMarker(data) { + ready.fulfill() + } + } + process.standardOutput = output + process.standardError = error + try process.run() + + let waitResult = XCTWaiter.wait(for: [ready], timeout: 3) + error.fileHandleForReading.readabilityHandler = nil + guard waitResult == .completed, + process.isRunning, + readyData.value == Data("MACTOOLS_CLI_SIGNAL_READY\n".utf8) else { + process.terminate() + process.waitUntilExit() + throw CLIExecutableTestError.signalHandlerDidNotBecomeReady + } + + XCTAssertEqual(kill(process.processIdentifier, signalNumber), 0) + let exitDeadline = Date().addingTimeInterval(3) + while process.isRunning, Date() < exitDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + if process.isRunning { + process.terminate() + process.waitUntilExit() + throw CLIExecutableTestError.interruptedProcessDidNotExit + } + return ( + process.terminationStatus, + String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self), + String(decoding: error.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + ) + } + + private var cliExecutableURL: URL { + Bundle.main.bundleURL + .deletingLastPathComponent() + .appendingPathComponent("MacToolsCLI") + } +} + +private enum CLIExecutableTestError: Error { + case signalHandlerDidNotBecomeReady + case interruptedProcessDidNotExit +} + +private final class CLIDataRecorder: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + private var didFindMarker = false + + var value: Data { + lock.lock() + defer { lock.unlock() } + return data + } + + func appendAndContainsReadyMarker(_ newData: Data) -> Bool { + lock.lock() + defer { lock.unlock() } + data.append(newData) + guard !didFindMarker, + data.range(of: Data("MACTOOLS_CLI_SIGNAL_READY\n".utf8)) != nil else { + return false + } + didFindMarker = true + return true + } +} + +private func XCTAssertThrowsCancellation( + _ task: Task, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await task.value + XCTFail("Expected task cancellation.", file: file, line: line) + } catch is CancellationError { + return + } catch { + XCTFail("Expected CancellationError, got \(error).", file: file, line: line) + } +} + +private final class CLISignalInstallRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [Int32] = [] + + var signalNumbers: [Int32] { + lock.lock() + defer { lock.unlock() } + return values + } + + func record(_ signalNumber: Int32) { + lock.lock() + values.append(signalNumber) + lock.unlock() + } +} diff --git a/Tests/Core/CLI/CLIHostApplicationLauncherTests.swift b/Tests/Core/CLI/CLIHostApplicationLauncherTests.swift new file mode 100644 index 00000000..9c7291e4 --- /dev/null +++ b/Tests/Core/CLI/CLIHostApplicationLauncherTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import MacTools + +final class CLIHostApplicationLauncherTests: XCTestCase { + private let applicationURL = URL(fileURLWithPath: "/Applications/MacTools.app") + + func testAwaitsSuccessfulLaunchCompletion() async throws { + let callback = expectation(description: "Launch callback completed") + let launcher = CLIHostApplicationLauncher { _, _, completion in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + completion(.success(())) + callback.fulfill() + } + } + + try await launcher.launch( + applicationURL: applicationURL, + deadline: CLIStartupDeadline(duration: .seconds(1)) + ) + + await fulfillment(of: [callback], timeout: 1) + } + + func testNonReturningLaunchCallbackIsBoundedByTimeout() async { + let launcher = CLIHostApplicationLauncher { _, _, _ in } + let startedAt = Date() + + do { + try await launcher.launch( + applicationURL: applicationURL, + deadline: CLIStartupDeadline(duration: .milliseconds(50)) + ) + XCTFail("Expected launch timeout") + } catch { + XCTAssertEqual(error as? CLIHostApplicationLaunchError, .timedOut) + } + + XCTAssertLessThan(Date().timeIntervalSince(startedAt), 0.5) + } + + func testLaunchFailureIsPropagated() async { + let launcher = CLIHostApplicationLauncher { _, _, completion in + completion(.failure(CLIHostApplicationLaunchError.failed("denied"))) + } + + do { + try await launcher.launch( + applicationURL: applicationURL, + deadline: CLIStartupDeadline(duration: .seconds(1)) + ) + XCTFail("Expected launch failure") + } catch { + XCTAssertEqual( + error as? CLIHostApplicationLaunchError, + .failed("denied") + ) + } + } + + func testCancellationReturnsWithoutAwaitingLaunchCallback() async { + let launcher = CLIHostApplicationLauncher { _, _, _ in } + let task = Task { + try await launcher.launch( + applicationURL: applicationURL, + deadline: CLIStartupDeadline(duration: .seconds(10)) + ) + } + try? await Task.sleep(for: .milliseconds(20)) + let cancelledAt = Date() + task.cancel() + + do { + try await task.value + XCTFail("Expected cancellation") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.5) + } + + func testLaunchConfigurationPreventsRunningCopySubstitution() async throws { + var receivedURL: URL? + var activates = true + var allowsRunningApplicationSubstitution = true + let launcher = CLIHostApplicationLauncher { url, configuration, completion in + receivedURL = url + activates = configuration.activates + allowsRunningApplicationSubstitution = + configuration.allowsRunningApplicationSubstitution + completion(.success(())) + } + + try await launcher.launch( + applicationURL: applicationURL, + deadline: CLIStartupDeadline(duration: .seconds(1)) + ) + + XCTAssertEqual(receivedURL, applicationURL) + XCTAssertFalse(activates) + XCTAssertFalse(allowsRunningApplicationSubstitution) + } +} diff --git a/Tests/Core/CLI/CLIHostBridgeCallbackRelayTests.swift b/Tests/Core/CLI/CLIHostBridgeCallbackRelayTests.swift new file mode 100644 index 00000000..9d926e4f --- /dev/null +++ b/Tests/Core/CLI/CLIHostBridgeCallbackRelayTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import MacTools + +final class CLIHostBridgeCallbackRelayTests: XCTestCase { + func testReconnectCallbackCanEnterFromDetachedTaskAndRunsOnMainActor() async { + let reconnected = expectation(description: "Reconnect callback ran") + let relay = CLIHostBridgeCallbackRelay { + XCTAssertTrue(Thread.isMainThread) + reconnected.fulfill() + } + + await Task.detached { + relay.makeReconnectHandler()() + }.value + + await fulfillment(of: [reconnected], timeout: 1) + } + + func testErrorCallbackCanEnterFromDetachedTaskAndRunsOnMainActor() async { + let reconnected = expectation(description: "Reconnect callback ran") + let relay = CLIHostBridgeCallbackRelay { + XCTAssertTrue(Thread.isMainThread) + reconnected.fulfill() + } + + await Task.detached { + relay.makeReconnectErrorHandler()(CallbackError.expected) + }.value + + await fulfillment(of: [reconnected], timeout: 1) + } +} + +private enum CallbackError: Error { + case expected +} diff --git a/Tests/Core/CLI/CLIHostDiscoveryTests.swift b/Tests/Core/CLI/CLIHostDiscoveryTests.swift new file mode 100644 index 00000000..bd47712a --- /dev/null +++ b/Tests/Core/CLI/CLIHostDiscoveryTests.swift @@ -0,0 +1,188 @@ +import XCTest +@testable import MacTools + +final class CLIHostDiscoveryTests: XCTestCase { + private let hostIdentifier = "app.ggbond.MacTools" + + func testSlowCandidateProviderReturnsAtDeadlineWithoutAwaitingWorker() async { + let locator = CLIHostLocator( + candidateProvider: { _ in + Thread.sleep(forTimeInterval: 1) + return [] + }, + identityEvaluator: { _ in .accepted } + ) + + await assertTimesOutPromptly(CLIHostDiscovery(locator: locator)) + } + + func testSlowIdentityAssessmentReturnsAtDeadlineWithoutAwaitingWorker() async { + let candidate = CLIHostCandidate( + url: URL(fileURLWithPath: "/Applications/MacTools.app"), + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69" + ) + let locator = CLIHostLocator( + candidateProvider: { _ in [candidate] }, + identityEvaluator: { _ in + Thread.sleep(forTimeInterval: 1) + return .accepted + } + ) + + await assertTimesOutPromptly(CLIHostDiscovery(locator: locator)) + } + + func testCancellationReturnsWithoutAwaitingBlockingDiscovery() async { + let hostIdentifier = hostIdentifier + let discovery = CLIHostDiscovery { _, _, _ in + Thread.sleep(forTimeInterval: 1) + throw CLIHostLocationError.notFound(bundleIdentifier: hostIdentifier) + } + let task = Task { + try await discovery.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69", + deadline: CLIStartupDeadline(duration: .seconds(10)) + ) + } + try? await Task.sleep(for: .milliseconds(20)) + let cancelledAt = Date() + task.cancel() + + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.5) + } + + func testRecoveryPolicyLaunchesOnceThenWaitsForReplacement() { + XCTAssertEqual( + CLIHostRecoveryPolicy.decision( + brokerMatches: true, + hostMatches: true, + launchAllowed: true, + didLaunch: false + ), + .continueHandshake + ) + XCTAssertEqual( + CLIHostRecoveryPolicy.decision( + brokerMatches: false, + hostMatches: true, + launchAllowed: true, + didLaunch: false + ), + .launchExactHost + ) + XCTAssertEqual( + CLIHostRecoveryPolicy.decision( + brokerMatches: true, + hostMatches: false, + launchAllowed: true, + didLaunch: false + ), + .launchExactHost + ) + XCTAssertEqual( + CLIHostRecoveryPolicy.decision( + brokerMatches: false, + hostMatches: false, + launchAllowed: true, + didLaunch: true + ), + .waitForReplacement + ) + XCTAssertEqual( + CLIHostRecoveryPolicy.decision( + brokerMatches: true, + hostMatches: false, + launchAllowed: false, + didLaunch: false + ), + .rejectHostVersion + ) + XCTAssertEqual( + CLIHostRecoveryPolicy.decision( + brokerMatches: false, + hostMatches: true, + launchAllowed: false, + didLaunch: false + ), + .rejectBrokerVersion + ) + } + + func testStartupDeadlineUsesOnlyInjectedMonotonicTime() { + let time = CLIManualMonotonicTime() + var wallTime = Date(timeIntervalSince1970: 1_000) + let deadline = CLIStartupDeadline( + duration: .seconds(10), + now: { time.now }, + sleep: { _ in } + ) + + wallTime = wallTime.addingTimeInterval(-3_600) + XCTAssertEqual(deadline.remaining, .seconds(10)) + + time.advance(by: .seconds(4)) + wallTime = wallTime.addingTimeInterval(7_200) + XCTAssertEqual(deadline.remaining, .seconds(6)) + + time.advance(by: .seconds(6)) + XCTAssertTrue(deadline.isExpired) + XCTAssertEqual(wallTime, Date(timeIntervalSince1970: 4_600)) + } + + private func assertTimesOutPromptly( + _ discovery: CLIHostDiscovery, + file: StaticString = #filePath, + line: UInt = #line + ) async { + let startedAt = Date() + do { + _ = try await discovery.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69", + deadline: CLIStartupDeadline(duration: .milliseconds(50)) + ) + XCTFail("Expected discovery timeout", file: file, line: line) + } catch { + XCTAssertEqual( + error as? CLIHostDiscoveryError, + .timedOut, + file: file, + line: line + ) + } + XCTAssertLessThan( + Date().timeIntervalSince(startedAt), + 0.5, + file: file, + line: line + ) + } +} + +private final class CLIManualMonotonicTime: @unchecked Sendable { + private let lock = NSLock() + private var instant = ContinuousClock().now + + var now: ContinuousClock.Instant { + lock.lock() + defer { lock.unlock() } + return instant + } + + func advance(by duration: Duration) { + lock.lock() + instant = instant.advanced(by: duration) + lock.unlock() + } +} diff --git a/Tests/Core/CLI/CLIHostLocatorTests.swift b/Tests/Core/CLI/CLIHostLocatorTests.swift new file mode 100644 index 00000000..d83bf6e6 --- /dev/null +++ b/Tests/Core/CLI/CLIHostLocatorTests.swift @@ -0,0 +1,174 @@ +import XCTest +@testable import MacTools + +final class CLIHostLocatorTests: XCTestCase { + private let hostIdentifier = "app.ggbond.MacTools" + + func testSelectsVersionMatchedCandidateInsteadOfFirstRegisteredApplication() throws { + let old = candidate(path: "/Applications/A-MacTools.app", version: "1.1.6", build: "60") + let matching = candidate( + path: "/Users/test/Applications/Z-MacTools.app", + version: "1.2.0", + build: "69" + ) + let locator = locator(candidates: [old, matching]) { _ in .accepted } + + XCTAssertEqual( + try locator.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69" + ), + matching.url + ) + } + + func testSelectionIsDeterministicWhenMultipleCandidatesMatch() throws { + let first = candidate(path: "/Applications/A-MacTools.app") + let second = candidate(path: "/Applications/Z-MacTools.app") + let locator = locator(candidates: [second, first]) { _ in .accepted } + + XCTAssertEqual( + try locator.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69" + ), + first.url + ) + } + + func testReportsEveryDiscoveryFailureCategory() { + assertFailure( + locator(candidates: []) { _ in .accepted }, + category: "hostNotFound" + ) + assertFailure( + locator(candidates: [candidate(version: "1.1.6", build: "60")]) { _ in .accepted }, + category: "hostVersionIncompatible" + ) + assertFailure( + locator(candidates: [candidate()]) { _ in .wrongTeam }, + category: "hostTeamMismatch" + ) + assertFailure( + locator(candidates: [candidate()]) { _ in .wrongRole }, + category: "hostRoleMismatch" + ) + assertFailure( + locator(candidates: [candidate()]) { _ in .invalidSignature }, + category: "hostSignatureInvalid" + ) + } + + func testRejectsCandidateWhoseBundleIdentifierDoesNotMatch() { + let candidate = CLIHostCandidate( + url: URL(fileURLWithPath: "/Applications/Other.app"), + bundleIdentifier: "example.Other", + version: "1.2.0", + build: "69" + ) + let locator = locator(candidates: [candidate]) { _ in .accepted } + + assertFailure(locator, category: "hostRoleMismatch") + } + + func testExactReleaseIdentityFailureWinsOverOlderTrustedCandidate() { + let old = candidate( + path: "/Applications/A-Old-MacTools.app", + version: "1.1.6", + build: "60" + ) + let exactWrongTeam = candidate( + path: "/Applications/Z-Exact-MacTools.app", + version: "1.2.0", + build: "69" + ) + let locator = locator(candidates: [old, exactWrongTeam]) { + $0 == old.url ? .accepted : .wrongTeam + } + + XCTAssertThrowsError( + try locator.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69" + ) + ) { error in + XCTAssertEqual( + error as? CLIHostLocationError, + .teamMismatch(candidate: exactWrongTeam.url) + ) + } + } + + func testVersionMismatchMessageInterpolatesExpectedAndInstalledReleases() { + let old = candidate(version: "1.1.6", build: "60") + let locator = locator(candidates: [old]) { _ in .accepted } + + XCTAssertThrowsError( + try locator.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69" + ) + ) { error in + guard let locationError = error as? CLIHostLocationError else { + return XCTFail("Expected CLIHostLocationError") + } + XCTAssertEqual(locationError.category, "hostVersionIncompatible") + XCTAssertEqual( + locationError.message, + "No installed MacTools app matches CLI version 1.2.0 (69). Found: 1.1.6 (60)." + ) + XCTAssertEqual(locationError.candidateURL, old.url) + } + } + + private func assertFailure( + _ locator: CLIHostLocator, + category: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError( + try locator.locate( + bundleIdentifier: hostIdentifier, + version: "1.2.0", + build: "69" + ), + file: file, + line: line + ) { error in + XCTAssertEqual( + (error as? CLIHostLocationError)?.category, + category, + file: file, + line: line + ) + } + } + + private func locator( + candidates: [CLIHostCandidate], + identityEvaluator: @escaping @Sendable (URL) -> CLIHostIdentityAssessment + ) -> CLIHostLocator { + CLIHostLocator( + candidateProvider: { _ in candidates }, + identityEvaluator: identityEvaluator + ) + } + + private func candidate( + path: String = "/Applications/MacTools.app", + version: String = "1.2.0", + build: String = "69" + ) -> CLIHostCandidate { + CLIHostCandidate( + url: URL(fileURLWithPath: path), + bundleIdentifier: hostIdentifier, + version: version, + build: build + ) + } +} diff --git a/Tests/Core/CLI/CLIHostRequestRouterTests.swift b/Tests/Core/CLI/CLIHostRequestRouterTests.swift new file mode 100644 index 00000000..d1a531ca --- /dev/null +++ b/Tests/Core/CLI/CLIHostRequestRouterTests.swift @@ -0,0 +1,203 @@ +import MacToolsPluginKit +import XCTest +@testable import MacTools + +@MainActor +final class CLIHostRequestRouterTests: XCTestCase { + func testParameterizedPresetsProduceOneDefinitionLevelRecordWithAggregateAvailability() async throws { + let plugin = CLIParameterizedActionTestPlugin() + let host = makePluginHostForTests(plugins: [plugin]) + let router = CLIHostRequestRouter(pluginHost: host, serviceStatus: { "enabled" }) + + let listResponse = await router.handle(try request( + operation: .actionsList, + payload: CLIActionListRequest(runnableOnly: false, continuationToken: nil) + )) + let page = try decode(CLIPage.self, from: listResponse) + let matching = page.records.filter { $0.reference.key.id == plugin.cliKey.id } + + let record = try XCTUnwrap(matching.first) + XCTAssertEqual(matching.count, 1) + XCTAssertEqual(record.title, plugin.definition.title) + XCTAssertEqual(record.subtitle, "Test presets") + XCTAssertEqual(record.parameters.map(\.id), ["enabled"]) + XCTAssertTrue(record.availability.isAvailable) + XCTAssertTrue(record.cliEligibility.isAvailable) + + let runnableResponse = await router.handle(try request( + operation: .actionsList, + payload: CLIActionListRequest(runnableOnly: true, continuationToken: nil) + )) + let runnable = try decode(CLIPage.self, from: runnableResponse) + XCTAssertEqual( + runnable.records.filter { $0.reference.key.id == plugin.cliKey.id }.count, + 1 + ) + + let doctorResponse = await router.handle(try request(operation: .doctor)) + let doctor = try decode(CLIDoctorRecord.self, from: doctorResponse) + XCTAssertEqual(doctor.actionCount, page.records.count) + } + + func testParameterizedRunUsesSubmittedReferenceInsteadOfRepresentativePreset() async throws { + let plugin = CLIParameterizedActionTestPlugin() + let host = makePluginHostForTests(plugins: [plugin]) + let router = CLIHostRequestRouter(pluginHost: host, serviceStatus: { "enabled" }) + + let response = await router.handle(try request( + operation: .actionsRun, + payload: CLIActionRunRequest( + key: plugin.cliKey, + parameters: ["enabled": .boolean(false)], + inputSource: .arguments, + noWait: false + ) + )) + + XCTAssertEqual(response.outcome, .completed) + XCTAssertEqual(plugin.invocations.count, 1) + XCTAssertEqual(plugin.invocations[0].reference.parameters["enabled"], .boolean(false)) + } + + func testActionExecutionReceivesBrokerIssuedInvocationContext() async throws { + let plugin = CLIParameterizedActionTestPlugin() + let host = makePluginHostForTests(plugins: [plugin]) + let router = CLIHostRequestRouter(pluginHost: host, serviceStatus: { "enabled" }) + let context = CLIInvocationContext(chainID: UUID(), depth: 0) + + let response = await router.handle(try request( + operation: .actionsRun, + payload: CLIActionRunRequest( + key: plugin.cliKey, + parameters: ["enabled": .boolean(false)], + inputSource: .arguments, + noWait: false + ), + invocationContext: context + )) + + XCTAssertEqual(response.outcome, .completed) + XCTAssertEqual( + plugin.invocationContexts, + [PluginCLIInvocationContext(chainID: context.chainID, depth: context.depth)] + ) + } + + private func request( + operation: CLIOperation, + payload: Payload, + invocationContext: CLIInvocationContext? = nil + ) throws -> CLIRequestEnvelope { + CLIRequestEnvelope( + protocolVersion: CLIProtocolVersion.current, + requestID: UUID(), + operation: operation, + sentAt: .now, + invocationContext: invocationContext, + payload: try CLIProtocolCodec.encodeRequest(payload) + ) + } + + private func request(operation: CLIOperation) throws -> CLIRequestEnvelope { + CLIRequestEnvelope( + protocolVersion: CLIProtocolVersion.current, + requestID: UUID(), + operation: operation, + sentAt: .now, + payload: nil + ) + } + + private func decode( + _ type: Record.Type, + from response: CLIResponseEnvelope + ) throws -> Record { + XCTAssertEqual(response.outcome, .completed) + return try CLIProtocolCodec.decodeResponse( + type, + from: try XCTUnwrap(response.payload) + ) + } +} + +@MainActor +private final class CLIParameterizedActionTestPlugin: + MacToolsPlugin, + PluginActionProviding, + PluginActionExposureProviding +{ + let metadata = PluginMetadata( + id: "cli-parameterized", + title: "CLI Parameterized", + iconName: "switch.2", + iconTint: .blue, + order: 1, + defaultDescription: "CLI parameterized action tests" + ) + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + private(set) var invocations: [ActionInvocation] = [] + private(set) var invocationContexts: [PluginCLIInvocationContext?] = [] + + let definition = ActionDefinition( + key: ActionKey(providerID: "cli-parameterized", actionID: "set-enabled"), + title: "Set Test Mode", + description: "Sets a test mode deterministically.", + systemImage: "switch.2", + parameters: [ + ActionParameterDefinition(id: "enabled", title: "Enabled", kind: .boolean), + ], + externalInvocationPolicy: .allowed, + capabilities: [.background] + ) + + var cliKey: CLIActionKey { + CLIActionKey( + providerID: definition.key.providerID, + actionID: definition.key.actionID + ) + } + + var actionDefinitions: [ActionDefinition] { [definition] } + var actionCatalogEntries: [ActionCatalogEntry] { + [ + ActionCatalogEntry( + reference: reference(enabled: true), + title: "Enable Test Mode", + subtitle: "Test presets" + ), + ActionCatalogEntry( + reference: reference(enabled: false), + title: "Disable Test Mode", + subtitle: "Test presets" + ), + ] + } + + func actionAvailability(for reference: ActionReference) -> ActionAvailability { + reference.parameters["enabled"] == .boolean(false) + ? .available + : .unavailable("The enable preset is unavailable in this fixture.") + } + + func exposurePolicy( + for reference: ActionReference, + on surface: ActionExposureSurface + ) -> ActionExposurePolicy { + reference.parameters["enabled"] == .boolean(false) ? .automatic : .excluded + } + + func beginAction(_ invocation: ActionInvocation) throws -> ActionExecutionHandle { + invocations.append(invocation) + invocationContexts.append(PluginActionExecutionContext.cliInvocation) + return ActionExecutionHandle { .succeeded() } + } + + private func reference(enabled: Bool) -> ActionReference { + ActionReference( + key: definition.key, + parameters: try! ActionParameterSet(["enabled": .boolean(enabled)]) + ) + } +} diff --git a/Tests/Core/CLI/CLIParameterInputTests.swift b/Tests/Core/CLI/CLIParameterInputTests.swift new file mode 100644 index 00000000..a1335ffa --- /dev/null +++ b/Tests/Core/CLI/CLIParameterInputTests.swift @@ -0,0 +1,182 @@ +import XCTest +@testable import MacTools + +final class CLIParameterInputTests: XCTestCase { + func testJSONRejectsDuplicateParameterNames() throws { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + XCTAssertTrue(FileManager.default.createFile( + atPath: file.path, + contents: Data(#"{"name":"first","name":"second"}"#.utf8), + attributes: [.posixPermissions: 0o600] + )) + defer { try? FileManager.default.removeItem(at: file) } + XCTAssertThrowsError(try CLIParameterInput().json( + path: file.path, + definitions: [parameter("name", kind: "string")] + )) { error in + XCTAssertEqual(error as? CLIParameterInputError, .invalidJSON) + } + } + + func testConvertsPublicArgumentTypesAndRejectsSensitiveValues() throws { + let definitions = [ + CLIActionParameter(id: "name", title: "Name", kind: "string", isRequired: true, privacy: "public", portability: "portable"), + CLIActionParameter(id: "count", title: "Count", kind: "integer", isRequired: true, privacy: "public", portability: "portable"), + CLIActionParameter(id: "ratio", title: "Ratio", kind: "double", isRequired: true, privacy: "public", portability: "portable"), + CLIActionParameter(id: "enabled", title: "Enabled", kind: "boolean", isRequired: true, privacy: "public", portability: "portable"), + CLIActionParameter(id: "token", title: "Token", kind: "string", isRequired: false, privacy: "sensitive", portability: "localOnly"), + ] + XCTAssertEqual(try CLIParameterInput().arguments([ + "name": "demo", "count": "4", "ratio": "1.5", "enabled": "yes", + ], definitions: definitions), [ + "name": .string("demo"), "count": .integer(4), "ratio": .double(1.5), "enabled": .boolean(true), + ]) + XCTAssertThrowsError(try CLIParameterInput().arguments( + ["token": "visible"], definitions: definitions + )) { error in + XCTAssertEqual(error as? CLIParameterInputError, .sensitiveArgument("token")) + } + } + + func testDuplicateDefinitionsAreRejectedWithoutTrapping() throws { + let definitions = [ + parameter("value", kind: "string"), + parameter("value", kind: "string"), + ] + XCTAssertThrowsError(try CLIParameterInput().arguments( + ["value": "demo"], definitions: definitions + )) { error in + XCTAssertEqual(error as? CLIParameterInputError, .invalidValue("value")) + } + XCTAssertThrowsError(try parseJSONObject( + #"{"value":"demo"}"#, + definitions: definitions + )) { error in + XCTAssertEqual(error as? CLIParameterInputError, .invalidValue("value")) + } + } + + func testProtectedJSONRequiresOwnedUserOnlyRegularFile() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let secure = root.appendingPathComponent("secure.json") + XCTAssertTrue(FileManager.default.createFile( + atPath: secure.path, + contents: Data(#"{"token":"secret","count":2}"#.utf8), + attributes: [.posixPermissions: 0o600] + )) + let parsed = try CLIParameterInput().json( + path: secure.path, + definitions: [ + parameter("token", kind: "string", privacy: "sensitive"), + parameter("count", kind: "integer"), + ] + ) + XCTAssertEqual(parsed.source, .protectedFile) + XCTAssertEqual(parsed.values["token"], .string("secret")) + XCTAssertEqual(parsed.values["count"], .integer(2)) + + let link = root.appendingPathComponent("link.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: secure) + XCTAssertThrowsError(try CLIParameterInput().json(path: link.path, definitions: [])) + + let open = root.appendingPathComponent("open.json") + XCTAssertTrue(FileManager.default.createFile( + atPath: open.path, + contents: Data("{}".utf8), + attributes: [.posixPermissions: 0o644] + )) + XCTAssertThrowsError(try CLIParameterInput().json(path: open.path, definitions: [])) + } + + func testJSONIntegerConversionPreservesInt64BoundsAndRejectsAdjacentValues() throws { + XCTAssertEqual( + try parseJSON(#"{"value":9223372036854775807}"#, kind: "integer"), + .integer(Int64.max) + ) + XCTAssertEqual( + try parseJSON(#"{"value":-9223372036854775808}"#, kind: "integer"), + .integer(Int64.min) + ) + XCTAssertThrowsError(try parseJSON(#"{"value":9223372036854775808}"#, kind: "integer")) { error in + XCTAssertEqual(error as? CLIParameterInputError, .invalidValue("value")) + } + XCTAssertThrowsError(try parseJSON(#"{"value":-9223372036854775809}"#, kind: "integer")) { error in + XCTAssertEqual(error as? CLIParameterInputError, .invalidValue("value")) + } + } + + func testJSONPreservesBooleanIntegerAndFloatingScalarTypes() throws { + let values = try parseJSONObject( + #"{"zero":0,"one":1,"trueValue":true,"falseValue":false,"oneDouble":1.0,"twoDouble":2.0,"exponent":1e3}"#, + definitions: [ + parameter("zero", kind: "integer"), + parameter("one", kind: "integer"), + parameter("trueValue", kind: "boolean"), + parameter("falseValue", kind: "boolean"), + parameter("oneDouble", kind: "double"), + parameter("twoDouble", kind: "double"), + parameter("exponent", kind: "double"), + ] + ) + + XCTAssertEqual(values["zero"], .integer(0)) + XCTAssertEqual(values["one"], .integer(1)) + XCTAssertEqual(values["trueValue"], .boolean(true)) + XCTAssertEqual(values["falseValue"], .boolean(false)) + XCTAssertEqual(values["oneDouble"], .double(1)) + XCTAssertEqual(values["twoDouble"], .double(2)) + XCTAssertEqual(values["exponent"], .double(1_000)) + } + + func testJSONRejectsCrossTypeNumericAndBooleanValues() { + XCTAssertThrowsError(try parseJSON(#"{"value":1}"#, kind: "boolean")) + XCTAssertThrowsError(try parseJSON(#"{"value":true}"#, kind: "integer")) + XCTAssertThrowsError(try parseJSON(#"{"value":1.5}"#, kind: "integer")) + } + + private func parseJSON( + _ json: String, + kind: String + ) throws -> CLIParameterValue? { + try parseJSONObject( + json, + definitions: [parameter("value", kind: kind)] + )["value"] + } + + private func parseJSONObject( + _ json: String, + definitions: [CLIActionParameter] + ) throws -> [String: CLIParameterValue] { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + XCTAssertTrue(FileManager.default.createFile( + atPath: file.path, + contents: Data(json.utf8), + attributes: [.posixPermissions: 0o600] + )) + defer { try? FileManager.default.removeItem(at: file) } + return try CLIParameterInput().json( + path: file.path, + definitions: definitions + ).values + } + + private func parameter( + _ id: String, + kind: String, + privacy: String = "public" + ) -> CLIActionParameter { + CLIActionParameter( + id: id, + title: id, + kind: kind, + isRequired: true, + privacy: privacy, + portability: "portable" + ) + } +} diff --git a/Tests/Core/CLI/CLIPeerIdentityValidatorTests.swift b/Tests/Core/CLI/CLIPeerIdentityValidatorTests.swift new file mode 100644 index 00000000..f9dc4ce3 --- /dev/null +++ b/Tests/Core/CLI/CLIPeerIdentityValidatorTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import MacTools + +final class CLIPeerIdentityValidatorTests: XCTestCase { + func testExactPeerMatchingRejectsWrongUserTeamIdentifierAndRole() { + let validator = CLIPeerIdentityValidator() + let broker = CLIPeerIdentity( + processIdentifier: 1, + effectiveUserIdentifier: 501, + signingIdentifier: "app.example.mactools.cli-broker", + teamIdentifier: "TEAM123" + ) + let validCLI = CLIPeerIdentity( + processIdentifier: 2, + effectiveUserIdentifier: 501, + signingIdentifier: "app.example.mactools.cli", + teamIdentifier: "TEAM123" + ) + XCTAssertTrue(validator.matches(validCLI, as: .commandLineTool, relativeTo: broker)) + XCTAssertFalse(validator.matches( + CLIPeerIdentity( + processIdentifier: 2, + effectiveUserIdentifier: 502, + signingIdentifier: validCLI.signingIdentifier, + teamIdentifier: validCLI.teamIdentifier + ), + as: .commandLineTool, + relativeTo: broker + )) + XCTAssertFalse(validator.matches( + CLIPeerIdentity( + processIdentifier: 2, + effectiveUserIdentifier: 501, + signingIdentifier: validCLI.signingIdentifier, + teamIdentifier: "WRONG" + ), + as: .commandLineTool, + relativeTo: broker + )) + XCTAssertFalse(validator.matches(validCLI, as: .host, relativeTo: broker)) + } + + func testDerivesExactRoleSpecificSigningIdentifiers() { + let validator = CLIPeerIdentityValidator() + let releaseBroker = "app.ggbond.MacTools.mactools.cli-broker" + XCTAssertEqual( + validator.expectedSigningIdentifier(for: .host, brokerIdentifier: releaseBroker), + "app.ggbond.MacTools.mactools" + ) + XCTAssertEqual( + validator.expectedSigningIdentifier(for: .commandLineTool, brokerIdentifier: releaseBroker), + "app.ggbond.MacTools.mactools.cli" + ) + XCTAssertEqual( + validator.expectedSigningIdentifier(for: .broker, brokerIdentifier: releaseBroker), + releaseBroker + ) + } + + func testCLIIdentityCanDeriveItsBrokerIdentity() { + XCTAssertEqual( + CLIPeerIdentityValidator().expectedSigningIdentifier( + for: .broker, + brokerIdentifier: "app.ggbond.MacTools.mactools.dev.cli" + ), + "app.ggbond.MacTools.mactools.dev.cli-broker" + ) + } +} diff --git a/Tests/Core/CLI/CLIProtocolCodecTests.swift b/Tests/Core/CLI/CLIProtocolCodecTests.swift new file mode 100644 index 00000000..38b2199f --- /dev/null +++ b/Tests/Core/CLI/CLIProtocolCodecTests.swift @@ -0,0 +1,201 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools + +final class CLIProtocolCodecTests: XCTestCase { + func testNegotiationRequiresThreeWayOverlapWhenHostIsRegistered() { + XCTAssertEqual( + CLIProtocolNegotiator.selectedVersion( + clientMinimum: 1, + clientMaximum: 3, + brokerMinimum: 1, + brokerMaximum: 2, + hostMinimum: 1, + hostMaximum: 1 + ), + 1 + ) + XCTAssertNil(CLIProtocolNegotiator.selectedVersion( + clientMinimum: 2, + clientMaximum: 3, + brokerMinimum: 1, + brokerMaximum: 2, + hostMinimum: 1, + hostMaximum: 1 + )) + } + + func testTimestampsUseFractionalSeconds() { + XCTAssertEqual( + CLIProtocolCodec.timestamp(Date(timeIntervalSince1970: 0.123)), + "1970-01-01T00:00:00.123Z" + ) + } + + func testRequestRoundTripAndUnknownTopLevelKeyRejection() throws { + let request = CLIActionListRequest(runnableOnly: true, continuationToken: nil) + let data = try CLIProtocolCodec.encodeRequest(request) + XCTAssertEqual( + try CLIProtocolCodec.decodeRequest( + CLIActionListRequest.self, + from: data, + allowedKeys: ["runnableOnly", "continuationToken"] + ), + request + ) + + let unknown = Data(#"{"runnableOnly":true,"future":1}"#.utf8) + XCTAssertThrowsError(try CLIProtocolCodec.decodeRequest( + CLIActionListRequest.self, + from: unknown, + allowedKeys: ["runnableOnly"] + )) + + let duplicate = Data(#"{"runnableOnly":true,"runnableOnly":false}"#.utf8) + XCTAssertThrowsError(try CLIProtocolCodec.decodeRequest( + CLIActionListRequest.self, + from: duplicate, + allowedKeys: ["runnableOnly"] + )) { error in + XCTAssertEqual(error as? CLIProtocolCodecError, .duplicateFields(["runnableOnly"])) + } + } + + func testRecursiveDuplicateFieldValidationRejectsNestedObjectsAndArrays() throws { + for payload in [ + #"{"outer":{"value":1,"value":2}}"#, + #"{"records":[{"id":"first","id":"second"}]}"#, + ] { + XCTAssertThrowsError( + try CLIProtocolCodec.rejectDuplicateFieldsRecursively(in: Data(payload.utf8)) + ) { error in + XCTAssertTrue(error is CLIProtocolCodecError) + } + } + + XCTAssertNoThrow(try CLIProtocolCodec.rejectDuplicateFieldsRecursively( + in: Data(#"{"outer":{"value":1},"records":[{"id":"first"}]}"#.utf8) + )) + } + + func testInvocationContextEnvironmentRequiresCompleteBoundedValues() throws { + let chainID = UUID() + XCTAssertEqual( + try CLIInvocationContext.inherited(environment: [ + CLIInvocationContext.chainEnvironmentKey: chainID.uuidString, + CLIInvocationContext.depthEnvironmentKey: "1", + ]), + CLIInvocationContext(chainID: chainID, depth: 1) + ) + XCTAssertNil(try CLIInvocationContext.inherited(environment: [:])) + XCTAssertThrowsError(try CLIInvocationContext.inherited(environment: [ + CLIInvocationContext.chainEnvironmentKey: chainID.uuidString, + ])) + XCTAssertThrowsError(try CLIInvocationContext.inherited(environment: [ + CLIInvocationContext.chainEnvironmentKey: chainID.uuidString, + CLIInvocationContext.depthEnvironmentKey: "2", + ])) + } + + func testRequestEnvelopeRoundTripPreservesBrokerInvocationContext() throws { + let request = CLIRequestEnvelope( + protocolVersion: 1, + requestID: UUID(), + operation: .actionsRun, + sentAt: .now, + invocationContext: CLIInvocationContext(chainID: UUID(), depth: 0), + payload: nil + ) + + let data = try CLIProtocolCodec.encodeRequest(request) + let decoded = try CLIProtocolCodec.decodeRequest( + CLIRequestEnvelope.self, + from: data, + allowedKeys: [ + "protocolVersion", "requestID", "operation", "sentAt", + "invocationContext", "payload", + ] + ) + + XCTAssertEqual(decoded.protocolVersion, request.protocolVersion) + XCTAssertEqual(decoded.requestID, request.requestID) + XCTAssertEqual(decoded.operation, request.operation) + XCTAssertEqual(decoded.invocationContext, request.invocationContext) + XCTAssertEqual(decoded.payload, request.payload) + XCTAssertEqual( + decoded.sentAt.timeIntervalSince1970, + request.sentAt.timeIntervalSince1970, + accuracy: 0.001 + ) + } + + func testVersionOneDraftAcceptsRequestShapeFromBeforeInvocationContext() throws { + let requestID = UUID() + let data = Data(""" + { + "protocolVersion": 1, + "requestID": "\(requestID.uuidString)", + "operation": "doctor", + "sentAt": "2026-08-23T17:00:00.000Z" + } + """.utf8) + + let request = try CLIProtocolCodec.decodeRequest( + CLIRequestEnvelope.self, + from: data, + allowedKeys: [ + "protocolVersion", "requestID", "operation", "sentAt", + "invocationContext", "payload", + ] + ) + + XCTAssertEqual(request.requestID, requestID) + XCTAssertEqual(request.operation, .doctor) + XCTAssertNil(request.invocationContext) + XCTAssertNil(request.payload) + } + + func testRejectsOversizedRequestsAndNonFiniteValues() throws { + let oversized = Data(repeating: 0x20, count: CLIProtocolVersion.maximumRequestBytes + 1) + XCTAssertThrowsError(try CLIProtocolCodec.decodeRequest( + CLIActionListRequest.self, + from: oversized, + allowedKeys: ["runnableOnly"] + )) + XCTAssertThrowsError(try CLIProtocolCodec.encodeRequest( + ["value": CLIParameterValue.double(.infinity)] + )) + } + + func testExecutionSourceAndExposureSurfaceAreForwardCompatible() throws { + let source = ActionExecutionSource(rawValue: "future-source") + let encoded = try JSONEncoder().encode(source) + XCTAssertEqual(try JSONDecoder().decode(ActionExecutionSource.self, from: encoded), source) + XCTAssertEqual(ActionExecutionSource.cli.rawValue, "cli") + XCTAssertEqual(ActionExposureSurface.cli.rawValue, "cli") + } + + func testReplacingOperationPreservesFailureDetails() { + let request = CLIRequestEnvelope( + protocolVersion: 1, + requestID: UUID(), + operation: .actionsDescribe, + sentAt: .now, + payload: nil + ) + let response = CLIResponseEnvelope.failure( + request: request, + outcome: .unknownTarget, + category: "unknownAction", + message: "The requested action was not found." + ) + + let replaced = response.replacingOperation(.actionsRun) + + XCTAssertEqual(replaced.operation, .actionsRun) + XCTAssertEqual(replaced.requestID, response.requestID) + XCTAssertEqual(replaced.outcome, response.outcome) + XCTAssertEqual(replaced.rejection, response.rejection) + XCTAssertEqual(replaced.message, response.message) + } +} diff --git a/Tests/Core/CLI/CLIRequestAdmissionStateTests.swift b/Tests/Core/CLI/CLIRequestAdmissionStateTests.swift new file mode 100644 index 00000000..9127a4f0 --- /dev/null +++ b/Tests/Core/CLI/CLIRequestAdmissionStateTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import MacTools + +final class CLIRequestAdmissionStateTests: XCTestCase { + func testDefaultLimitsMatchProtocolVersionOneContract() { + let state = CLIRequestAdmissionState() + XCTAssertEqual(state.maximumRequestsPerClient, 8) + XCTAssertEqual(state.maximumRequestsGlobally, 32) + } + + func testRejectsDuplicatePerClientAndGlobalCapacity() { + var state = CLIRequestAdmissionState( + maximumRequestsPerClient: 2, + maximumRequestsGlobally: 3 + ) + let first = UUID() + let second = UUID() + let third = UUID() + XCTAssertNil(state.admit(requestID: first, clientID: "a")) + XCTAssertEqual( + state.admit(requestID: first, clientID: "b"), + .duplicateRequestID + ) + XCTAssertNil(state.admit(requestID: second, clientID: "a")) + XCTAssertEqual( + state.admit(requestID: UUID(), clientID: "a"), + .clientCapacity + ) + XCTAssertNil(state.admit(requestID: third, clientID: "b")) + XCTAssertEqual( + state.admit(requestID: UUID(), clientID: "c"), + .globalCapacity + ) + } + + func testLateFinishCannotRemoveReusedRequestOwnedByAnotherClient() { + var state = CLIRequestAdmissionState() + let requestID = UUID() + XCTAssertNil(state.admit(requestID: requestID, clientID: "old")) + XCTAssertTrue(state.owns(requestID: requestID, clientID: "old")) + XCTAssertFalse(state.owns(requestID: requestID, clientID: "new")) + XCTAssertEqual(state.removeRequests(clientID: "old"), [requestID]) + XCTAssertNil(state.admit(requestID: requestID, clientID: "new")) + state.finish(requestID: requestID, clientID: "old") + XCTAssertEqual(state.activeRequests[requestID], "new") + } + + func testActiveInvocationMarkerRejectsRecursiveChildAndUnknownMarkerIsInvalid() throws { + var state = CLIRequestAdmissionState() + let rootRequestID = UUID() + XCTAssertNil(state.admit(requestID: rootRequestID, clientID: "root")) + let context = try XCTUnwrap( + state.invocationContext(requestID: rootRequestID, clientID: "root") + ) + let childContext = CLIInvocationContext( + chainID: context.chainID, + depth: context.depth + 1 + ) + + XCTAssertEqual( + state.admit( + requestID: UUID(), + clientID: "child", + invocationContext: childContext + ), + .recursiveInvocation + ) + XCTAssertEqual( + state.admit( + requestID: UUID(), + clientID: "forged", + invocationContext: CLIInvocationContext(chainID: UUID(), depth: 1) + ), + .invalidInvocationContext + ) + + state.finish(requestID: rootRequestID, clientID: "root") + XCTAssertEqual( + state.admit( + requestID: UUID(), + clientID: "late-child", + invocationContext: childContext + ), + .invalidInvocationContext + ) + } + + func testRejectsInvocationDepthOutsideProtocolBound() { + var state = CLIRequestAdmissionState() + XCTAssertEqual( + state.admit( + requestID: UUID(), + clientID: "child", + invocationContext: CLIInvocationContext(chainID: UUID(), depth: 0) + ), + .invalidInvocationContext + ) + XCTAssertEqual( + state.admit( + requestID: UUID(), + clientID: "child", + invocationContext: CLIInvocationContext( + chainID: UUID(), + depth: CLIProtocolVersion.maximumInvocationDepth + 1 + ) + ), + .invalidInvocationContext + ) + } + + func testPreAdmissionCancellationIsConsumedAndDoesNotLeakCapacity() { + var state = CLIRequestAdmissionState() + let requestID = UUID() + + XCTAssertEqual(state.cancel(requestID: requestID, clientID: "client"), .recorded) + XCTAssertEqual( + state.admit(requestID: requestID, clientID: "client"), + .cancelledBeforeAdmission + ) + XCTAssertTrue(state.pendingCancellations.isEmpty) + XCTAssertNil(state.admit(requestID: requestID, clientID: "client")) + } + + func testCancellationBetweenAdmissionAndForwardingPreventsHostDelivery() { + var state = CLIRequestAdmissionState() + let requestID = UUID() + + XCTAssertNil(state.admit(requestID: requestID, clientID: "client")) + XCTAssertEqual(state.cancel(requestID: requestID, clientID: "client"), .recorded) + XCTAssertFalse(state.beginForwarding(requestID: requestID, clientID: "client")) + XCTAssertFalse(state.forwardedRequests.contains(requestID)) + + state.finish(requestID: requestID, clientID: "client") + XCTAssertTrue(state.activeCancellations.isEmpty) + } + + func testCancellationAfterForwardingMustBeDeliveredToHost() { + var state = CLIRequestAdmissionState() + let requestID = UUID() + + XCTAssertNil(state.admit(requestID: requestID, clientID: "client")) + XCTAssertTrue(state.beginForwarding(requestID: requestID, clientID: "client")) + XCTAssertEqual( + state.cancel(requestID: requestID, clientID: "client"), + .forwardToHost + ) + + state.finish(requestID: requestID, clientID: "client") + XCTAssertTrue(state.forwardedRequests.isEmpty) + } +} diff --git a/Tests/Core/CLI/CLIServiceConfigurationTests.swift b/Tests/Core/CLI/CLIServiceConfigurationTests.swift new file mode 100644 index 00000000..a53908ee --- /dev/null +++ b/Tests/Core/CLI/CLIServiceConfigurationTests.swift @@ -0,0 +1,96 @@ +import XCTest +@testable import MacTools + +final class CLIServiceConfigurationTests: XCTestCase { + func testServiceNameIsDerivedOnlyFromHostBundleIdentifier() { + XCTAssertEqual( + CLIServiceConfiguration.serviceName(bundleIdentifier: "example.MacTools.dev"), + "example.MacTools.dev.cli-broker" + ) + } + + func testStandaloneCLIIdentifierResolvesHostService() { + XCTAssertEqual( + CLIServiceConfiguration.serviceName( + bundleIdentifier: "example.MacTools.dev.cli" + ), + "example.MacTools.dev.cli-broker" + ) + XCTAssertEqual( + CLIServiceConfiguration.hostBundleIdentifier( + for: "example.MacTools.cli" + ), + "example.MacTools" + ) + XCTAssertEqual( + CLIServiceConfiguration.hostBundleIdentifier( + for: "example.MacTools.cli-broker" + ), + "example.MacTools" + ) + } + + func testReleaseDownloadURLUsesMatchingVersionedAsset() { + XCTAssertEqual( + CLIServiceConfiguration.releaseDownloadURL(version: "1.2.0-beta.1").absoluteString, + "https://github.com/ggbond268/MacTools/releases/download/v1.2.0-beta.1/mactools-cli-1.2.0-beta.1-macos-universal.zip" + ) + } + + func testReleaseDownloadURLFallsBackToReleaseListForInvalidVersion() { + XCTAssertEqual( + CLIServiceConfiguration.releaseDownloadURL(version: "1/2").absoluteString, + "https://github.com/ggbond268/MacTools/releases" + ) + XCTAssertEqual( + CLIServiceConfiguration.releaseDownloadURL(version: nil).absoluteString, + "https://github.com/ggbond268/MacTools/releases" + ) + } + + func testFindsContainingApplicationThroughSymlinkedExecutable() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let executable = root.appendingPathComponent("MacTools.app/Contents/MacOS/mactools") + let link = root.appendingPathComponent("bin/mactools") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: link.deletingLastPathComponent(), withIntermediateDirectories: true) + XCTAssertTrue(FileManager.default.createFile(atPath: executable.path, contents: Data())) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: executable) + defer { try? FileManager.default.removeItem(at: root) } + + XCTAssertEqual( + CLIServiceConfiguration.containingApplicationURL(executableURL: link), + root.appendingPathComponent("MacTools.app") + ) + } + + func testResolvesBareExecutableNameThroughPath() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let executable = root.appendingPathComponent("MacTools.app/Contents/MacOS/mactools") + let link = root.appendingPathComponent("bin/mactools") + try FileManager.default.createDirectory( + at: executable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: link.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + XCTAssertTrue(FileManager.default.createFile( + atPath: executable.path, + contents: Data(), + attributes: [.posixPermissions: 0o755] + )) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: executable) + defer { try? FileManager.default.removeItem(at: root) } + + let resolved = CLIServiceConfiguration.resolvedExecutableURL( + executablePath: "mactools", + environment: ["PATH": link.deletingLastPathComponent().path] + ) + XCTAssertEqual( + CLIServiceConfiguration.containingApplicationURL(executableURL: resolved), + root.appendingPathComponent("MacTools.app") + ) + } +} diff --git a/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift b/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift index 90b47d60..f117698f 100644 --- a/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift +++ b/Tests/Core/Plugins/Dynamic/PluginCatalogTests.swift @@ -25,10 +25,10 @@ final class PluginCatalogTests: XCTestCase { ) } - func testCurrentPluginKitUsesVersion5CatalogURL() throws { + func testCurrentPluginKitUsesVersion6CatalogURL() throws { XCTAssertEqual( PluginCatalogProviderConfiguration.productionCatalogURL, - URL(string: "https://mactools.ggbond.app/plugins/v5/catalog.json") + URL(string: "https://mactools.ggbond.app/plugins/v6/catalog.json") ) } diff --git a/changes/unreleased/local-cli.md b/changes/unreleased/local-cli.md new file mode 100644 index 00000000..530110be --- /dev/null +++ b/changes/unreleased/local-cli.md @@ -0,0 +1,7 @@ +--- +release: app +type: added +area: Automation +--- + +Added an optional, separately downloadable authenticated `mactools` command for guarded actions and workflows, plugin diagnostics, version-matched cold-start discovery, and stable JSON and exit-code output. diff --git a/changes/unreleased/plugin-kit-v6.md b/changes/unreleased/plugin-kit-v6.md new file mode 100644 index 00000000..00e411f8 --- /dev/null +++ b/changes/unreleased/plugin-kit-v6.md @@ -0,0 +1,7 @@ +--- +release: plugin +type: changed +area: Plugin Platform +--- + +Rebuilt the plugin line on PluginKit 6 so plugins can participate safely in the new command-line action surface. diff --git a/docs/actions-automation.md b/docs/actions-automation.md index 7037292b..6b9680d7 100644 --- a/docs/actions-automation.md +++ b/docs/actions-automation.md @@ -2,6 +2,11 @@ MacTools exposes one host-owned action platform to every invocation surface. Plugins publish stable action definitions and catalog entries; the host owns lookup, migration, availability, shortcut registration, confirmation, and execution. +The separately installed [`mactools` CLI](cli.md) is another host-owned surface. It +discovers published actions, workflows, and plugin diagnostics through a signed +XPC broker and invokes only `ActionExecutor`; CLI eligibility never grants an +action broader permissions or skips confirmation and availability checks. + ## Ownership - `ActionRegistry` owns revisioned in-memory definition/catalog indexes and live availability invalidation. @@ -21,7 +26,7 @@ Workflows can be created, renamed, duplicated, enabled or disabled, reordered, p The workflow editor keeps action identity and parameters together: changing an action uses the shared action picker and replaces its parameters with a valid reference. Step names, waits, and failure policy live under Advanced Options. Text and numeric drafts are debounced before persistence, while structural changes such as adding, replacing, moving, or deleting steps are saved immediately and rebuild the published catalog only when action identity changes. -Workflow actions publish durable progress through Automation. Action Grid and Unified Search complete validation, availability checks, provider-generation revalidation, and any confirmation before handing the run to Automation and closing; the menu-bar running indicator, Automation run history, and Stop control then own its lifecycle. Ordinary actions still keep the invoking surface open until they return a terminal result, and nested workflow steps always await their child action so ordering, failure policy, recursion limits, and cancellation remain deterministic. +Workflow actions publish durable progress through Automation. Action Grid, Unified Search, and the CLI complete validation, availability checks, provider-generation revalidation, and any confirmation before handing the run to Automation and closing; the menu-bar running indicator, Automation run history, and Stop control then own its lifecycle. Ordinary actions still keep the invoking surface open until they return a terminal result, and nested workflow steps always await their child action so ordering, failure policy, recursion limits, and cancellation remain deterministic. Automatic rules use one trigger and zero or more conditions: diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..f1d6be2e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,140 @@ +# MacTools CLI + +MacTools provides an optional authenticated local command-line client for discovering and +running the same canonical actions and workflows used by the app. The GUI host +remains the authority for availability, permissions, confirmation, concurrency, +timeouts, and plugin execution. The CLI does not load plugins itself. + +## Setup + +Download `mactools-cli--macos-universal.zip` and its `.sha256` file from +the same [GitHub release](https://github.com/ggbond268/MacTools/releases) as the +installed app. The archive contains one universal executable named `mactools`. +Install it in a user-owned directory: + +```bash +mkdir -p "$HOME/.local/bin" +unzip mactools-cli-*-macos-universal.zip +install -m 755 mactools "$HOME/.local/bin/mactools" +``` + +MacTools does not request administrator access or edit shell startup files. Add +`~/.local/bin` to `PATH` yourself if it is not already there. + +Open **Settings > General > Command Line** and enable the integration. The app +then registers its bundled, user-scoped background broker. Merely installing the +app or CLI does not register it. macOS may ask +for approval in **System Settings > General > Login Items & Extensions**. The +CLI starts the installed MacTools app without activation when the host is not +running and waits up to ten seconds for discovery, launch, broker replacement, +and action-registry registration. Cancelling the CLI also stops waiting for host +discovery immediately. + +If more than one MacTools copy is registered, the CLI checks every Launch +Services candidate, requires the same release version and build plus the exact +same-team host signature, and then chooses deterministically. `doctor --json` +reports distinct categories for a missing or mismatched app, an invalid +signature, a launch failure, and background-item approval instead of reducing +all cold-start failures to one timeout. When the enabled app path or release +changes, MacTools refreshes its broker registration before reconnecting, so a +previous broker cannot keep a newly installed matching CLI on the old release. + +The app and CLI use the same release version initially, but remain separate +artifacts so either can be replaced independently. Their handshake selects the +highest mutually supported protocol version and fails clearly if none overlaps. +Disable the integration in Settings to unregister the broker. Remove the CLI +executable yourself when it is no longer needed. + +## Commands + +```text +mactools version [--json] +mactools doctor [--json] +mactools actions list [--runnable] [--page-token token] [--json] +mactools actions describe [--json] +mactools actions availability [--json] +mactools actions run [--parameter name=value ...] + [--input-json ] [--no-wait] [--json] +mactools workflows list [--page-token token] [--json] +mactools workflows describe [--json] +mactools workflows run [--no-wait] [--json] +mactools plugins list [--page-token token] [--json] +mactools plugins describe [--json] +mactools plugins doctor [--json] +``` + +Workflow names resolve only when there is one exact match. Action identity is +always the stable `provider/action` key printed by `actions list`. + +One action definition may publish several parameter presets to graphical action +surfaces. CLI discovery still emits one record for its stable key, using the +definition's title, description, parameter schema, and capabilities. Its +availability and CLI eligibility are true when at least one published preset is +available or eligible. Execution always validates the caller's submitted +parameters and rechecks availability and exposure for that exact reference. + +`--no-wait` is accepted only for actions that hand durable progress ownership to +MacTools. Ordinary actions wait for a terminal outcome. `SIGINT` and `SIGTERM` +request cancellation; the provider receives it only when its canonical action +declares cancellation support. The CLI exits with status `8` after forwarding +the interrupt, including during host startup, parameter discovery, confirmation, +and the request-admission boundary. + +Saved Scripts propagates an opaque invocation marker to child processes. If a +script invokes `mactools` again while its parent CLI request is active, the +broker rejects the nested request as recursive. Invocation markers are bounded, +never printed, and are not credentials; the normal global request and action +concurrency limits remain in force. + +## Parameters and secrets + +Public string, integer, number, and Boolean parameters may use repeated +`--parameter name=value` arguments. Sensitive parameters are rejected on the +command line because process arguments are visible to other local tools. + +Use JSON on standard input or in a protected file instead: + +```bash +printf '%s\n' '{"token":"secret"}' | + mactools actions run provider/action --input-json - + +mactools actions run provider/action --input-json "$HOME/private-input.json" +``` + +A file must be a regular, non-symlink file owned by the current user, have no +group or other permission bits, and fit within the 64 KiB request limit. Values +are decoded against the current action schema, validated again by the host, and +omitted from output and logs. JSON Booleans are not accepted as numbers (or vice +versa); integer and number parameters retain their declared schema types. + +## JSON and exit status + +`--json` writes exactly one versioned object. It includes `schemaVersion`, +`protocolVersion`, `requestID`, `command`, timestamps, `invocationSource`, +`outcome`, a structured `rejection`, and command-specific `data`. +Malformed or mismatched responses from an authenticated peer are protocol +failures (exit 10). If a broker reply never arrives after a request is sent, +delivery is uncertain and the CLI reports a transport failure (exit 9). + +| Exit | Meaning | +| ---: | --- | +| 0 | Completed or durably started | +| 2 | Invalid command or parameters | +| 3 | Unknown action, workflow, or plugin | +| 4 | Known but unavailable | +| 5 | Confirmation denied or unavailable | +| 6 | Provider/action failure | +| 7 | Timed out | +| 8 | Cancelled | +| 9 | Host or broker transport failure | +| 10 | Incompatible protocol | + +## Trust boundary + +Release builds require the CLI, broker, and GUI host to have exact role-specific +signing identifiers, the same Developer Team identifier, valid strict code +signatures, and the same effective user. The broker does not discover plugins, +persist payloads, or execute actions. + +Disabling Command-Line Integration unregisters the broker but does not modify the +separately installed CLI executable. diff --git a/docs/github-actions.md b/docs/github-actions.md index 36b42e1f..2378dce7 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -4,7 +4,7 @@ - `Build`:在 `main` push、Pull Request 和手动触发时运行。执行 XcodeGen、Debug 测试,并在非 PR 场景额外编译 unsigned Release app 做配置校验;不上传不可分发的未签名产物。 - `Prepare Release`:在 GitHub Actions 页面手动触发。输入发布类型、目标版本和是否继续发布;它会检查、bump、提交版本变更、创建 tag,并在需要时显式触发 `Release` 或 `Plugin Release`。 -- `Release`:在推送 `v*.*.*` 或 `v*.*.*-*` tag,或手动输入 tag 时运行。构建 Release 版本,使用 Developer ID 签名、公证、打包 DMG,创建或更新 GitHub Release;稳定版会明确标记为 GitHub Latest,并更新官网使用的 `docs/app-release.json`,预发布不会覆盖稳定版下载元数据。 +- `Release`:在推送 `v*.*.*` 或 `v*.*.*-*` tag,或手动输入 tag 时运行。构建 Release 版本,分别签名并公证 App DMG 与独立的 universal `mactools-cli` ZIP,为两者生成可随文件移动的 SHA-256 校验文件,再创建或更新 GitHub Release;稳定版会明确标记为 GitHub Latest,并更新官网使用的 `docs/app-release.json`,预发布不会覆盖稳定版下载元数据。 - `Homebrew Cask Update`:手动输入版本时运行;未输入版本则从稳定 `v*` App Release 中查找同时包含 `MacTools.dmg` 与 `MacTools.sha256` 的最新版,通过 `brew bump-cask-pr` 向官方 `Homebrew/homebrew-cask` 提交 cask bump PR。 - `Plugin Release` runs for a pushed `plugins-*` tag or a manually selected plugin batch tag. PluginKit v2 keeps `docs/plugins/catalog.json`, PluginKit v3 and later use `docs/plugins/vN/catalog.json`, the v4 catalog remains immutable for MacTools through 1.1.6, and MacTools 1.2 uses the new `docs/plugins/v5/catalog.json`. The first release of an ABI line rebuilds and signs every plugin. Plugin batches use `--latest=false` and never replace the latest App release. - `Deploy Pages`:在 `site/**` 或 `docs/app-release.json` 合入 `main`、`Release` / `Plugin Release` 成功完成,或手动触发时运行。它先构建 `site/` 下的 Astro 官网,再合并 `docs/` 中的 App 发布元数据、appcast、插件 catalog、图标库等静态发布资源并发布到 GitHub Pages;PR 不会触发这条流水线。 @@ -104,7 +104,7 @@ PY make release ``` -命令会交互选择发布类型、分析当前版本和最新 tag、选择 `patch`/`minor`/`major`,并先展示 bump 预览;确认后才自动 `git pull --rebase`、运行轻量检查、更新版本文件、提交版本 bump、创建并推送 tag。App 发布会推送 `v*.*.*` tag,后续构建、签名、公证、上传 GitHub Release、更新 Sparkle appcast 由 `Release` workflow 完成。`Release` 不更新 Homebrew;需要更新官方 cask 时,手动运行独立的 `Homebrew Cask Update` workflow。 +命令会交互选择发布类型、分析当前版本和最新 tag、选择 `patch`/`minor`/`major`,并先展示 bump 预览;确认后才自动 `git pull --rebase`、运行轻量检查、更新版本文件、提交版本 bump、创建并推送 tag。App 发布会推送 `v*.*.*` tag,后续构建、签名、公证、上传 GitHub Release、更新 Sparkle appcast 由 `Release` workflow 完成。同一 Release 会上传 `MacTools.dmg`、`MacTools.sha256`、`mactools-cli--macos-universal.zip` 和对应 `.sha256`;CLI ZIP 独立公证,DMG 与 CLI 都必须通过共享产物验证器。`Release` 不更新 Homebrew;需要更新官方 cask 时,手动运行独立的 `Homebrew Cask Update` workflow。 在选择 App 或插件发布之前,`make release` 会比较最新 App tag 与当前 `Sources/MacToolsPluginKit/`。没有代码变化时直接继续;检测到变化时会列出文件,并要求发布者通过 `y/N` 明确确认是否已经检查 `pluginKitVersion`。这项兼容性确认不会被 `--yes` 跳过;非交互发布遇到 PluginKit 变化时会停止,要求改用交互终端完成检查。 @@ -138,7 +138,7 @@ git tag v0.9.3 git push origin v0.9.3 ``` -Release 工作流会校验 `v0.9.3` 与 `Configs/AppVersion.xcconfig` 的 `MARKETING_VERSION = 0.9.3` 一致,并使用 `CURRENT_PROJECT_VERSION` 作为 Sparkle appcast 和 App 包里的 build 号。版本不一致时会直接失败,避免产物、tag 和 appcast 不一致。 +Release 工作流会校验 `v0.9.3` 与 `Configs/AppVersion.xcconfig` 的 `MARKETING_VERSION = 0.9.3` 一致,并使用 `CURRENT_PROJECT_VERSION` 作为 Sparkle appcast、App、broker 和独立 CLI 的 build 号。验证器不会执行待验证的 CLI;它从 Mach-O 内嵌 Info.plist 读取身份与版本,并在上传前检查 App/CLI 版本一致、精确 bundle 布局、双架构、LaunchAgent 配置、同 Team 签名、hardened runtime 与 Gatekeeper。任一检查失败都会终止发布。 也可以在 GitHub Actions 页面手动运行 `Release`,输入已存在的 tag,例如 `v0.9.3`;该 tag 指向的提交里仍必须已经更新 `Configs/AppVersion.xcconfig`。 @@ -172,7 +172,7 @@ make release ARGS="--type plugin --version 1.1.0 --plugin-mode all --yes" 应用内是否显示“可更新”只比较插件版本,不比较 batch tag 或 asset URL。因此只有实际变化的插件需要递增各自 `plugin.json.version`;未变化插件不会因为新批次 tag 而显示可更新或无效。 -`pluginKitVersion` is the plugin ABI boundary. Raising it requires rebuilding every plugin package and incrementing each plugin's own `plugin.json.version`. The release helper automatically switches an ABI migration to `plugin_mode=all` and writes a complete catalog for the new ABI. PluginKit v5 writes `docs/plugins/v5/catalog.json` and does not modify the v4 catalog used by MacTools through 1.1.6. Catalog validation rejects mixed PluginKit versions so the host never loads a binary from an incompatible ABI. +`pluginKitVersion` is the plugin ABI boundary. Raising it requires rebuilding every plugin package and incrementing each plugin's own `plugin.json.version`. The release helper automatically switches an ABI migration to `plugin_mode=all` and writes a complete catalog for the new ABI. PluginKit v6 writes `docs/plugins/v6/catalog.json` and does not modify the immutable v4/v5 catalogs. Catalog validation rejects mixed PluginKit versions so the host never loads a binary from an incompatible ABI. 推送插件批次 tag: diff --git a/docs/plugins/local-native-plugins.md b/docs/plugins/local-native-plugins.md index bbe71af5..2709f9b9 100644 --- a/docs/plugins/local-native-plugins.md +++ b/docs/plugins/local-native-plugins.md @@ -39,7 +39,7 @@ Example.mactoolsplugin/ }, "version": "1.0.0", "minHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "bundleRelativePath": "Example.bundle", "factoryClass": "Example.ExamplePluginFactory", "capabilities": { @@ -127,7 +127,7 @@ When a change touches `Sources/MacToolsPluginKit/`, it is package-relevant for e ## Settings UI -Plugin settings are hosted by MacTools. PluginKit 5 exposes one `settingsPage` entry point with two explicit layouts: +Plugin settings are hosted by MacTools. PluginKit 6 exposes one `settingsPage` entry point with two explicit layouts: - `PluginSettingsPage.form` is the default. Describe standard controls with `PluginSettingsSection`, `PluginSettingsRow`, and `PluginSettingsControl`; the host renders the native grouped form, search entries, validation, permissions, and shortcuts. - Reserve segmented pickers for a few short labels; use `.menu` when options are longer or localization can make the row overflow. Declarative sliders should provide `valueFormat` for a live host-rendered readout. Custom settings use `PluginSettingsSlider` to keep stepped values without drawing dense tick marks. @@ -168,6 +168,8 @@ Commands are never inferred from panel buttons. A plugin must explicitly conform New executable capabilities should use `PluginActionProviding` rather than adding new legacy commands or shortcut-owned callbacks. Publish stable `ActionKey` values, versioned parameter schemas, availability snapshots, risk/confirmation policy, external-invocation policy, execution capabilities, and bounded timeouts. If discovery surfaces should name system permissions before execution, also implement `PluginActionPermissionProviding` and map each action to IDs declared by `permissionRequirements`. +If a plugin's initial action definitions or catalog entries depend on asynchronous discovery, also implement `PluginActionCatalogPreparing`. The host awaits this optional preparation before registering external transports, then synchronously rebuilds the shared registry. The method should finish only the bounded initial discovery needed for action identity and availability; settings-only visuals and later live refreshes remain asynchronous and continue to notify through `onStateChange`. + Host-owned system integrations may additionally consult the optional `PluginActionExposureProviding` contract. `.excluded` is a provider veto; `.automatic` delegates to the host's conservative eligibility checks and does not bypass risk, availability, permission, parameter, or foreground requirements. Unknown surfaces and provider failures must fail closed, and exposure is revalidated at execution time. Keep `externalInvocationPolicy` separate because it governs Run Links rather than general system discovery. If an action executes mutable provider-owned content that is not represented by its `ActionDefinition` or catalog entry, also implement `PluginActionExecutionRevisionProviding`. Advance the revision after every successful persisted mutation. The host snapshots and revalidates it around confirmation so a user never approves one payload and executes another. @@ -211,7 +213,7 @@ Install and update are staged before moving into `Installed`. Per-plugin runtime - The manifest ID, versions, and bundle relative path are validated before loading code. - Host version and plugin kit version are checked before loading code. - Installed packages built for an older PluginKit are kept on disk but marked incompatible and are never passed to the native bundle loader. -- Public value types within one PluginKit version must preserve their stored binary layout. CI compiles the frozen v5 `PluginShortcutRecorder` client declaration and links that client against the current framework so source-only tests cannot hide an incompatible in-place layout change. +- Public value types within one PluginKit version must preserve their stored binary layout. CI keeps the frozen v5 `PluginShortcutRecorder` client check for unchanged legacy value types; PluginKit 6 intentionally changes `ActionExecutionSource` to a forward-compatible string-backed value and requires every plugin to be rebuilt. - The plugin bundle signature is validated before loading code. - When the host has a Team ID, the plugin bundle must have the same Team ID. - Untrusted third-party native plugins should use a future isolated process or XPC model instead of in-process bundle loading. diff --git a/docs/plugins/plugin-catalog.md b/docs/plugins/plugin-catalog.md index a33cff8e..541ef920 100644 --- a/docs/plugins/plugin-catalog.md +++ b/docs/plugins/plugin-catalog.md @@ -2,7 +2,7 @@ MacTools dynamic plugins use one catalog-driven flow for both production distribution and local development. -- PluginKit 2 production builds read the legacy `catalog.json` URL. PluginKit 3 and later builds read versioned URLs. MacTools through 1.1.6 remains on the immutable PluginKit v4 catalog at `v4/catalog.json`; MacTools 1.2 and later use the new PluginKit v5 catalog at `v5/catalog.json`. +- PluginKit 2 production builds read the legacy `catalog.json` URL. PluginKit 3 and later builds read versioned URLs. MacTools through 1.1.6 remains on immutable PluginKit v4, MacTools 1.2 uses immutable PluginKit v5, and the CLI-capable host uses PluginKit v6 at `v6/catalog.json`. - Each catalog contains packages for one PluginKit ABI line. The legacy v2 catalog is kept unchanged when a new ABI is released, so older app builds continue to work. - `minimumHostVersion` at the catalog root is the oldest host that can parse that catalog schema. Each entry declares its own install requirement; older hosts keep the catalog available and show newer entries as incompatible instead of rejecting the whole marketplace. - Local development reads a Debug-only `file://` catalog, usually configured with `MACTOOLS_PLUGIN_CATALOG_URL`. @@ -16,7 +16,7 @@ MacTools dynamic plugins use one catalog-driven flow for both production distrib "catalogID": "com.ggbond.mactools.plugins", "generatedAt": "2026-05-16T12:00:00Z", "minimumHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "plugins": [ { "id": "com.ggbond.mactools.demo", @@ -34,7 +34,7 @@ MacTools dynamic plugins use one catalog-driven flow for both production distrib }, "version": "1.0.0", "minimumHostVersion": "1.2.0", - "pluginKitVersion": 5, + "pluginKitVersion": 6, "capabilities": { "primaryPanel": true, "componentPanel": false, @@ -76,6 +76,7 @@ PluginKit 2 -> https://mactools.ggbond.app/plugins/catalog.json PluginKit 3 -> https://mactools.ggbond.app/plugins/v3/catalog.json PluginKit 4 -> https://mactools.ggbond.app/plugins/v4/catalog.json PluginKit 5 -> https://mactools.ggbond.app/plugins/v5/catalog.json +PluginKit 6 -> https://mactools.ggbond.app/plugins/v6/catalog.json PluginKit N -> https://mactools.ggbond.app/plugins/vN/catalog.json ``` @@ -165,7 +166,7 @@ Recommended production flow is an incremental batch plugin release: 7. If package-relevant files changed inside a plugin or shared PluginKit code changed but that plugin version did not increase, the workflow fails before signing or uploading. A `pluginKitVersion` change automatically becomes a full `mode=all` rebuild and replaces the catalog for that ABI line; other exceptional shared paths can still be supplied explicitly with `--shared-path`. 8. The workflow builds, signs, zips, and uploads only the selected plugin packages. 9. For an ABI migration, the workflow generates a complete catalog from all rebuilt packages. For later releases within an ABI line, it generates a delta catalog and merges it into that line's catalog, keeping unchanged entries pointing at their existing assets. -10. The signed catalog is committed to `docs/plugins/catalog.json` for v2 or `docs/plugins/vN/catalog.json` for PluginKit N >= 3. The current PluginKit v5 catalog is `docs/plugins/v5/catalog.json`. +10. The signed catalog is committed to `docs/plugins/catalog.json` for v2 or `docs/plugins/vN/catalog.json` for PluginKit N >= 3. PluginKit v6 writes `docs/plugins/v6/catalog.json` and never mutates the v5 compatibility catalog. 11. `Deploy Pages` publishes the signed catalog to GitHub Pages. The batch tag is stored per plugin entry through `package.url` and `releaseNotesURL`, so one catalog can point different plugins to different release tags without changing host code. @@ -219,7 +220,7 @@ Generated local output: build/PluginRelease/ Assets/*.mactoolsplugin.zip catalog.json -docs/plugins/v5/catalog.json +docs/plugins/v6/catalog.json ``` The lower-level scripts are still useful for external plugin repositories. `build-plugin-release-assets.sh` can build all plugins or a subset with repeated `--plugin` arguments: diff --git a/docs/superpowers/plans/2026-08-23-local-cli-capability-api.md b/docs/superpowers/plans/2026-08-23-local-cli-capability-api.md new file mode 100644 index 00000000..4460bb3f --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-local-cli-capability-api.md @@ -0,0 +1,421 @@ +# Local CLI and Capability API Implementation Plan + +**Goal:** Implement the accepted design in +`docs/superpowers/specs/2026-08-23-local-cli-capability-api.md` as four bounded +phases, preserving canonical action ownership and a fail-closed local trust +boundary. + +**Architecture:** A signed CLI connects to a user-scoped launchd XPC broker. The +primary GUI host registers a bidirectional host interface after its action +registry is ready. The broker authenticates and routes bounded versioned +envelopes; only the host discovers plugins and invokes `ActionExecutor`. + +**Tech Stack:** Swift 6, Foundation XPC, Security, ServiceManagement, AppKit, +SwiftUI, XcodeGen, XCTest, and the existing action/automation/plugin host. + +This plan intentionally separates the security spike from user-visible +execution. Phase 0 must be reviewed before Phase 1 or later work is merged. + +--- + +## Phase 0: Transport and Security Spike + +### Task 1: Add a transport-neutral protocol module + +**Files:** + +- Create `Sources/MacToolsCLIProtocol/CLIProtocolVersion.swift` +- Create `Sources/MacToolsCLIProtocol/CLIEnvelope.swift` +- Create `Sources/MacToolsCLIProtocol/CLIProtocolModels.swift` +- Create `Sources/MacToolsCLIProtocol/CLIStrictJSONDecoder.swift` +- Create `Tests/Core/CLI/CLIProtocolTests.swift` +- Modify `project.yml` + +Steps: + +1. Add a static `MacToolsCLIProtocol` target shared by the CLI, broker, host, + and tests. Do not place private IPC types in `MacToolsPluginKit`. +2. Define minimum/current protocol versions, request/response headers, + handshake models, operation names, cancellation, readiness, and redacted + transport errors. +3. Enforce the 64 KiB request, 4 MiB response, pagination, and request-count + bounds from the design. +4. Implement strict decoding that rejects unknown and duplicate keys instead of + relying on `JSONDecoder`'s default unknown-key behavior. +5. Add golden encoding tests, unknown-field tests, boundary-size tests, malformed + data tests, and version-negotiation matrix tests. +6. Run `make generate`, then the focused `CLIProtocolTests` class. + +### Task 2: Implement peer identity validation + +**Files:** + +- Create `Sources/Core/CLI/CLIPeerIdentity.swift` +- Create `Sources/Core/CLI/CLIPeerIdentityValidator.swift` +- Create `Tests/Core/CLI/CLIPeerIdentityValidatorTests.swift` +- Add a small signed-process integration probe under `Tests/Support/CLI/` +- Modify `project.yml` + +Steps: + +1. Define an injectable identity-validation protocol returning EUID, Team + Identifier, signing identifier, signature validity, and designated- + requirement validity. +2. Resolve a live peer with Security framework APIs while the XPC connection is + retained; do not validate an arbitrary path supplied by the peer. +3. Require exact role-specific signing IDs and the current process's Team + Identifier. Reject missing identities and ad-hoc/unsigned peers in production. +4. Add debug fixtures that are explicit build configuration, not environment + switches accepted by a release binary. +5. Test wrong user, wrong team, wrong signing ID, invalid signature, missing + metadata, role confusion, and valid host/CLI/broker identities. +6. Run the focused identity tests and the signed-process probe. + +### Task 3: Build and register the XPC broker + +**Files:** + +- Create `Sources/MacToolsCLIBroker/main.swift` +- Create `Sources/MacToolsCLIBroker/CLIBroker.swift` +- Create `Sources/MacToolsCLIBroker/CLIBrokerListener.swift` +- Create `Sources/MacToolsCLIBroker/CLIBrokerConnectionState.swift` +- Create `Configs/MacToolsCLIBroker-LaunchAgent.plist` +- Create `Sources/Core/CLI/CLIBrokerServiceController.swift` +- Create `Tests/Core/CLI/CLIBrokerTests.swift` +- Modify `project.yml` + +Steps: + +1. Add the broker tool target and copy its LaunchAgent plist into + `Contents/Library/LaunchAgents`. +2. Advertise one build-configured Mach service. Do not use a user-writable name + or socket path. +3. Apply a listener code-signing requirement before accepting connections, + verify EUID, then bind each connection permanently to the host or CLI role. +4. Implement handshake, host registration, request routing, cancellation, + connection invalidation, per-client/global capacity, and payload limits. +5. Keep no plugin imports, action definitions, filesystem endpoint, payload + persistence, or parameter logging in the broker. +6. Register/unregister the LaunchAgent through `SMAppService` and expose status + for doctor/setup UI. +7. Test host replacement, disconnects, late replies, request ID collisions, + capacity, broker restart, and unauthorized connections. +8. Generate and build the app, then inspect the built bundle for both broker + executable and LaunchAgent plist. + +### Task 4: Register the ready primary host + +**Files:** + +- Create `Sources/Core/CLI/CLIHostBridge.swift` +- Create `Sources/Core/CLI/CLIHostRequestRouter.swift` +- Modify `Sources/App/MacToolsAppRuntime.swift` +- Modify `Sources/Core/Plugins/PluginHost.swift` +- Create `Tests/Core/CLI/CLIHostBridgeTests.swift` +- Create or update startup coordination tests under `Tests/App/` + +Steps: + +1. Add an explicit PluginHost readiness snapshot covering built-in/dynamic + provider synchronization and workflow action publication. +2. Start the broker service controller during primary-host startup, but register + the exported host interface only after readiness. +3. Never register from the secondary app instance or during tests unless the + injected transport requests it. +4. Authenticate the broker before exporting the host interface. +5. Route only version-1 discovery/doctor spike operations; return a structured + unsupported response for execution. +6. Remove registration on termination and reconnect with bounded backoff after + broker invalidation. +7. Test registry delay, dynamic plugin preparation, secondary-instance behavior, + termination, and reconnect. + +### Task 5: Build the handshake/doctor CLI spike + +**Files:** + +- Create `Sources/MacToolsCLI/main.swift` +- Create `Sources/MacToolsCLI/CLIApplication.swift` +- Create `Sources/MacToolsCLI/CLIArgumentParser.swift` +- Create `Sources/MacToolsCLI/CLIBrokerClient.swift` +- Create `Sources/MacToolsCLI/CLIHostLocator.swift` +- Create `Sources/MacToolsCLI/CLIOutput.swift` +- Create `Tests/Core/CLI/CLIArgumentParserTests.swift` +- Create `Tests/Core/CLI/CLIHostLocatorTests.swift` +- Create `Tests/Core/CLI/CLIOutputTests.swift` +- Modify `project.yml` + +Steps: + +1. Add a standalone `mactools` tool target without a third-party argument-parser + dependency; distribute it as a separate same-version release asset. +2. Implement offline `help` and CLI-version output, plus handshake-backed + `version` and `doctor` human/JSON output. +3. Read identity and version from the CLI executable's embedded Info.plist, + locate the matching host through Launch Services, and verify identity before launch. +4. Launch without activation and retry broker/host readiness within one injected + 10-second deadline. +5. Authenticate the broker and fail closed; never fall back to `CFMessagePort` + or an unauthenticated transport. +6. Add deterministic argument, JSON, host-location, startup-timeout, approval- + required, and exit-code tests. +7. Run the CLI-focused test classes and a signed Debug end-to-end handshake. + +### Phase 0 review gate + +Before proceeding, record results for: + +- macOS 14.0-14.3 explicit peer validation; +- macOS 14.4+ XPC peer-requirement defense in depth; +- same user / wrong user; +- same team / wrong team / unsigned; +- host cold launch and registry delay; +- app update with old broker process; +- broker crash and reconnect; +- cancellation round trip; and +- strict signature verification of the built app. + +If exact mutual authentication cannot be demonstrated on macOS 14.0, stop and +revisit the deployment target or transport. Do not ship a weaker fallback. + +--- + +## Phase 1: Read-Only Discovery + +### Task 6: Add host discovery snapshots + +**Files:** + +- Create `Sources/Core/CLI/CLIActionDiscoveryService.swift` +- Create `Sources/Core/CLI/CLIWorkflowDiscoveryService.swift` +- Create `Sources/Core/CLI/CLIPluginDiscoveryService.swift` +- Modify `Sources/Core/CLI/CLIHostRequestRouter.swift` +- Create `Tests/Core/CLI/CLIActionDiscoveryServiceTests.swift` +- Create `Tests/Core/CLI/CLIWorkflowDiscoveryServiceTests.swift` +- Create `Tests/Core/CLI/CLIPluginDiscoveryServiceTests.swift` + +Steps: + +1. Map published `ActionRegistry` entries to redacted protocol DTOs with current + availability, parameter definitions, execution capabilities, external policy, + and CLI eligibility. +2. Include excluded/unavailable catalog actions and support the runnable filter. +3. Map workflows by ID and name while retaining their canonical action reference. +4. Map read-only plugin installation, compatibility, trust, load, permission, + and action-provider diagnostics from PluginHost. +5. Add stable sorting and bounded continuation-token pagination. +6. Verify no DTO contains invocation values, provider closures, local-only data, + or plugin implementation objects. + +### Task 7: Add discovery commands and output + +**Files:** + +- Modify `Sources/MacToolsCLI/CLIArgumentParser.swift` +- Modify `Sources/MacToolsCLI/CLIApplication.swift` +- Modify `Sources/MacToolsCLI/CLIOutput.swift` +- Create `Tests/Core/CLI/CLIDiscoveryCommandTests.swift` +- Update `docs/actions-automation.md` + +Steps: + +1. Implement action list/describe/availability, workflow list/describe, and + plugin list/describe/doctor. +2. Resolve action keys strictly as `provider/action` and workflow identifiers as + UUID first, then unique exact name. +3. Print concise human output and exactly one stable envelope in JSON mode. +4. Map unknown, unavailable, host, and protocol failures to documented exits. +5. Add JSON golden files and human-output assertions for empty, unavailable, + ambiguous, incompatible, and paginated results. + +--- + +## Phase 2: Conservative Execution + +### Task 8: Add CLI action source and exposure + +**Files:** + +- Modify `Sources/MacToolsPluginKit/ActionModels.swift` +- Modify `Sources/MacToolsPluginKit/PluginKitCompatibility.swift` +- Modify `Sources/Core/Actions/ActionExecutor.swift` +- Modify `Sources/Core/Plugins/PluginHost.swift` +- Modify exhaustive source switches in host and plugins +- Modify `Plugins/*/plugin.json` +- Modify PluginKit compatibility fixtures, catalogs, and plugin documentation +- Modify `Tests/Core/Actions/ActionModelsTests.swift` +- Modify `Tests/Core/Actions/ActionExecutorTests.swift` +- Modify `Tests/Core/Actions/PluginHostActionRegistryTests.swift` + +Steps: + +1. Bump PluginKit to v6 and convert `ActionExecutionSource` from an enum to a + string-backed `RawRepresentable` value while preserving every existing raw + string. Add `.cli` to it and to `ActionExposureSurface`. +2. Update exhaustive switches, all plugin manifests, compatibility fixtures, + generated-catalog inputs, and ABI documentation. Rebuild every plugin for the + new ABI; do not publish a mixed v5/v6 catalog. +3. Apply external invocation policy to both Run Link and CLI sources. +4. Apply `.confirmAlways` to CLI and route `.cli` exposure vetoes through the + existing repeated pre-execution checks. +5. Add a CLI-specific structured rejection only where existing rejection + categories cannot express the policy; do not duplicate the executor. +6. Test Codable raw-value compatibility plus allowed, unavailable, + confirm-always, sensitive, excluded, provider-changed, mode, and availability + transitions. + +### Task 9: Route parameterless execution and results + +**Files:** + +- Create `Sources/Core/CLI/CLIActionExecutionService.swift` +- Modify `Sources/Core/CLI/CLIHostRequestRouter.swift` +- Modify `Sources/MacToolsCLI/CLIApplication.swift` +- Modify `Sources/MacToolsCLI/CLIOutput.swift` +- Create `Tests/Core/CLI/CLIActionExecutionServiceTests.swift` +- Create `Tests/Core/CLI/CLIActionRunCommandTests.swift` + +Steps: + +1. Resolve the catalog action, choose background mode when supported, construct + source `.cli`, and invoke only `ActionExecutor`. +2. Track the request task by request ID and preserve the executor's timeout, + confirmation, provider revalidation, and concurrency behavior. +3. Use the existing confirmation router and activate only host-owned UI that is + required for the request. +4. Wait for ordinary completion. Accept `--no-wait` only for durable-progress + actions and return `started` after admission. +5. Map every executor outcome to one JSON category and documented exit code. +6. Test every risk, concurrency, timeout, cancellation, continuing-action, and + provider-change path. + +### Task 10: Propagate interrupts and bounded recursion markers + +**Files:** + +- Create `Sources/MacToolsCLI/CLISignalCoordinator.swift` +- Create `Sources/Core/CLI/CLIInvocationContext.swift` +- Modify shared command-running infrastructure where available +- Create `Tests/Core/CLI/CLICancellationTests.swift` +- Create `Tests/Core/CLI/CLIRecursionTests.swift` + +Steps: + +1. Convert SIGINT/SIGTERM to one cancellation request without doing unsafe work + directly in the signal handler. +2. Cancel validation/confirmation or a cancellable provider; report exit 8 when + a final cancellation is known. +3. Preserve durable accepted work after `started` and document uncertain + transport interruption separately. +4. Propagate an opaque active-request marker through shared host command runners + and reject a marker already active in the host. +5. Enforce connection/global capacity even when recursion is not detectable. + +--- + +## Phase 3: Typed Parameters and Distribution + +### Task 11: Parse and validate typed input + +**Files:** + +- Create `Sources/MacToolsCLI/CLIParameterInput.swift` +- Modify `Sources/MacToolsCLI/CLIArgumentParser.swift` +- Modify `Sources/Core/CLI/CLIActionExecutionService.swift` +- Create `Tests/Core/CLI/CLIParameterInputTests.swift` +- Extend `Tests/Core/CLI/CLIActionExecutionServiceTests.swift` + +Steps: + +1. Fetch the current parameter definition before parsing values. +2. Support repeated public `--parameter name=value` values with exact type + conversion and duplicate detection. +3. Support `--input-json -` and verified regular files opened with no symlink + following, current-user ownership, user-only permissions, and size bounds. +4. Reject sensitive command-line values before transport and mark the trusted + input channel in the request. +5. Revalidate schema, privacy, and values in the host, then omit all values from + outputs and logs. +6. Test shell-visible secret refusal, symlinks, ownership/mode, file replacement, + malformed JSON, non-finite numbers, duplicate names, bounds, and redaction. + +### Task 12: Add command-line-tool setup UI + +**Files:** + +- Create `Sources/App/CLISettingsSection.swift` +- Modify `Sources/App/SettingsView.swift` +- Add localized strings to the appropriate `.xcstrings` catalog + +Steps: + +1. Show broker registration and approval state in Settings using existing host + settings styles. +2. Link to the separately downloadable CLI release asset. +3. Enable or disable only the app-bundled broker integration; never write a CLI + executable, symlink, privileged path, or shell startup file. +4. Keep Chinese copy concise and cover broker registration transitions. + +### Task 13: Update build, signing, packaging, and Homebrew flow + +**Files:** + +- Modify `project.yml` +- Modify `.github/workflows/build.yml` +- Modify `.github/workflows/release.yml` +- Modify `scripts/release-local.sh` +- Modify `scripts/install-debug-app.sh` +- Modify `.github/workflows/homebrew-cask-update.yml` or the maintained cask + template/repository workflow +- Update adjacent script tests + +Steps: + +1. Build universal CLI/broker binaries, embed only the broker and its plist in + the app, and package the CLI as a separate archive. +2. Sign the standalone CLI and broker before the outer app in local and GitHub + release flows. +3. Verify exact signing identifiers, Team Identifier parity, hardened runtime, + inner/outer strict signatures, notarization, and bundle paths. +4. Ensure debug install preserves/re-registers the broker safely without touching + a release app's service. +5. Publish the version-matched CLI archive and checksum alongside the app DMG in + a Homebrew-friendly one-binary layout. +6. Notarize the app DMG and CLI archive independently and add artifact-layout + and signing-order checks. + +### Task 14: Documentation, changelog, and release verification + +**Files:** + +- Modify `README.md` +- Modify `docs/actions-automation.md` +- Create `docs/cli.md` +- Create `docs/testing/cli-e2e.md` +- Create `changes/unreleased/*.md` app changelog fragment +- Extend the existing E2E harness under `scripts/e2e/` + +Steps: + +1. Document separate installation, integration setup, all commands, JSON schema, + exit codes, sensitive input, confirmation, host startup, and uninstall behavior. +2. Add an E2E pack for cold host launch, signed handshake, discovery, one safe + parameterless action, cancellation, incompatible protocol, and redaction. +3. Run focused Swift/script tests after each task, then `make build` and the full + suite because this is cross-module infrastructure. +4. Build release-style signed app and CLI artifacts, verify role signatures, run + the standalone CLI by absolute path, and inspect both notarization results. + +--- + +## Phase 4 Candidates (Separate RFC or Follow-Up Issues) + +- versioned progress event stream with backpressure +- paginated workflow history with explicit privacy/retention rules +- Keychain-backed saved parameter references +- public third-party client requirements and compatibility promises +- MCP adapter using the same local capability contract +- dedicated CLI opt-in distinct from Run Link external policy + +These are not required to close issue #309's RFC. They should not expand +protocol version 1 without separate review. diff --git a/docs/superpowers/specs/2026-08-23-local-cli-capability-api.md b/docs/superpowers/specs/2026-08-23-local-cli-capability-api.md new file mode 100644 index 00000000..89a3cf23 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-local-cli-capability-api.md @@ -0,0 +1,743 @@ +# Local CLI and Capability API + +**Status:** Proposed decision for [issue #309](https://github.com/ggbond268/MacTools/issues/309) + +## Decision Summary + +MacTools should ship an official `mactools` command-line tool. The CLI is a +local, authenticated client of the running MacTools host; it is not a second +plugin host and it does not introduce a network API. + +The first implementation will use a user-scoped bundled LaunchAgent as a narrow +XPC broker: + +```text +signed mactools CLI + | + | versioned, mutually authenticated XPC + v +launchd-managed MacTools CLI broker + | + | versioned, mutually authenticated XPC + v +primary MacTools GUI host + | + v +ActionRegistry -> ActionExecutor -> registered provider +``` + +The following decisions are part of the first contract: + +- Canonical actions remain the sole execution domain model. +- The GUI host remains authoritative for plugins, workflows, availability, + confirmation, concurrency, timeout, cancellation, and results. +- Run Links remain supported and keep their current lightweight role. +- CLI execution initially reuses `ActionExternalInvocationPolicy` and adds a + provider veto through `ActionExposureSurface.cli`. +- The host starts without activation when the CLI needs it. UI is raised only + when the selected action or confirmation flow requires it. +- Public parameters may be supplied on the command line. Sensitive parameters + are accepted only from standard input or a tightly permissioned input file. +- The CLI ships as a separately downloadable, notarized universal archive with + the same release version and cadence as the app. The app bundles only the + broker and lets the user explicitly enable Command-Line Integration. +- The initial protocol is private to the bundled app and CLI. It is versioned, + but it is not yet a supported third-party API. + +## Product Role + +The CLI is an additional invocation and discovery surface over the action +platform described in `docs/actions-automation.md`. It is intended for local +scripts, shell aliases, developer tools, launchers, test automation, support +diagnostics, and future local agent adapters. + +It does not replace Run Links. Run Links remain the compact choice for clickable +integrations, Apple Shortcuts, launchers, and fire-and-forget commands that do +not need structured input or a result. The CLI is the choice when the caller +needs discovery, typed parameters, completion, cancellation, machine-readable +output, or stable exit behavior. + +## Goals + +- Discover the canonical actions and workflows that the current host has + actually registered. +- Describe parameter schemas, availability, and CLI eligibility without loading + plugin bundles in the CLI or broker. +- Invoke eligible actions through `ActionExecutor` and preserve every existing + policy check. +- Return bounded, versioned, redacted results in human-readable or JSON form. +- Start the installed host when necessary and wait for registry readiness with + a fixed deadline. +- Authenticate the user and code-signing identity at every process boundary. +- Survive compatible app/CLI/broker upgrade ordering and fail clearly when + versions are incompatible. + +## Non-Goals + +- TCP, HTTP, REST, WebSocket, or any other network listener +- remote control or multi-user control +- loading or inspecting plugin bundles outside the host +- arbitrary Swift, Objective-C selector, shell, or plugin-internal invocation +- replacing the GUI, Run Links, App Intents, or Apple Shortcuts +- root, privileged-helper, or system-wide installation +- plugin installation, updates, or removal from the first CLI +- headless guarantees for actions that need a graphical session +- a permanently stable public protocol for third-party clients +- progress-event streaming in protocol version 1 + +## Transport Decision + +### Selected: bundled user LaunchAgent with XPC + +The app bundles a small broker executable and a LaunchAgent property list under +`Contents/Library/LaunchAgents`. The app registers it with +`SMAppService.agent(plistName:)`. The LaunchAgent advertises one Mach service in +the current user's launchd domain and starts on demand. + +The broker has no action definitions or plugin knowledge. It performs four +jobs only: + +1. authenticate CLI and host connections; +2. negotiate the private protocol version; +3. bound and correlate request, response, and cancellation envelopes; and +4. route accepted requests to the currently registered primary host. + +The primary host opens a bidirectional connection to the broker after its action +registry and workflow provider are ready. It exports a host interface on that +connection. The broker retains the connection only while it is live and never +persists a host endpoint or action payload. + +The CLI connects to the broker's Mach service. If the service or host is not +ready, the CLI locates and launches the matching installed MacTools app through +Launch Services, then retries +the handshake until the startup deadline. The existing `CFMessagePort` single- +instance channel remains limited to launch/reopen/deep-link forwarding and is +not extended into a capability API. + +Apple documents `SMAppService` as the macOS 13+ mechanism for registering +LaunchAgents bundled in an app, and `NSXPCConnection(machServiceName:)` as the +connection form for a Mach service advertised by a LaunchAgent: + +- +- +- + +### Why the other candidates are not selected + +| Criterion | LaunchAgent XPC | GUI-owned endpoint | Unix-domain socket | +| --- | --- | --- | --- | +| Discovery | launchd Mach service | endpoint publication file | socket path | +| Host not running | broker can remain discoverable and CLI can launch host | stale/missing endpoint bootstrap | stale/missing socket bootstrap | +| Same-team validation | XPC code-signing requirement | possible, but endpoint origin still needs secure bootstrap | custom peer audit/signing validation | +| Same-user validation | user launchd domain plus peer EUID check | peer EUID check | file mode plus peer credentials | +| Cancellation | bidirectional request | bidirectional request | custom framing and state | +| Upgrade/removal | `SMAppService` registration lifecycle | custom endpoint cleanup | custom socket cleanup | +| Testability | injectable broker/host protocols | injectable endpoint store and protocols | custom server and framing harness | +| Custom security code | limited | endpoint authenticity and stale-file handling | highest | + +A GUI-owned anonymous XPC endpoint removes one process, but safely publishing, +replacing, and authenticating the endpoint recreates much of a service manager. +A Unix-domain socket provides useful streaming semantics, but protocol framing, +peer identity, file permissions, stale socket recovery, and signature checking +would all become MacTools-owned security code. The broker architecture is more +work than a prototype socket, but has the clearest lifecycle and smallest custom +trust surface. + +## Trust and Security Model + +### Process identities + +Release builds use three distinct signing identifiers under the configured +bundle prefix: + +- GUI host: `$(BUNDLE_IDENTIFIER_PREFIX).mactools` +- CLI: `$(BUNDLE_IDENTIFIER_PREFIX).mactools.cli` +- broker: `$(BUNDLE_IDENTIFIER_PREFIX).mactools.cli-broker` + +Debug builds use the corresponding `.mactools.dev` identities. The concrete +identifiers are generated into build settings; they are not inferred from an +untrusted request. + +Every accepted connection must satisfy all of these checks: + +- its effective user ID equals the broker or client's effective user ID; +- its code signature is valid and has the expected exact signing identifier; +- its Team Identifier equals the broker's Team Identifier; +- the dynamic code instance satisfies its designated requirement; and +- validation failure or unavailable identity information rejects the connection. + +The broker listener applies an exact connection code-signing requirement before +its delegate accepts a host or CLI connection. The listener then verifies EUID +and assigns the connection one fixed role based on its signing identifier. A +host connection cannot call CLI methods and a CLI connection cannot register as +the host. + +The host and CLI validate the connected broker while the connection is alive, +using the peer process identity and Security framework dynamic-code checks. On +macOS 14.4 and later, the implementation should additionally apply XPC's peer +code-signing requirement API. macOS 14.0 through 14.3 retain the explicit +Security framework validation so the project does not need to raise its current +deployment target. + +Apple's Security framework supports resolving a running guest by audit or +process attributes, and XPC can enforce peer signing requirements before +delivering messages: + +- +- +- + +### Authorization is not action admission + +A valid signature authorizes a process to speak the private protocol. It never +authorizes an action by itself. The host still resolves every reference through +`ActionRegistry` and executes only through `ActionExecutor`. + +The host must revalidate, in order: + +1. protocol and request shape; +2. catalog publication and current registration; +3. schema version and typed parameters; +4. CLI exposure and external-invocation policy; +5. current availability and execution-mode support; +6. confirmation policy; +7. provider generation, execution revision, and availability after confirmation; +8. concurrency policy; and +9. the action's configured timeout and cancellation capability. + +The existing pre-execution revalidation in `ActionExecutor` remains the final +gate. The CLI bridge must not call provider closures directly. + +### Bounds and redaction + +Protocol version 1 uses these hard bounds: + +- request envelope: 64 KiB +- typed action parameters: existing `ActionParameterSet` limits, including a + 16 KiB aggregate value limit +- response envelope: 4 MiB +- discovery page: at most 256 records; a continuation token is required for + additional records +- in-flight requests per CLI connection: 8 +- in-flight CLI requests globally: 32 +- inherited CLI invocation depth: 1 +- pending pre-admission cancellations per CLI connection: 8 +- request identifier: UUID generated by the CLI +- host startup and registry readiness: 10 seconds total + +The broker may inspect the fixed envelope header needed for routing and bounds, +but it must not log payload data. The host and CLI log only request ID, command, +action key, duration, and redacted outcome category. Parameter values never +appear in diagnostics, audit entries, JSON output, history, or error messages. +Before returning provider-owned messages, the host bridge replaces exact string +representations of supplied values with a redaction marker. Providers remain +responsible for returning user-facing messages that do not disclose secrets; +the bridge redactor is a final containment layer, not permission to echo input. + +The decoder rejects unsupported protocol versions, unknown operation names, +missing fields, duplicate fields, invalid enum values, and payloads over the +limit. Adding an optional field requires a protocol-version decision; silently +accepting unknown JSON keys is not part of the contract. + +Protocol version 1 in this document is the first unreleased wire draft; no +released MacTools build advertises it. `invocationContext` is therefore part of +the v1 schema before that schema is frozen, while its optional decoding permits +requests produced by earlier prototype clients. Earlier development-branch +brokers and hosts are not supported upgrade peers. The first public CLI release +freezes this shape: any later request-header field requires a new negotiated +protocol version, and a broker must encode only fields supported by the selected +host version. + +## Protocol Shape + +Protocol version 1 is a request/reply protocol with a separate cancellation +message. XPC carries `Data` values containing strict Codable envelopes so the +wire schema can be fuzzed and versioned independently of Swift or Objective-C +method layouts. + +Each request header contains: + +```json +{ + "protocolVersion": 1, + "requestID": "7A2775DB-ACF1-4A95-A04B-C6F41E42C277", + "operation": "actions.run", + "sentAt": "2026-08-23T18:42:30.123Z", + "invocationContext": null, + "payload": {} +} +``` + +The CLI omits `invocationContext` for a root invocation. The broker replaces it +with a newly generated opaque chain UUID and depth `0` before forwarding the +request to the host. A shared command runner may pass that chain to a child +process through `MACTOOLS_CLI_CHAIN_ID` and `MACTOOLS_CLI_CHAIN_DEPTH`, with the +depth incremented to `1`. A child CLI sends the inherited context back to the +broker. The broker rejects active chains as recursive and rejects malformed, +expired, unknown, or deeper contexts as invalid input. Chain values are routing +metadata and must not be logged or returned in command output. + +The handshake exchanges: + +- minimum and maximum supported protocol versions; +- CLI, broker, and host marketing/build versions; +- the selected protocol version; +- host registration/readiness state; and +- a redacted incompatibility or startup failure category. + +Requests are idempotent only where explicitly documented. Discovery and doctor +requests are idempotent. `actions.run` and `workflows.run` are not automatically +retried after the broker has acknowledged forwarding; an uncertain transport +result fails with `hostTransportFailure` rather than risk duplicate execution. + +The host tracks each running request by request ID. `cancel` cancels the host +task and calls the provider cancellation handler only when the canonical action +declares `.cancellable`. A cancellation that reaches the broker immediately +before request admission is retained in a per-connection bounded set and +consumed when that request arrives. After admission, the broker orders +`host.handle` before any corresponding `host.cancel`; the host also retains a +bounded cancellation received before its main-actor request task is registered. +`SIGINT` and `SIGTERM` cover the entire remote-command lifecycle, including host +startup and parameter-schema discovery. They are handled once per CLI request, +restore the process's prior signal dispositions afterward, and wait for +cancellation forwarding before the CLI emits exit `8`. Disconnecting a CLI +cancels requests that have not yet been durably accepted. A continuing action +that has already returned `started` keeps its host-owned progress UI and is not +canceled by client disconnect. + +Fine-grained progress is deferred. Protocol version 1 reports only accepted, +started, and terminal states. + +## Action Exposure and Eligibility + +Add these model values to `MacToolsPluginKit`: + +```swift +public extension ActionExposureSurface { + static let cli = ActionExposureSurface(rawValue: "cli") +} + +public extension ActionExecutionSource { + static let cli = ActionExecutionSource(rawValue: "cli") +} +``` + +`ActionExecutionSource` is currently a public enum in PluginKit v5. Phase 2 must +therefore introduce PluginKit v6, rebuild every plugin, and convert the source +to a string-backed `RawRepresentable` value like `ActionExposureSurface`. Its +existing raw strings remain unchanged. This is one intentional ABI migration +that allows future host-owned invocation sources to be added without repeatedly +making provider switches exhaustive. + +An action is visible in CLI discovery when it has a published catalog entry. +Visibility does not imply that the action can run. `actions list` includes +unavailable and excluded actions with a structured `cliEligibility` state; +`--runnable` filters them for interactive use. An unpublished provider action +is not exposed. + +An action can run from the CLI only when all of the following are true: + +- its catalog entry is published and currently registered; +- its reference migrates to the current schema and all parameters validate; +- its availability is currently true; +- the host can choose a supported execution mode; +- `externalInvocationPolicy` is not `.unavailable`; +- the provider's policy for `ActionExposureSurface.cli` is not `.excluded`; +- sensitive values arrived through an allowed input path; and +- the normal confirmation, concurrency, provider-generation, and timeout gates + succeed. + +Version 1 deliberately reuses `ActionExternalInvocationPolicy` as the broad +external opt-in. `.confirmAlways` applies to both Run Links and CLI requests. +`ActionExposureSurface.cli` lets a provider narrow that opt-in without creating +a second broad policy. A future CLI-only opt-in can be added only after real +actions demonstrate a need to be callable by a signed local client but not by a +Run Link. + +The host prefers background execution when the action supports it, otherwise it +uses foreground execution. Foreground-only or confirmation-required actions +may activate the host to present UI. If there is no graphical login session or +the host cannot present confirmation, execution fails; it is never approved +silently. + +`--no-wait` is accepted only for an action that publishes durable progress with +`.reportsProgress`. It returns `started` after validation and confirmation. For +all other actions it is invalid input. Ordinary actions wait for a terminal +result and keep the action's configured timeout. + +## Sensitive Parameter Input + +`--parameter name=value` accepts public parameters only. Before sending an +invocation, the CLI fetches the current description and rejects this syntax for +any parameter whose privacy is `.sensitive`. + +`--input-json -` reads typed values from standard input. A file path is accepted +only when the CLI can open it without following a symlink and `fstat` confirms +that it is: + +- a regular file; +- owned by the current effective user; +- inaccessible to group and other users (no `0077` permission bits); and +- within the request-size bound. + +Before decoding JSON values, the CLI fetches the current action description and +uses its parameter schema as the type authority. Boolean, integer, number, and +string values cannot cross-convert merely because Foundation bridges their +runtime representations; integer bounds are checked exactly. + +The file path may appear in process arguments, but its contents do not. The CLI +reads once from the verified descriptor and does not reopen by path. Sensitive +values are held only for request encoding and are cleared with the request +lifetime as far as Swift value semantics allow. A future host-owned secure +prompt or Keychain preset can extend this model without putting secrets in +arguments. + +The host independently checks parameter privacy metadata. It never returns an +invocation reference containing parameter values. Public and sensitive values +are both omitted from stdout JSON, logs, diagnostics, workflow history, and +optional audit entries. + +## Command Contract + +The first released CLI includes: + +```text +mactools help +mactools version [--json] +mactools doctor [--json] + +mactools actions list [--runnable] [--json] +mactools actions describe [--json] +mactools actions availability [--json] +mactools actions run [--parameter name=value ...] + [--input-json ] [--no-wait] [--json] + +mactools workflows list [--json] +mactools workflows describe [--json] +mactools workflows run [--no-wait] [--json] + +mactools plugins list [--json] +mactools plugins describe [--json] +mactools plugins doctor [--json] +``` + +`workflows run` resolves the name or UUID to the workflow's canonical +`automation/workflow.` action and invokes it through `ActionExecutor`. +Workflow list and describe are convenience discovery operations over the host's +workflow store and published actions; they do not create a second execution +path. Ambiguous workflow names fail and print the matching IDs. + +Plugin commands are read-only snapshots from `PluginHost`. `plugins doctor` +reports installation, compatibility, trust, load, permission, and action- +provider status. Installation, update, enable/disable, and removal are excluded. + +`workflows history` is deferred until the history privacy and pagination +contract is designed in a later protocol version. Progress streaming and MCP +are also deferred. + +Human-readable output is concise, localized using the current process locale, +and written to stdout on success. Diagnostics and errors go to stderr. JSON mode +emits exactly one UTF-8 JSON object to stdout and no decorative text. + +`mactools version` always reports the standalone CLI version without launching +the host. If a broker and host are already reachable it also +reports their selected protocol and runtime builds. `mactools doctor` performs +the active registration, launch, identity, readiness, and compatibility checks. + +## JSON Output Contract + +Every `--json` response uses this top-level envelope: + +```json +{ + "schemaVersion": 1, + "protocolVersion": 1, + "requestID": "7A2775DB-ACF1-4A95-A04B-C6F41E42C277", + "command": "actions.run", + "actionReference": { + "providerID": "display.brightness", + "actionID": "increase", + "schemaVersion": 1 + }, + "invocationSource": "cli", + "startedAt": "2026-08-23T18:42:30.123Z", + "finishedAt": "2026-08-23T18:42:30.287Z", + "outcome": "completed", + "message": "Brightness increased.", + "rejection": null, + "data": null +} +``` + +The stable version-1 outcome values are: + +- `completed` +- `started` +- `cancelled` +- `unavailable` +- `confirmationDenied` +- `timedOut` +- `invalidInput` +- `unknownTarget` +- `failed` +- `hostUnavailable` +- `providerChanged` +- `protocolIncompatible` + +`rejection`, when present, has a stable `category` plus an optional redacted, +user-facing `message`. It never contains a raw Swift error, provider object, +path from a sensitive value, or parameter value. `data` contains a command- +specific versioned object for discovery and doctor commands. List responses +include a continuation token when another page exists. + +Timestamps use UTC RFC 3339 with millisecond precision. `finishedAt` is `null` +only for `started`. `actionReference` is omitted for commands that do not target +an action and never includes parameters. + +## Exit Codes + +These numeric categories are stable for CLI major version 1: + +| Code | Category | Examples | +| ---: | --- | --- | +| 0 | success | completed discovery/action, or durable work accepted as `started` | +| 2 | invalid command or input | usage, duplicate parameter, wrong type, insecure input file | +| 3 | unknown target | unknown action, workflow, or plugin | +| 4 | unavailable | unavailable action, unsupported mode, external/CLI exposure excluded, concurrency rejection | +| 5 | confirmation failure | confirmation denied or UI unavailable | +| 6 | action failure | provider failure or provider changed during admission/execution | +| 7 | timeout | confirmation or action execution timeout | +| 8 | cancellation | caller interrupt or provider cancellation | +| 9 | host or transport failure | app missing, startup failure, broker unavailable, authentication failure, uncertain delivery | +| 10 | protocol incompatibility | no overlapping protocol version or invalid peer contract | + +Shell parse failures happen before a request ID exists. JSON mode still returns +the same envelope with a locally generated request ID and `protocolVersion: null` +when negotiation never occurred. + +## Host Lifecycle + +For discovery, doctor, and execution commands, startup proceeds as follows: + +1. Derive the expected host identifier from the signed CLI role identifier. +2. Query Launch Services for the installed release or debug app. +3. Verify the app's signing identifier and Team Identifier before launching it. +4. Connect to the broker and negotiate. If the broker is not registered or the + host is absent, launch the app without activation and retry. +5. The existing single-instance coordinator ensures only the primary host + initializes the runtime. +6. The primary host gives every `PluginActionCatalogPreparing` provider a + bounded preparation window, then synchronously rebuilds `ActionRegistry` and + publishes workflow actions before registering with the broker. A stalled + provider is logged and omitted from the prepared snapshot. Readiness does not + depend on a fixed delay; if the provider later completes normally, the Host + republishes that provider without requiring a restart. +7. Retry with jitter inside one 10-second deadline. Do not reset the deadline + after a partial handshake. +8. Return a precise startup, approval-required, authentication, or compatibility + error on failure. + +If `SMAppService` reports that user approval is required, `doctor` identifies +the broker as disabled and gives the System Settings path. The implementation +does not fall back to an unauthenticated socket or the instance-coordination +port. + +No graphical login session means discovery may proceed if the host is already +available, but launching the GUI host or presenting confirmation can fail with +a clear host/confirmation category. + +## Distribution and Shell Path + +The signed universal CLI is published as a separate release asset: + +```text +mactools-cli--macos-universal.zip +└── mactools +``` + +The CLI embeds its own version and role identifier, contains no app resources or +PluginKit dependency, and locates the installed GUI host through Launch Services. +The broker remains an auxiliary executable referenced by the app-bundled +LaunchAgent property list. + +Distribution behavior is: + +- Each app release uploads a version-matched CLI archive and checksum. The + archive layout can be consumed directly by a future `mactools-cli` Homebrew + cask without coupling it to the app cask. +- Settings links to the release download and independently enables or disables + the bundled broker integration. It never writes a CLI executable or symlink. +- Users may install the executable in `~/.local/bin`, another directory on + `PATH`, or invoke its absolute path. MacTools does not mutate shell startup + files. +- No privileged helper and no `/usr/local/bin` write are introduced. + +Release signing signs the standalone CLI and broker with exact role identifiers, +then signs the outer app. CI separately notarizes the CLI archive and app DMG. + +## Upgrade and Failure Behavior + +CLI, broker, and host advertise minimum and maximum supported protocol versions. +They select the highest overlapping version. No overlap returns exit code 10 +without forwarding a command. + +The three binaries can be upgraded at different times. The following edge cases +remain defined: + +- A CLI with no installed MacTools app fails with setup guidance. +- An old CLI pointed at a newer registered broker can proceed only when their + ranges overlap. +- A running old broker is drained and restarted after app update registration; + active requests receive their existing reply or a transport failure, never an + automatic execution retry. +- A newer host may speak an older selected protocol but must omit behavior not + representable in that version. +- A broker crash loses only routing state. The host reconnects and re-registers; + the CLI reports uncertain in-flight execution rather than retrying it. +- Unregistering the broker disables new CLI requests. App removal leaves no + privileged file and launchd eventually removes the invalid bundled service; + an explicit uninstall action should call `unregister()` first when possible. + +## Confirmation and UI Ownership + +All confirmation UI is host-owned and uses the same confirmation router as +other canonical action surfaces. For a CLI request, the host may activate only +the confirmation window and return focus afterward when macOS permits. The CLI +prints “Waiting for confirmation in MacTools…” in human mode and remains silent +in JSON mode until the final object. + +The exact action reference, source `.cli`, and confirmation text are captured +before presentation. `ActionExecutor` revalidates the provider generation, +execution revision, definition, exposure policy, and availability after the +response. Missing UI, host lock, or inability to order a window fails closed. + +## Audit and Recursion + +Protocol version 1 may record a bounded, redacted host audit event containing: + +- request ID; +- action key without parameters; +- `.cli` source; +- start/finish timestamps; +- outcome category; and +- CLI and host build versions. + +Audit storage is off by default until its retention UI is designed. It never +contains parameter values. + +The host caps total CLI depth and active CLI requests. Command-running providers +that use a shared host runner should propagate an opaque active-request chain +marker to child processes; a child `mactools` invocation presenting an already +active marker is rejected as recursive. Providers that do not launch through a +shared runner may not be detectable, so canonical action concurrency and global +capacity limits remain the final guard. + +## Testing Requirements + +Tests use injected identity validators, launchers, clocks, broker transports, +and host bridges. They must not depend on the developer's real LaunchAgent or +modify the user's shell path. + +### Protocol and security + +- exact and overlapping version negotiation +- unsupported version and unknown-field rejection +- request/response/page/in-flight limits +- wrong user, unsigned client, wrong Team Identifier, and wrong signing ID +- CLI attempting host registration and host attempting CLI methods +- invalid or unavailable identity information fails closed +- sensitive-value redaction in logs, JSON, doctor output, and audit models +- malformed, oversized, duplicate-field, and fuzzed envelopes +- malformed, expired, unknown, over-depth, and active recursive chain markers + +### Lifecycle and upgrades + +- app missing, invalid signature, and Launch Services mismatch +- broker unregistered, approval required, and broker startup timeout +- host not running, host launch failure, and registry startup delay +- secondary host never registers as authoritative +- broker crash/reconnect and stale host connection removal +- old/new CLI, broker, and host version matrices +- no retry after uncertain `actions.run` delivery +- unregister and moved-app behavior + +### Discovery and execution + +- catalog action visible but unavailable or CLI-excluded +- unknown action and schema migration failure +- public and sensitive typed parameters from every allowed input form +- sensitive value refused in arguments and insecure/symlink input file rejected +- permission and availability failures +- confirmation approval, denial, timeout, and unavailable UI +- provider generation or availability changing during confirmation +- each concurrency policy +- ordinary completion, durable `started`, provider failure, timeout, and cancel +- interrupt before admission, during confirmation, and during cancellable work +- signal-handler restoration and one cancellation message per interrupted request +- workflow name ambiguity and canonical action execution +- plugin diagnostic snapshots with unloaded or incompatible plugins +- JSON schema snapshots and every exit-code mapping + +### Release and distribution + +- standalone CLI artifact and broker bundle path are both present +- standalone CLI and broker are signed with exact same-team role identifiers +- release archive contains the LaunchAgent plist +- standalone CLI archive contains only an executable named `mactools` +- app and CLI archives are notarized independently +- debug and release signing identifiers remain distinct + +## Rollout + +### Phase 0: transport and security spike + +Build the protocol module, broker, identity validator, handshake, host launch, +host registration/readiness, and cancellation proof. Validate signed release- +style binaries as well as debug behavior on macOS 14.0 and 14.4+. Do not merge +execution until wrong-team, wrong-user, stale-client, and broker-restart tests +pass. + +### Phase 1: read-only discovery + +Ship `help`, `version`, `doctor`, action list/describe/availability, workflow +list/describe, and plugin list/describe/doctor with human and JSON output. + +### Phase 2: conservative execution + +Introduce PluginKit v6, migrate execution sources to a string-backed contract, +add `.cli` source/surface, and rebuild the plugin catalog. Then add parameterless +externally eligible action execution, host-owned confirmation, waiting, durable +acceptance, exit codes, interrupts, and cancellation. + +### Phase 3: typed parameters and integration UI + +Add public `--parameter`, stdin/restricted-file JSON, sensitive metadata checks, +separate CLI packaging, release signing changes, and broker integration controls. + +### Phase 4: richer integrations + +Consider progress events, workflow history, Keychain preset references, a public +local SDK, and an MCP adapter only after protocol-1 security and compatibility +data are available. + +## Resolved RFC Questions + +- **Should MacTools ship a CLI?** Yes, as a local action client. +- **Who owns the listener?** A bundled user LaunchAgent owns the named XPC + listener; the GUI host owns action state and execution. +- **Does CLI eligibility reuse Run Link policy?** Yes for the broad version-1 + external opt-in, with a separate `.cli` exposure veto. +- **Does the CLI launch MacTools?** Yes by default, without activation, within a + 10-second total startup deadline. +- **Which actions are visible but not runnable?** Published catalog actions may + be visible with structured eligibility reasons; unpublished actions are + absent. +- **Are workflow conveniences separate execution operations?** No. Discovery + may query workflow metadata, but execution resolves and runs the canonical + workflow action. +- **How are secrets transported?** Standard input or a verified user-only input + file; never ordinary arguments or URLs. +- **How is the binary installed?** Downloaded as a separately signed and + notarized release asset, then placed in a user-owned `PATH` directory without + privilege. The app installs and controls only its broker. +- **Is this a remote or public API commitment?** No. diff --git a/docs/testing/cli-e2e.md b/docs/testing/cli-e2e.md new file mode 100644 index 00000000..b185f3af --- /dev/null +++ b/docs/testing/cli-e2e.md @@ -0,0 +1,40 @@ +# CLI end-to-end verification + +The mutual-authentication path requires a normally signed Debug or Release app; +an unsigned XCTest host cannot register an `SMAppService` LaunchAgent. + +1. Configure `LocalConfig.xcconfig`, then run `make build-plugin` and `make run`. +2. Build the standalone client with `make build-cli`, copy + `build/DerivedData/Build/Products/Debug/MacToolsCLI` to a stable path named + `mactools`, then enable Settings > General > Command Line. Approve the + background item if macOS requests it. +3. Quit MacTools, then run the absolute path to `mactools doctor --json`. Verify that the + app cold-starts, the protocol is `1`, no Settings window is activated, and + the first action count matches an immediate `actions list` response without + waiting or retrying. +4. Run `actions list --runnable`, describe one safe parameterless action, and + execute it. Verify that the same result/history appears in MacTools. +5. Send Control-C immediately after starting a request, while a confirmation is + visible, and while a cancellable provider is running. Each request must emit + one cancellation result with exit `8`; dismissing a later confirmation must + not start the action. Run another CLI command afterward to verify the prior + signal handlers were restored without leaving the CLI transport stuck. +6. Pass a distinctive secret through `--input-json -`, force a provider error, + and verify the value does not appear in JSON, Console, or diagnostics. +7. Inspect nested signatures and the LaunchAgent: + +```bash +APP="$HOME/Applications/MacTools Dev.app" +CLI="/absolute/path/to/mactools" +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -dv --verbose=4 "$CLI" +codesign -dv --verbose=4 "$APP/Contents/MacOS/MacToolsCLIBroker" +plutil -p "$APP/Contents/Library/LaunchAgents/app.ggbond.MacTools.cli-broker.plist" +``` + +The host, standalone CLI, and broker signing identifiers must end in `.mactools.dev`, `.mactools.dev.cli`, and +`.mactools.dev.cli-broker`, with matching non-empty Team identifiers. + +8. Configure a Saved Script that invokes the same `mactools` path. Start that + script from the CLI and verify the nested command is rejected with category + `recursiveInvocation` while the parent request completes normally. diff --git a/project.yml b/project.yml index ea64f7a6..4c345ea4 100644 --- a/project.yml +++ b/project.yml @@ -68,9 +68,14 @@ targets: - "**/.gitkeep" - MacToolsPluginKit/** - MacToolsAppIntents/** + - MacToolsCLI/** + - MacToolsCLIBroker/** - Extensions/** - "../Plugins/**" - Plugins/**/CalendarPluginResources/** + - path: Sources/MacToolsCLI/CLIArgumentParser.swift + - path: Sources/MacToolsCLI/CLIParameterInput.swift + - path: Sources/MacToolsCLI/CLISignalCoordinator.swift dependencies: - target: MacToolsPluginKit embed: true @@ -78,6 +83,12 @@ targets: embed: true - target: RightClickFinderSync embed: true + - target: MacToolsCLI + link: false + embed: false + - target: MacToolsCLIBroker + link: false + embed: false - package: Sparkle settings: base: @@ -110,6 +121,95 @@ targets: DEAD_CODE_STRIPPING: true DEPLOYMENT_POSTPROCESSING: true STRIP_INSTALLED_PRODUCT: true + postBuildScripts: + - name: Embed MacTools CLI broker + script: | + set -euo pipefail + app_macos_dir="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/MacOS" + launch_agents_dir="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Library/LaunchAgents" + mkdir -p "$app_macos_dir" "$launch_agents_dir" + ditto "$BUILT_PRODUCTS_DIR/MacToolsCLIBroker" "$app_macos_dir/MacToolsCLIBroker" + service_name="$PRODUCT_BUNDLE_IDENTIFIER.cli-broker" + sed "s/__MACTOOLS_CLI_SERVICE_NAME__/$service_name/g" \ + "$SRCROOT/Configs/MacToolsCLIBroker-LaunchAgent.plist" \ + > "$launch_agents_dir/app.ggbond.MacTools.cli-broker.plist" + inputFiles: + - "$(BUILT_PRODUCTS_DIR)/MacToolsCLIBroker" + - "$(SRCROOT)/Configs/MacToolsCLIBroker-LaunchAgent.plist" + outputFiles: + - "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/MacOS/MacToolsCLIBroker" + - "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Library/LaunchAgents/app.ggbond.MacTools.cli-broker.plist" + MacToolsCLI: + type: tool + platform: macOS + deploymentTarget: '14.0' + configFiles: + Debug: Configs/AppDebug.xcconfig + Release: Configs/AppRelease.xcconfig + sources: + - path: Sources/Core/CLI/CLIProtocolModels.swift + - path: Sources/Core/CLI/CLIProtocolCodec.swift + - path: Sources/Core/CLI/CLIServiceConfiguration.swift + - path: Sources/Core/CLI/CLIHostLocator.swift + - path: Sources/Core/CLI/CLIHostDiscovery.swift + - path: Sources/Core/CLI/CLIHostApplicationLauncher.swift + - path: Sources/Core/CLI/CLIXPCProtocols.swift + - path: Sources/Core/CLI/CLIPeerIdentityValidator.swift + - path: Sources/Core/CLI/CLIRequestLifecycleState.swift + - path: Sources/MacToolsCLI + settings: + base: + PRODUCT_NAME: MacToolsCLI + PRODUCT_MODULE_NAME: MacToolsCLI + PRODUCT_BUNDLE_IDENTIFIER: "$(BUNDLE_IDENTIFIER_PREFIX).mactools.cli" + GENERATE_INFOPLIST_FILE: true + CREATE_INFOPLIST_SECTION_IN_BINARY: true + SWIFT_VERSION: 6.0 + MACOSX_DEPLOYMENT_TARGET: '14.0' + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" + OTHER_CODE_SIGN_FLAGS: "--identifier $(PRODUCT_BUNDLE_IDENTIFIER)" + configs: + Debug: + PRODUCT_BUNDLE_IDENTIFIER: "$(BUNDLE_IDENTIFIER_PREFIX).mactools.dev.cli" + Release: + DEAD_CODE_STRIPPING: true + DEPLOYMENT_POSTPROCESSING: true + STRIP_INSTALLED_PRODUCT: true + MacToolsCLIBroker: + type: tool + platform: macOS + deploymentTarget: '14.0' + configFiles: + Debug: Configs/AppDebug.xcconfig + Release: Configs/AppRelease.xcconfig + sources: + - path: Sources/Core/CLI/CLIProtocolModels.swift + - path: Sources/Core/CLI/CLIProtocolCodec.swift + - path: Sources/Core/CLI/CLIServiceConfiguration.swift + - path: Sources/Core/CLI/CLIXPCProtocols.swift + - path: Sources/Core/CLI/CLIPeerIdentityValidator.swift + - path: Sources/Core/CLI/CLIRequestAdmissionState.swift + - path: Sources/MacToolsCLIBroker + settings: + base: + PRODUCT_NAME: MacToolsCLIBroker + PRODUCT_MODULE_NAME: MacToolsCLIBroker + PRODUCT_BUNDLE_IDENTIFIER: "$(BUNDLE_IDENTIFIER_PREFIX).mactools.cli-broker" + GENERATE_INFOPLIST_FILE: true + CREATE_INFOPLIST_SECTION_IN_BINARY: true + SWIFT_VERSION: 6.0 + MACOSX_DEPLOYMENT_TARGET: '14.0' + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "$(DEVELOPMENT_TEAM)" + OTHER_CODE_SIGN_FLAGS: "--identifier $(PRODUCT_BUNDLE_IDENTIFIER)" + configs: + Debug: + PRODUCT_BUNDLE_IDENTIFIER: "$(BUNDLE_IDENTIFIER_PREFIX).mactools.dev.cli-broker" + Release: + DEAD_CODE_STRIPPING: true + DEPLOYMENT_POSTPROCESSING: true + STRIP_INSTALLED_PRODUCT: true RightClickFinderSync: type: app-extension platform: macOS diff --git a/scripts/e2e/mactools-e2e.sh b/scripts/e2e/mactools-e2e.sh index c74f183d..e81959f7 100755 --- a/scripts/e2e/mactools-e2e.sh +++ b/scripts/e2e/mactools-e2e.sh @@ -149,6 +149,8 @@ for raw in sorted(path for path in paths if path): if os.path.islink(path): digest.update(b"L\0") digest.update(os.readlink(path).encode("utf-8", "surrogateescape")) + elif not os.path.exists(path): + digest.update(b"D\0") else: digest.update(b"F\0") with open(path, "rb") as handle: diff --git a/scripts/package-cli.sh b/scripts/package-cli.sh new file mode 100755 index 00000000..8124986c --- /dev/null +++ b/scripts/package-cli.sh @@ -0,0 +1,54 @@ +#!/bin/zsh + +set -euo pipefail + +function usage() { + cat <<'EOF' +Usage: scripts/package-cli.sh --binary --output + +Packages a built MacToolsCLI executable as a standalone archive whose root +contains one executable named `mactools`. +EOF +} + +BINARY_PATH="" +OUTPUT_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --binary) + BINARY_PATH="${2:-}" + shift 2 + ;; + --output) + OUTPUT_PATH="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$BINARY_PATH" ]] || { echo "--binary is required" >&2; exit 1; } +[[ -n "$OUTPUT_PATH" ]] || { echo "--output is required" >&2; exit 1; } +[[ -x "$BINARY_PATH" ]] || { echo "CLI binary is not executable: $BINARY_PATH" >&2; exit 1; } + +mkdir -p "$(dirname "$OUTPUT_PATH")" +OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_PATH")" && pwd)/$(basename "$OUTPUT_PATH")" +STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mactools-cli-package.XXXXXX")" +trap '/bin/rm -rf "$STAGE_DIR"' EXIT + +ditto "$BINARY_PATH" "$STAGE_DIR/mactools" +chmod 755 "$STAGE_DIR/mactools" +xattr -c "$STAGE_DIR/mactools" +rm -f "$OUTPUT_PATH" +/usr/bin/zip -X -j -q "$OUTPUT_PATH" "$STAGE_DIR/mactools" + +[[ -s "$OUTPUT_PATH" ]] || { echo "CLI archive was not created: $OUTPUT_PATH" >&2; exit 1; } diff --git a/scripts/plugins/generate-plugin-project-config.rb b/scripts/plugins/generate-plugin-project-config.rb index b2b0efaa..96f5cbb2 100755 --- a/scripts/plugins/generate-plugin-project-config.rb +++ b/scripts/plugins/generate-plugin-project-config.rb @@ -379,7 +379,9 @@ def write_yaml(io, value, indent = 0) { "target" => "MacToolsPluginKit" }, { "target" => "MacToolsAppIntents" }, { "target" => "AppInstanceProbe" }, - { "target" => "AppIntentCircuitBreakerProbe" } + { "target" => "AppIntentCircuitBreakerProbe" }, + { "target" => "MacToolsCLI" }, + { "target" => "MacToolsCLIBroker" } ] + plugin_core_targets.map { |target| { "target" => target } }, "settings" => test_settings } @@ -391,7 +393,9 @@ def write_yaml(io, value, indent = 0) "MacToolsPluginKit" => "all", "MacToolsAppIntents" => "all", "AppInstanceProbe" => ["test"], - "AppIntentCircuitBreakerProbe" => ["test"] + "AppIntentCircuitBreakerProbe" => ["test"], + "MacToolsCLI" => "all", + "MacToolsCLIBroker" => "all" }.merge(plugin_bundle_targets.to_h { |target| [target, "all"] }) .merge( "MacTools" => "all", diff --git a/scripts/release-local.sh b/scripts/release-local.sh index ea9c8c8f..44301438 100755 --- a/scripts/release-local.sh +++ b/scripts/release-local.sh @@ -42,7 +42,7 @@ Options: --tag Git tag / release tag. Defaults to v. --notes-file Release notes file for GitHub Release upload. --publish Push the tag and sync the DMG to GitHub Releases. - --publish-existing Upload the existing DMG artifact without rebuilding. + --publish-existing Upload the existing DMG and CLI artifacts without rebuilding. --skip-build Reuse the existing Release app in build/DerivedData. --skip-sign Skip Developer ID signing. Implies --skip-notarize. --skip-notarize Skip notarization and stapling. @@ -358,6 +358,42 @@ function sign_path() { "$path" } +function sign_path_with_identifier() { + local path="$1" + local identifier="$2" + /usr/bin/codesign \ + --force \ + --sign "$DEVELOPER_ID_APPLICATION" \ + --options runtime \ + --timestamp \ + --identifier "$identifier" \ + "$path" +} + +function signing_detail() { + local path="$1" + local key="$2" + /usr/bin/codesign -dvvv "$path" 2>&1 \ + | awk -F= -v key="$key" '$1 == key { print substr($0, length(key) + 2); exit }' +} + +function validate_cli_role_signature() { + local path="$1" + local expected_identifier="$2" + local expected_team="$3" + local details + local actual_identifier + local actual_team + details="$(/usr/bin/codesign -dvvv "$path" 2>&1)" + actual_identifier="$(signing_detail "$path" Identifier)" + actual_team="$(signing_detail "$path" TeamIdentifier)" + [[ "$actual_identifier" == "$expected_identifier" ]] \ + || fail "签名标识不匹配:$path(期望 $expected_identifier,实际 $actual_identifier)" + [[ -n "$expected_team" && "$actual_team" == "$expected_team" ]] \ + || fail "签名 Team Identifier 不匹配:$path" + [[ "$details" == *"runtime"* ]] || fail "签名未启用 hardened runtime:$path" +} + function sign_path_preserving_entitlements() { local path="$1" /usr/bin/codesign \ @@ -472,6 +508,12 @@ function dmg_signing_identifier() { function sign_app_bundle() { local app_path="$1" + local broker_path="$app_path/Contents/MacOS/MacToolsCLIBroker" + + [[ -x "$broker_path" ]] || fail "未找到命令行代理:$broker_path" + local host_identifier + host_identifier="$(app_bundle_identifier "$app_path")" + sign_path_with_identifier "$broker_path" "$host_identifier.cli-broker" if [[ -d "$app_path/Contents" ]]; then while IFS= read -r binary; do @@ -496,7 +538,23 @@ function sign_app_bundle() { sign_app_path "$app_path" validate_finder_sync_extension "$app_path" + /usr/bin/codesign --verify --strict --verbose=2 "$broker_path" /usr/bin/codesign --verify --deep --strict --verbose=2 "$app_path" + local host_team + host_team="$(signing_detail "$app_path" TeamIdentifier)" + validate_cli_role_signature "$broker_path" "$host_identifier.cli-broker" "$host_team" +} + +function sign_cli_binary() { + local cli_path="$1" + local host_identifier="$2" + local cli_team + + [[ -x "$cli_path" ]] || fail "未找到独立命令行工具:$cli_path" + sign_path_with_identifier "$cli_path" "$host_identifier.cli" + /usr/bin/codesign --verify --strict --verbose=2 "$cli_path" + cli_team="$(signing_detail "$cli_path" TeamIdentifier)" + validate_cli_role_signature "$cli_path" "$host_identifier.cli" "$cli_team" } function sign_disk_image() { @@ -570,16 +628,37 @@ function notarize_dmg() { xcrun stapler staple -v "$dmg_path" } -function validate_notarized_dmg() { - local dmg_path="$1" - local assessment_output +function notarize_cli_archive() { + local archive_path="$1" + [[ -n "${APPLE_NOTARY_PROFILE:-}" ]] || fail "缺少 APPLE_NOTARY_PROFILE,无法公证。" + + info "Submitting standalone CLI for notarization" + xcrun notarytool submit "$archive_path" --keychain-profile "$APPLE_NOTARY_PROFILE" --wait +} - assessment_output="$(spctl -a -t open --context context:primary-signature -v "$dmg_path" 2>&1)" \ - || fail "Gatekeeper 未接受最终 DMG: -$assessment_output" +function validate_release_artifacts() { + local arguments + arguments=( + --cli-archive "$CLI_ARCHIVE_PATH" + --dmg "$DMG_PATH" + --version "$VERSION" + --build "$BUILD_NUMBER" + ) + if [[ "$SKIP_SIGN" -eq 1 ]]; then + arguments+=(--allow-unsigned) + fi + if [[ "$SKIP_NOTARIZE" -eq 0 ]]; then + arguments+=(--gatekeeper) + fi + "$ROOT_DIR/scripts/validate-release-artifacts.sh" "${arguments[@]}" +} - info "Gatekeeper assessment passed" - printf '%s\n' "$assessment_output" +function verify_existing_checksum() { + local checksum_path="$1" + ( + cd "$(dirname "$checksum_path")" + /usr/bin/shasum -a 256 -c "$(basename "$checksum_path")" + ) || fail "现有发布文件的校验和不匹配:$checksum_path" } function require_existing_dmg() { @@ -594,6 +673,9 @@ function require_existing_dmg() { function publish_release() { local dmg_path="$1" + local cli_archive_path="$2" + local dmg_sha256_path="$3" + local cli_sha256_path="$4" local repository repository="$(git_repository)" || fail "无法推断 GitHub 仓库,请在 scripts/release.local.env 中设置 GITHUB_REPOSITORY。" @@ -624,10 +706,14 @@ function publish_release() { fi if gh release view "$TAG" --repo "$repository" >/dev/null 2>&1; then - gh release upload "$TAG" "$dmg_path" --repo "$repository" --clobber + gh release upload "$TAG" \ + "$dmg_path" "$dmg_sha256_path" "$cli_archive_path" "$cli_sha256_path" \ + --repo "$repository" --clobber gh release edit "$TAG" "${release_args[@]}" else - gh release create "$TAG" "$dmg_path" "${release_args[@]}" + gh release create "$TAG" \ + "$dmg_path" "$dmg_sha256_path" "$cli_archive_path" "$cli_sha256_path" \ + "${release_args[@]}" fi } @@ -644,18 +730,28 @@ NOTES_FILE="${NOTES_FILE:-${GITHUB_RELEASE_NOTES_FILE:-}}" [[ -n "$VERSION" ]] || fail "无法从 Configs/AppVersion.xcconfig 读取 MARKETING_VERSION。" [[ -n "$BUILD_NUMBER" ]] || fail "无法从 Configs/AppVersion.xcconfig 读取 CURRENT_PROJECT_VERSION。" +[[ "$TAG" == "v$VERSION" ]] \ + || fail "发布标签必须与版本一致:期望 v$VERSION,实际 $TAG。" if [[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]]; then fail "Release notes 文件不存在:$NOTES_FILE" fi if [[ "$PUBLISH" -eq 1 && "$PUBLISH_EXISTING" -eq 1 ]]; then fail "--publish 和 --publish-existing 不能同时使用。" fi +if [[ "$PUBLISH" -eq 1 || "$PUBLISH_EXISTING" -eq 1 ]]; then + [[ "$SKIP_SIGN" -eq 0 && "$SKIP_NOTARIZE" -eq 0 ]] \ + || fail "发布到 GitHub 时不能跳过签名或公证。" +fi ARTIFACT_DIR="$ROOT_DIR/build/release/$TAG" DERIVED_DATA="$ROOT_DIR/build/DerivedData" APP_PATH="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app" +CLI_PATH="$DERIVED_DATA/Build/Products/Release/MacToolsCLI" SIGNED_APP_PATH="$ARTIFACT_DIR/$APP_NAME.app" DMG_PATH="$ARTIFACT_DIR/$APP_NAME.dmg" +DMG_SHA256_PATH="$ARTIFACT_DIR/$APP_NAME.sha256" +CLI_ARCHIVE_PATH="$ARTIFACT_DIR/mactools-cli-$VERSION-macos-universal.zip" +CLI_SHA256_PATH="$ARTIFACT_DIR/mactools-cli-$VERSION-macos-universal.sha256" DMG_IDENTIFIER="" DOCS_DIR="$ROOT_DIR/docs" APPCAST_PATH="$DOCS_DIR/appcast.xml" @@ -665,7 +761,12 @@ mkdir -p "$ARTIFACT_DIR" if [[ "$PUBLISH_EXISTING" -eq 1 ]]; then require_existing_dmg "$DMG_PATH" - validate_notarized_dmg "$DMG_PATH" + [[ -f "$CLI_ARCHIVE_PATH" ]] || fail "未找到现有独立 CLI:$CLI_ARCHIVE_PATH" + [[ -f "$DMG_SHA256_PATH" ]] || fail "未找到现有 DMG 校验和:$DMG_SHA256_PATH" + [[ -f "$CLI_SHA256_PATH" ]] || fail "未找到现有 CLI 校验和:$CLI_SHA256_PATH" + verify_existing_checksum "$DMG_SHA256_PATH" + verify_existing_checksum "$CLI_SHA256_PATH" + validate_release_artifacts else if [[ "$SKIP_BUILD" -eq 0 ]]; then build_release_app @@ -674,19 +775,35 @@ else [[ -d "$APP_PATH" ]] || fail "未找到 Release app:$APP_PATH" info "Preparing artifact directory $ARTIFACT_DIR" - rm -rf "$SIGNED_APP_PATH" "$DMG_PATH" + rm -rf \ + "$SIGNED_APP_PATH" "$DMG_PATH" "$DMG_SHA256_PATH" \ + "$CLI_ARCHIVE_PATH" "$CLI_SHA256_PATH" ditto "$APP_PATH" "$SIGNED_APP_PATH" + [[ -x "$CLI_PATH" ]] || fail "未找到 Release CLI:$CLI_PATH" + if [[ "$SKIP_SIGN" -eq 0 ]]; then [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]] || fail "缺少 DEVELOPER_ID_APPLICATION,无法做正式签名。" require_release_signing_identity - info "Signing app with Developer ID" + info "Signing standalone CLI before the outer app" + sign_cli_binary \ + "$CLI_PATH" \ + "$(app_bundle_identifier "$SIGNED_APP_PATH")" + info "Signing broker, nested app contents, and outer app with Developer ID" sign_app_bundle "$SIGNED_APP_PATH" + validate_cli_role_signature \ + "$CLI_PATH" \ + "$(app_bundle_identifier "$SIGNED_APP_PATH").cli" \ + "$(signing_detail "$SIGNED_APP_PATH" TeamIdentifier)" DMG_IDENTIFIER="$(dmg_signing_identifier "$SIGNED_APP_PATH")" else info "Skipping code signing" fi + "$ROOT_DIR/scripts/package-cli.sh" \ + --binary "$CLI_PATH" \ + --output "$CLI_ARCHIVE_PATH" + create_dmg "$SIGNED_APP_PATH" "$DMG_PATH" if [[ "$SKIP_SIGN" -eq 0 ]]; then @@ -697,24 +814,30 @@ else if [[ "$SKIP_NOTARIZE" -eq 0 ]]; then require_command xcrun notarize_dmg "$DMG_PATH" - validate_notarized_dmg "$DMG_PATH" + notarize_cli_archive "$CLI_ARCHIVE_PATH" else info "Skipping notarization" fi + validate_release_artifacts fi -DMG_SHA256="$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')" +"$ROOT_DIR/scripts/write-sha256.sh" --artifact "$DMG_PATH" --output "$DMG_SHA256_PATH" +"$ROOT_DIR/scripts/write-sha256.sh" --artifact "$CLI_ARCHIVE_PATH" --output "$CLI_SHA256_PATH" +DMG_SHA256="$(awk '{print $1}' "$DMG_SHA256_PATH")" +CLI_SHA256="$(awk '{print $1}' "$CLI_SHA256_PATH")" info "DMG ready: $DMG_PATH" info "SHA256: $DMG_SHA256" +info "CLI ready: $CLI_ARCHIVE_PATH" +info "CLI SHA256: $CLI_SHA256" if [[ "$PUBLISH" -eq 1 ]]; then - publish_release "$DMG_PATH" + publish_release "$DMG_PATH" "$CLI_ARCHIVE_PATH" "$DMG_SHA256_PATH" "$CLI_SHA256_PATH" write_appcast "$DMG_PATH" info "Appcast updated after GitHub Release publish: $APPCAST_PATH" fi if [[ "$PUBLISH_EXISTING" -eq 1 ]]; then - publish_release "$DMG_PATH" + publish_release "$DMG_PATH" "$CLI_ARCHIVE_PATH" "$DMG_SHA256_PATH" "$CLI_SHA256_PATH" write_appcast "$DMG_PATH" info "Appcast updated after existing artifact publish: $APPCAST_PATH" fi diff --git a/scripts/release_binary_validation.py b/scripts/release_binary_validation.py new file mode 100755 index 00000000..727514b5 --- /dev/null +++ b/scripts/release_binary_validation.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import plistlib +import re +import sys +from pathlib import Path + + +EXPECTED_ARCHITECTURES = {"arm64", "x86_64"} + + +def fail(message: str) -> None: + raise SystemExit(f"[artifact-validation] error: {message}") + + +def validate_architectures(value: str, role: str) -> None: + architectures = value.split() + if len(architectures) != 2 or set(architectures) != EXPECTED_ARCHITECTURES: + fail( + f"{role} must contain exactly arm64 and x86_64 slices; " + f"found: {value or 'none'}" + ) + + +def extract_embedded_plist(value: str) -> bytes: + output = bytearray() + for line in value.splitlines(): + fields = line.split() + if fields and re.fullmatch(r"[0-9a-fA-F]+", fields[0]): + for word in fields[1:]: + if not re.fullmatch(r"(?:[0-9a-fA-F]{2}){1,4}", word): + fail(f"Invalid __info_plist word: {word}") + output.extend(bytes.fromhex(word)[::-1]) + result = bytes(output).rstrip(b"\0") + if not result: + fail("Mach-O __info_plist section is empty") + return result + + +def validate_info( + plist_path: Path, + expected_identifier: str, + expected_version: str, + expected_build: str, + role: str, +) -> None: + try: + with plist_path.open("rb") as stream: + value = plistlib.load(stream) + except (OSError, plistlib.InvalidFileException) as error: + fail(f"Missing or invalid embedded Info.plist for {role}: {error}") + actual = ( + value.get("CFBundleIdentifier"), + value.get("CFBundleShortVersionString"), + value.get("CFBundleVersion"), + ) + expected = (expected_identifier, expected_version, expected_build) + if actual != expected: + fail(f"Embedded identity/version mismatch for {role}: expected {expected}, found {actual}") + + +def signing_value(details: str, key: str) -> str | None: + prefix = f"{key}=" + return next( + (line[len(prefix):] for line in details.splitlines() if line.startswith(prefix)), + None, + ) + + +def validate_signing_details( + details: str, + expected_identifier: str, + expected_team: str, + role: str, +) -> None: + identifier = signing_value(details, "Identifier") + team = signing_value(details, "TeamIdentifier") + if identifier != expected_identifier: + fail(f"Signing identifier mismatch for {role}") + if not expected_team or team != expected_team: + fail(f"Team Identifier mismatch for {role}") + if not any("flags=" in line and "(runtime)" in line for line in details.splitlines()): + fail(f"Hardened runtime is missing for {role}") + + +def validate_command_status(status: int, operation: str) -> None: + if status != 0: + fail(f"{operation} failed with status {status}") + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + architectures = subparsers.add_parser("architectures") + architectures.add_argument("--value", required=True) + architectures.add_argument("--role", required=True) + + subparsers.add_parser("extract-info") + + info = subparsers.add_parser("info") + info.add_argument("--plist", type=Path, required=True) + info.add_argument("--identifier", required=True) + info.add_argument("--version", required=True) + info.add_argument("--build", required=True) + info.add_argument("--role", required=True) + + signing = subparsers.add_parser("signing") + signing.add_argument("--details", type=Path, required=True) + signing.add_argument("--identifier", required=True) + signing.add_argument("--team", required=True) + signing.add_argument("--role", required=True) + + status = subparsers.add_parser("status") + status.add_argument("--value", type=int, required=True) + status.add_argument("--operation", required=True) + + arguments = parser.parse_args() + if arguments.command == "architectures": + validate_architectures(arguments.value, arguments.role) + elif arguments.command == "extract-info": + sys.stdout.buffer.write(extract_embedded_plist(sys.stdin.read())) + elif arguments.command == "info": + validate_info( + arguments.plist, + arguments.identifier, + arguments.version, + arguments.build, + arguments.role, + ) + elif arguments.command == "signing": + validate_signing_details( + arguments.details.read_text(), + arguments.identifier, + arguments.team, + arguments.role, + ) + elif arguments.command == "status": + validate_command_status(arguments.value, arguments.operation) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_cli_packaging.py b/scripts/tests/test_cli_packaging.py new file mode 100644 index 00000000..f6634a09 --- /dev/null +++ b/scripts/tests/test_cli_packaging.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import plistlib +import shutil +import stat +import subprocess +import tempfile +import unittest +import zipfile +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] +PACKAGE_SCRIPT = SCRIPTS_DIR / "package-cli.sh" +VALIDATE_SCRIPT = SCRIPTS_DIR / "validate-release-artifacts.sh" +VALIDATE_LAYOUT_SCRIPT = SCRIPTS_DIR / "validate-release-layout.py" +CHECKSUM_SCRIPT = SCRIPTS_DIR / "write-sha256.sh" +BINARY_VALIDATION_SCRIPT = SCRIPTS_DIR / "release_binary_validation.py" +RELEASE_SCRIPT = SCRIPTS_DIR / "release-local.sh" + + +class CLIPackagingTests(unittest.TestCase): + host_identifier = "app.ggbond.MacTools" + + def run_validator(self, archive: Path, dmg: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + str(VALIDATE_SCRIPT), + "--cli-archive", + str(archive), + "--dmg", + str(dmg), + "--version", + "1.2.0", + "--build", + "69", + "--allow-unsigned", + ], + check=False, + capture_output=True, + text=True, + ) + + def run_binary_validator(self, *arguments: str, stdin: bytes | None = None) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + [str(BINARY_VALIDATION_SCRIPT), *arguments], + input=stdin, + check=False, + capture_output=True, + ) + + def test_archive_contains_one_root_level_executable(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + binary = root / "MacToolsCLI" + archive = root / "output" / "mactools-cli.zip" + binary.write_bytes(b"#!/bin/sh\nexit 0\n") + binary.chmod(0o755) + + subprocess.run( + [ + str(PACKAGE_SCRIPT), + "--binary", + str(binary), + "--output", + str(archive), + ], + check=True, + capture_output=True, + text=True, + ) + + with zipfile.ZipFile(archive) as package: + self.assertEqual(package.namelist(), ["mactools"]) + mode = package.getinfo("mactools").external_attr >> 16 + self.assertTrue(mode & stat.S_IXUSR) + self.assertEqual(package.read("mactools"), binary.read_bytes()) + + def test_validator_rejects_extra_archive_entries(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + archive = root / "mactools-cli.zip" + dmg = root / "MacTools.dmg" + dmg.touch() + with zipfile.ZipFile(archive, "w") as package: + package.writestr("mactools", b"binary") + package.writestr("README", b"unexpected") + + result = self.run_validator(archive, dmg) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("exactly one root entry", result.stderr) + + def test_validator_rejects_symlink_cli_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + archive = root / "mactools-cli.zip" + dmg = root / "MacTools.dmg" + dmg.touch() + info = zipfile.ZipInfo("mactools") + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + with zipfile.ZipFile(archive, "w") as package: + package.writestr(info, "elsewhere") + + result = self.run_validator(archive, dmg) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("regular executable", result.stderr) + + def test_validator_rejects_non_universal_cli(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + binary = root / "MacToolsCLI" + archive = root / "mactools-cli.zip" + dmg = root / "MacTools.dmg" + binary.write_bytes(b"#!/bin/sh\nexit 0\n") + binary.chmod(0o755) + dmg.touch() + subprocess.run( + [ + str(PACKAGE_SCRIPT), + "--binary", + str(binary), + "--output", + str(archive), + ], + check=True, + capture_output=True, + text=True, + ) + + result = self.run_validator(archive, dmg) + + self.assertNotEqual(result.returncode, 0) + + def test_binary_policy_rejects_missing_extra_and_duplicate_architectures(self) -> None: + valid = self.run_binary_validator( + "architectures", "--value", "x86_64 arm64", "--role", "CLI" + ) + self.assertEqual(valid.returncode, 0, valid.stderr.decode()) + for role in ("Host", "Broker", "CLI"): + for architectures in ("arm64", "arm64 x86_64 i386", "arm64 arm64"): + with self.subTest(role=role, architectures=architectures): + result = self.run_binary_validator( + "architectures", "--value", architectures, "--role", role + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"exactly arm64 and x86_64", result.stderr) + + def test_otool_info_plist_parser_round_trips_every_word(self) -> None: + value = { + "CFBundleIdentifier": "app.ggbond.MacTools.cli", + "CFBundleShortVersionString": "1.2.0", + "CFBundleVersion": "69", + } + payload = plistlib.dumps(value, fmt=plistlib.FMT_XML) + padded = payload + b"\0" * (-len(payload) % 4) + words = [padded[index : index + 4][::-1].hex() for index in range(0, len(padded), 4)] + lines = ["Contents of (__TEXT,__info_plist) section"] + for index in range(0, len(words), 4): + lines.append(f"{index * 4:016x} " + " ".join(words[index : index + 4])) + + result = self.run_binary_validator( + "extract-info", + stdin=("\n".join(lines) + "\n").encode(), + ) + + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(plistlib.loads(result.stdout), value) + + def test_embedded_metadata_policy_rejects_each_role_slice_mutation(self) -> None: + roles = { + "CLI arm64": "app.ggbond.MacTools.cli", + "CLI x86_64": "app.ggbond.MacTools.cli", + "Broker arm64": "app.ggbond.MacTools.cli-broker", + "Broker x86_64": "app.ggbond.MacTools.cli-broker", + } + with tempfile.TemporaryDirectory() as temporary_directory: + plist_path = Path(temporary_directory) / "Info.plist" + for role, identifier in roles.items(): + expected = { + "CFBundleIdentifier": identifier, + "CFBundleShortVersionString": "1.2.0", + "CFBundleVersion": "69", + } + for key, replacement in ( + ("CFBundleIdentifier", "example.WrongRole"), + ("CFBundleShortVersionString", "9.9.9"), + ("CFBundleVersion", "999"), + ): + with self.subTest(role=role, key=key): + mutated = expected | {key: replacement} + with plist_path.open("wb") as stream: + plistlib.dump(mutated, stream) + result = self.run_binary_validator( + "info", + "--plist", + str(plist_path), + "--identifier", + identifier, + "--version", + "1.2.0", + "--build", + "69", + "--role", + role, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"identity/version mismatch", result.stderr) + + def test_signing_policy_rejects_role_team_runtime_and_command_failures(self) -> None: + roles = { + "Host arm64": "app.ggbond.MacTools", + "Host x86_64": "app.ggbond.MacTools", + "CLI arm64": "app.ggbond.MacTools.cli", + "CLI x86_64": "app.ggbond.MacTools.cli", + "Broker arm64": "app.ggbond.MacTools.cli-broker", + "Broker x86_64": "app.ggbond.MacTools.cli-broker", + } + with tempfile.TemporaryDirectory() as temporary_directory: + details = Path(temporary_directory) / "signing.txt" + for role, identifier in roles.items(): + valid_details = ( + f"Identifier={identifier}\n" + "TeamIdentifier=JENNYTEAM\n" + "CodeDirectory v=20500 size=1 flags=0x10000(runtime)\n" + ) + cases = { + "valid": valid_details, + "identifier": valid_details.replace(identifier, "example.WrongRole"), + "team": valid_details.replace("JENNYTEAM", "OTHERTEAM"), + "runtime": valid_details.replace("runtime", "adhoc"), + } + for mutation, value in cases.items(): + with self.subTest(role=role, mutation=mutation): + details.write_text(value) + result = self.run_binary_validator( + "signing", + "--details", + str(details), + "--identifier", + identifier, + "--team", + "JENNYTEAM", + "--role", + role, + ) + if mutation == "valid": + self.assertEqual(result.returncode, 0, result.stderr.decode()) + else: + self.assertNotEqual(result.returncode, 0) + + for operation in ( + "App signature verification", + "CLI signature verification", + "Broker signature verification", + "DMG Gatekeeper assessment", + "CLI Gatekeeper assessment", + ): + with self.subTest(operation=operation): + result = self.run_binary_validator( + "status", "--value", "1", "--operation", operation + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(operation.encode(), result.stderr) + + def test_validator_rejects_non_exact_executable_mode(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + archive = root / "mactools-cli.zip" + dmg = root / "MacTools.dmg" + dmg.touch() + info = zipfile.ZipInfo("mactools") + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o777) << 16 + with zipfile.ZipFile(archive, "w") as package: + package.writestr(info, b"binary") + + result = self.run_validator(archive, dmg) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("exact mode 0755", result.stderr) + + def test_release_validator_is_shared_and_signs_cli_before_outer_app(self) -> None: + local_release = RELEASE_SCRIPT.read_text() + release_workflow = (SCRIPTS_DIR.parent / ".github/workflows/release.yml").read_text() + validator = VALIDATE_SCRIPT.read_text() + binary_policy = BINARY_VALIDATION_SCRIPT.read_text() + + main_flow = local_release.index('info "Signing standalone CLI before the outer app"') + self.assertLess( + local_release.index('sign_cli_binary \\\n', main_flow), + local_release.index('sign_app_bundle "$SIGNED_APP_PATH"', main_flow), + ) + self.assertIn("validate-release-artifacts.sh", local_release) + self.assertIn("validate-release-artifacts.sh", release_workflow) + for invariant in ( + "arm64", + "x86_64", + 'validate_universal_binary "$HOST_PATH" "Host"', + "--all-architectures", + '--arch "$architecture"', + "TeamIdentifier", + "runtime", + "validate-release-layout.py", + "MacToolsCLIBroker", + "CFBundleIdentifier", + ): + self.assertIn(invariant, validator + binary_policy) + self.assertNotIn("$CLI_PATH version", validator) + + def make_app_layout(self, root: Path) -> Path: + app = root / "MacTools.app" + macos = app / "Contents" / "MacOS" + launch_agents = app / "Contents" / "Library" / "LaunchAgents" + macos.mkdir(parents=True) + launch_agents.mkdir(parents=True) + (macos / "MacTools").touch() + (macos / "MacToolsCLIBroker").touch() + service = f"{self.host_identifier}.cli-broker" + with (launch_agents / "app.ggbond.MacTools.cli-broker.plist").open("wb") as stream: + plistlib.dump( + { + "Label": service, + "BundleProgram": "Contents/MacOS/MacToolsCLIBroker", + "MachServices": {service: True}, + "ProcessType": "Interactive", + }, + stream, + ) + return app + + def run_layout_validator(self, app: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + str(VALIDATE_LAYOUT_SCRIPT), + "--app", + str(app), + "--host-executable", + "MacTools", + "--host-identifier", + self.host_identifier, + ], + check=False, + capture_output=True, + text=True, + ) + + def test_layout_validator_accepts_only_authorized_release_layout(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + app = self.make_app_layout(Path(temporary_directory)) + + result = self.run_layout_validator(app) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_layout_validator_rejects_any_extra_macos_executable(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + app = self.make_app_layout(Path(temporary_directory)) + (app / "Contents" / "MacOS" / "MacToolsCLI").touch() + + result = self.run_layout_validator(app) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unexpected Contents/MacOS entries", result.stderr) + + def test_layout_validator_rejects_symlinked_broker(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + app = self.make_app_layout(Path(temporary_directory)) + broker = app / "Contents" / "MacOS" / "MacToolsCLIBroker" + broker.unlink() + broker.symlink_to("MacTools") + + result = self.run_layout_validator(app) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be a regular file", result.stderr) + + def test_layout_validator_rejects_extra_launch_agent(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + app = self.make_app_layout(Path(temporary_directory)) + (app / "Contents" / "Library" / "LaunchAgents" / "unexpected.plist").touch() + + result = self.run_layout_validator(app) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unexpected LaunchAgents entries", result.stderr) + + def test_layout_validator_rejects_extra_launch_agent_configuration(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + app = self.make_app_layout(Path(temporary_directory)) + launch_agent = ( + app + / "Contents" + / "Library" + / "LaunchAgents" + / "app.ggbond.MacTools.cli-broker.plist" + ) + with launch_agent.open("rb") as stream: + value = plistlib.load(stream) + value["KeepAlive"] = True + with launch_agent.open("wb") as stream: + plistlib.dump(value, stream) + + result = self.run_layout_validator(app) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not exactly match", result.stderr) + + def test_checksums_remain_verifiable_after_pair_is_relocated(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + source = root / "source" + destination = root / "destination" + source.mkdir() + destination.mkdir() + artifact = source / "MacTools.dmg" + checksum = source / "MacTools.sha256" + artifact.write_bytes(b"portable release artifact") + + subprocess.run( + [ + str(CHECKSUM_SCRIPT), + "--artifact", + str(artifact), + "--output", + str(checksum), + ], + check=True, + capture_output=True, + text=True, + ) + shutil.copy2(artifact, destination / artifact.name) + shutil.copy2(checksum, destination / checksum.name) + + result = subprocess.run( + ["/usr/bin/shasum", "-a", "256", "-c", checksum.name], + cwd=destination, + check=False, + capture_output=True, + text=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_local_release_rejects_tag_that_does_not_match_version(self) -> None: + environment = os.environ.copy() + environment["RELEASE_CONFIG_FILE"] = str( + SCRIPTS_DIR.parent / "build/nonexistent-release-config" + ) + + result = subprocess.run( + [ + str(RELEASE_SCRIPT), + "--version", + "1.2.0", + "--build-number", + "69", + "--tag", + "v1.2.0-local", + "--skip-sign", + ], + cwd=SCRIPTS_DIR.parent, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("v1.2.0", result.stderr) + self.assertIn("v1.2.0-local", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_mactools_e2e.py b/scripts/tests/test_mactools_e2e.py index 4ef66b34..9e9b4ac6 100644 --- a/scripts/tests/test_mactools_e2e.py +++ b/scripts/tests/test_mactools_e2e.py @@ -133,6 +133,8 @@ def tree_hash(directory: pathlib.Path): if path.is_symlink(): source_digest.update(b"L\0") source_digest.update(os.readlink(path).encode("utf-8", "surrogateescape")) + elif not path.exists(): + source_digest.update(b"D\0") else: source_digest.update(b"F\0") source_digest.update(path.read_bytes()) diff --git a/scripts/tests/test_plugin_minimum_host_compatibility.py b/scripts/tests/test_plugin_minimum_host_compatibility.py index 634d337e..9267684a 100644 --- a/scripts/tests/test_plugin_minimum_host_compatibility.py +++ b/scripts/tests/test_plugin_minimum_host_compatibility.py @@ -35,6 +35,7 @@ "ActionExposureSurface": "1.2.0", "ActionExposurePolicy": "1.2.0", "PluginActionProviding": "1.2.0", + "PluginActionCatalogPreparing": "1.2.0", "PluginActionShortcutSettingsConfiguration": "1.2.0", "PluginActionShortcutSettingsProviding": "1.2.0", "PluginRetiredActionShortcutProviding": "1.2.0", @@ -67,6 +68,8 @@ "PluginInputGestureConflictConsuming": "1.2.0", # Shared lifecycle and presentation helpers introduced in host 1.2. "PluginCallbackContext": "1.2.0", + "PluginCLIInvocationContext": "1.2.0", + "PluginActionExecutionContext": "1.2.0", "PluginPresentationSafety": "1.2.0", "PluginProcessGroupLease": "1.2.0", "PluginSystemImage": "1.2.0", @@ -134,16 +137,16 @@ def test_plugin_kit5_release_targets_versioned_host_compatible_catalog(self) -> ) self.assertIn('PLUGIN_CATALOG_MINIMUM_HOST_VERSION="1.2.0"', workflow) self.assertIn( - "PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= $(if $(filter 5,$(PLUGIN_KIT_VERSION)),1.2.0,1.1.6)", + "PLUGIN_CATALOG_MINIMUM_HOST_VERSION ?= $(if $(filter 5 6,$(PLUGIN_KIT_VERSION)),1.2.0,1.1.6)", makefile, ) - def test_every_current_plugin_targets_plugin_kit5_and_a_released_host_line(self) -> None: + def test_every_current_plugin_targets_plugin_kit6_and_a_released_host_line(self) -> None: incompatible = [] for manifest_path in sorted(PLUGINS_ROOT.glob("*/plugin.json")): manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if ( - manifest["pluginKitVersion"] != 5 + manifest["pluginKitVersion"] != 6 or version_tuple(manifest["minHostVersion"]) < version_tuple("1.2.0") or version_tuple(manifest["minHostVersion"]) > version_tuple(declared_app_version()) diff --git a/scripts/validate-release-artifacts.sh b/scripts/validate-release-artifacts.sh new file mode 100755 index 00000000..b2388c63 --- /dev/null +++ b/scripts/validate-release-artifacts.sh @@ -0,0 +1,198 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" + +CLI_ARCHIVE="" +DMG_PATH="" +EXPECTED_VERSION="" +EXPECTED_BUILD="" +ALLOW_UNSIGNED=0 +CHECK_GATEKEEPER=0 + +function fail() { + printf '[artifact-validation] error: %s\n' "$1" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --cli-archive) CLI_ARCHIVE="${2:-}"; shift 2 ;; + --dmg) DMG_PATH="${2:-}"; shift 2 ;; + --version) EXPECTED_VERSION="${2:-}"; shift 2 ;; + --build) EXPECTED_BUILD="${2:-}"; shift 2 ;; + --allow-unsigned) ALLOW_UNSIGNED=1; shift ;; + --gatekeeper) CHECK_GATEKEEPER=1; shift ;; + *) fail "Unknown argument: $1" ;; + esac +done + +[[ -f "$CLI_ARCHIVE" ]] || fail "CLI archive not found: $CLI_ARCHIVE" +[[ -f "$DMG_PATH" ]] || fail "DMG not found: $DMG_PATH" +[[ -n "$EXPECTED_VERSION" ]] || fail "Expected version is required." +[[ -n "$EXPECTED_BUILD" ]] || fail "Expected build is required." + +STAGE_DIR="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mactools-artifact-validation.XXXXXX")" +MOUNT_POINT="" +function cleanup() { + if [[ -n "$MOUNT_POINT" && -d "$MOUNT_POINT" ]]; then + /usr/bin/hdiutil detach "$MOUNT_POINT" -quiet || true + fi + /bin/rm -rf "$STAGE_DIR" +} +trap cleanup EXIT + +/usr/bin/python3 - "$CLI_ARCHIVE" <<'PY' +import stat +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as archive: + infos = archive.infolist() + if [info.filename for info in infos] != ["mactools"]: + raise SystemExit("CLI archive must contain exactly one root entry named mactools") + mode = infos[0].external_attr >> 16 + if not stat.S_ISREG(mode) or mode & 0o7777 != 0o755: + raise SystemExit("CLI archive entry must be a regular executable with exact mode 0755") +PY + +/usr/bin/ditto -x -k "$CLI_ARCHIVE" "$STAGE_DIR/cli" +CLI_PATH="$STAGE_DIR/cli/mactools" +[[ -x "$CLI_PATH" && ! -L "$CLI_PATH" ]] || fail "Extracted CLI is not a regular executable." + +function validate_universal_binary() { + local path="$1" + local role="$2" + local architectures + architectures="$(/usr/bin/lipo -archs "$path")" + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" architectures \ + --value "$architectures" \ + --role "$role" +} + +validate_universal_binary "$CLI_PATH" "CLI" + +function signing_detail() { + /usr/bin/codesign -dvvv --arch "$3" "$1" 2>&1 \ + | /usr/bin/awk -F= -v key="$2" '$1 == key { print substr($0, length(key) + 2); exit }' +} + +function validate_embedded_info() { + local path="$1" + local architecture="$2" + local expected_identifier="$3" + local plist_path="$STAGE_DIR/$(/usr/bin/basename "$path").$architecture.plist" + /usr/bin/otool -arch "$architecture" -s __TEXT __info_plist "$path" \ + | /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" extract-info \ + > "$plist_path" + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" info \ + --plist "$plist_path" \ + --identifier "$expected_identifier" \ + --version "$EXPECTED_VERSION" \ + --build "$EXPECTED_BUILD" \ + --role "$path ($architecture)" +} + +function validate_signed_role() { + local path="$1" + local expected_identifier="$2" + local expected_team="$3" + local status=0 + /usr/bin/codesign --verify --strict --all-architectures --verbose=2 "$path" \ + >/dev/null 2>&1 || status=$? + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" status \ + --value "$status" \ + --operation "Signature verification for $path" + for architecture in arm64 x86_64; do + local details_status=0 + local details_path="$STAGE_DIR/$(/usr/bin/basename "$path").$architecture.signing.txt" + /usr/bin/codesign -dvvv --arch "$architecture" "$path" \ + > /dev/null 2> "$details_path" || details_status=$? + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" status \ + --value "$details_status" \ + --operation "Signature inspection for $path ($architecture)" + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" signing \ + --details "$details_path" \ + --identifier "$expected_identifier" \ + --team "$expected_team" \ + --role "$path ($architecture)" + done +} + +ATTACH_PLIST="$STAGE_DIR/attach.plist" +/usr/bin/hdiutil attach -readonly -nobrowse -plist "$DMG_PATH" > "$ATTACH_PLIST" +MOUNT_POINT="$(/usr/bin/python3 - "$ATTACH_PLIST" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], "rb") as stream: + value = plistlib.load(stream) +for entity in value.get("system-entities", []): + mount = entity.get("mount-point") + if mount: + print(mount) + break +PY +)" +[[ -n "$MOUNT_POINT" && -d "$MOUNT_POINT" ]] || fail "Could not mount DMG." + +APP_PATH="$MOUNT_POINT/MacTools.app" +[[ -d "$APP_PATH" ]] || fail "DMG must contain MacTools.app at its root." +APP_COUNT="$(/usr/bin/find "$MOUNT_POINT" -maxdepth 1 -type d -name '*.app' | /usr/bin/wc -l | /usr/bin/tr -d ' ')" +[[ "$APP_COUNT" == "1" ]] || fail "DMG must contain exactly one root-level app." + +APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PATH/Contents/Info.plist")" +APP_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_PATH/Contents/Info.plist")" +[[ "$APP_VERSION" == "$EXPECTED_VERSION" && "$APP_BUILD" == "$EXPECTED_BUILD" ]] \ + || fail "App version/build does not match the CLI release." + +HOST_IDENTIFIER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Contents/Info.plist")" +HOST_EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP_PATH/Contents/Info.plist")" +HOST_PATH="$APP_PATH/Contents/MacOS/$HOST_EXECUTABLE" +/usr/bin/python3 "$SCRIPT_DIR/validate-release-layout.py" \ + --app "$APP_PATH" \ + --host-executable "$HOST_EXECUTABLE" \ + --host-identifier "$HOST_IDENTIFIER" +[[ -x "$HOST_PATH" && ! -L "$HOST_PATH" ]] || fail "DMG app host is not a regular executable." +validate_universal_binary "$HOST_PATH" "Host" +BROKER_PATH="$APP_PATH/Contents/MacOS/MacToolsCLIBroker" +[[ -x "$BROKER_PATH" ]] || fail "DMG app is missing MacToolsCLIBroker." +validate_universal_binary "$BROKER_PATH" "Broker" + +for architecture in arm64 x86_64; do + validate_embedded_info "$CLI_PATH" "$architecture" "$HOST_IDENTIFIER.cli" +done +for architecture in arm64 x86_64; do + validate_embedded_info "$BROKER_PATH" "$architecture" "$HOST_IDENTIFIER.cli-broker" +done + +if [[ "$ALLOW_UNSIGNED" -eq 0 ]]; then + APP_SIGNATURE_STATUS=0 + /usr/bin/codesign --verify --deep --strict --all-architectures --verbose=2 "$APP_PATH" \ + >/dev/null 2>&1 || APP_SIGNATURE_STATUS=$? + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" status \ + --value "$APP_SIGNATURE_STATUS" \ + --operation "App signature verification" + HOST_TEAM="$(signing_detail "$APP_PATH" TeamIdentifier arm64)" + [[ -n "$HOST_TEAM" ]] || fail "App signature has no Team Identifier." + validate_signed_role "$APP_PATH" "$HOST_IDENTIFIER" "$HOST_TEAM" + validate_signed_role "$BROKER_PATH" "$HOST_IDENTIFIER.cli-broker" "$HOST_TEAM" + validate_signed_role "$CLI_PATH" "$HOST_IDENTIFIER.cli" "$HOST_TEAM" +fi + +if [[ "$CHECK_GATEKEEPER" -eq 1 ]]; then + DMG_GATEKEEPER_STATUS=0 + CLI_GATEKEEPER_STATUS=0 + /usr/sbin/spctl -a -t open --context context:primary-signature -v "$DMG_PATH" \ + || DMG_GATEKEEPER_STATUS=$? + /usr/sbin/spctl -a -t exec -v "$CLI_PATH" || CLI_GATEKEEPER_STATUS=$? + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" status \ + --value "$DMG_GATEKEEPER_STATUS" \ + --operation "DMG Gatekeeper assessment" + /usr/bin/python3 "$SCRIPT_DIR/release_binary_validation.py" status \ + --value "$CLI_GATEKEEPER_STATUS" \ + --operation "CLI Gatekeeper assessment" +fi + +printf '[artifact-validation] app, broker, and standalone CLI passed\n' diff --git a/scripts/validate-release-layout.py b/scripts/validate-release-layout.py new file mode 100755 index 00000000..43f22dcd --- /dev/null +++ b/scripts/validate-release-layout.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import plistlib +from pathlib import Path + + +BROKER_EXECUTABLE = "MacToolsCLIBroker" +LAUNCH_AGENT_NAME = "app.ggbond.MacTools.cli-broker.plist" + + +def fail(message: str) -> None: + raise SystemExit(f"[artifact-validation] error: {message}") + + +def validate_directory(directory: Path, expected: set[str], description: str) -> None: + if not directory.is_dir(): + fail(f"Missing {description} directory: {directory}") + actual = {entry.name for entry in directory.iterdir()} + if actual != expected: + fail( + f"Unexpected {description} entries: expected {sorted(expected)}, " + f"found {sorted(actual)}" + ) + for name in expected: + entry = directory / name + if not entry.is_file() or entry.is_symlink(): + fail(f"{description} entry must be a regular file: {name}") + + +def validate_layout(app: Path, host_executable: str, host_identifier: str) -> None: + validate_directory( + app / "Contents" / "MacOS", + {host_executable, BROKER_EXECUTABLE}, + "Contents/MacOS", + ) + launch_agents = app / "Contents" / "Library" / "LaunchAgents" + validate_directory(launch_agents, {LAUNCH_AGENT_NAME}, "LaunchAgents") + + launch_agent_path = launch_agents / LAUNCH_AGENT_NAME + try: + with launch_agent_path.open("rb") as stream: + actual = plistlib.load(stream) + except (OSError, plistlib.InvalidFileException) as error: + fail(f"Invalid broker LaunchAgent plist: {error}") + + service = f"{host_identifier}.cli-broker" + expected = { + "Label": service, + "BundleProgram": f"Contents/MacOS/{BROKER_EXECUTABLE}", + "MachServices": {service: True}, + "ProcessType": "Interactive", + } + if actual != expected: + fail("Broker LaunchAgent plist does not exactly match the authorized configuration") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--app", type=Path, required=True) + parser.add_argument("--host-executable", required=True) + parser.add_argument("--host-identifier", required=True) + arguments = parser.parse_args() + validate_layout(arguments.app, arguments.host_executable, arguments.host_identifier) + + +if __name__ == "__main__": + main() diff --git a/scripts/write-sha256.sh b/scripts/write-sha256.sh new file mode 100755 index 00000000..cfaf6bae --- /dev/null +++ b/scripts/write-sha256.sh @@ -0,0 +1,29 @@ +#!/bin/zsh + +set -euo pipefail + +ARTIFACT="" +OUTPUT="" + +function fail() { + printf '[checksum] error: %s\n' "$1" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --artifact) ARTIFACT="${2:-}"; shift 2 ;; + --output) OUTPUT="${2:-}"; shift 2 ;; + *) fail "Unknown argument: $1" ;; + esac +done + +[[ -f "$ARTIFACT" ]] || fail "Artifact not found: $ARTIFACT" +[[ -n "$OUTPUT" ]] || fail "Output path is required." + +ARTIFACT="${ARTIFACT:A}" +OUTPUT="${OUTPUT:A}" +( + cd "${ARTIFACT:h}" + /usr/bin/shasum -a 256 "${ARTIFACT:t}" +) > "$OUTPUT"