# Sensible defaults .ONESHELL: SHELL := bash .SHELLFLAGS := -e -u -c -o pipefail .DELETE_ON_ERROR: MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules # Derived values (DO NOT TOUCH). CURRENT_MAKEFILE_PATH := $(abspath $(lastword $(MAKEFILE_LIST))) CURRENT_MAKEFILE_DIR := $(patsubst %/,%,$(dir $(CURRENT_MAKEFILE_PATH))) APP_DIR := $(CURRENT_MAKEFILE_DIR)/App CLI_PACKAGE := $(CURRENT_MAKEFILE_DIR)/CLI RELAY_PACKAGE := $(CURRENT_MAKEFILE_DIR)/Mirror/Relay SHARED_PACKAGE := $(CURRENT_MAKEFILE_DIR)/Shared GHOSTTY_XCFRAMEWORK_PATH := $(APP_DIR)/Frameworks/GhosttyKit.xcframework GHOSTTY_RESOURCE_PATH := $(APP_DIR)/Resources/ghostty GHOSTTY_TERMINFO_PATH := $(APP_DIR)/Resources/terminfo GHOSTTY_BUILD_OUTPUTS := $(GHOSTTY_XCFRAMEWORK_PATH) $(GHOSTTY_RESOURCE_PATH) $(GHOSTTY_TERMINFO_PATH) GHOSTTY_BUILD_STAMP := $(CURRENT_MAKEFILE_DIR)/.ghostty_build_stamp GHOSTTY_HASH_FILE := $(CURRENT_MAKEFILE_DIR)/.ghostty_hash SPM_CACHE_DIR := $(HOME)/Library/Caches/prowl-spm-cache/SourcePackages # Tuist generates the workspace and the Xcode projects from the manifests; they are not in Git. XCODE_WORKSPACE := $(CURRENT_MAKEFILE_DIR)/Prowl.xcworkspace APP_SCHEME := Prowl TEST_TARGET := ProwlTests IOS_MIRROR_PROJECT := $(CURRENT_MAKEFILE_DIR)/Mirror/iOS/ProwlMirror-iOS.xcodeproj IOS_MIRROR_SCHEME := ProwlMirror-iOS # Regular expression for the full simulator name. Each Xcode release renames the models, # so scripts/select_ios_simulator.py uses the newest match on the newest supported runtime. IOS_MIRROR_SIMULATOR_PATTERN ?= iPad Pro 11-inch \(M[0-9]+\) XCODE_CONFIG_DIR := $(APP_DIR)/Config VERSION_XCCONFIG := $(XCODE_CONFIG_DIR)/Version.xcconfig TUIST_STAMP := $(CURRENT_MAKEFILE_DIR)/.tuist_generated_stamp TUIST_INPUTS := \ $(CURRENT_MAKEFILE_DIR)/Tuist.swift \ $(CURRENT_MAKEFILE_DIR)/Workspace.swift \ $(APP_DIR)/Project.swift \ $(CURRENT_MAKEFILE_DIR)/Mirror/iOS/Project.swift \ $(CURRENT_MAKEFILE_DIR)/.package.resolved \ $(CURRENT_MAKEFILE_DIR)/mise.toml \ $(wildcard $(XCODE_CONFIG_DIR)/*.xcconfig) \ $(wildcard $(CURRENT_MAKEFILE_DIR)/Mirror/iOS/Config/*.xcconfig) # Paths that the app target copies into the bundle. Tuist reads them at generation time. TUIST_REQUIRED_PATHS := \ App/Frameworks/GhosttyKit.xcframework \ App/Resources/ghostty App/Resources/terminfo ThirdParty/git-wt/wt \ App/Resources/docs App/Resources/skills App/Resources/agent-hooks App/Resources/workflows \ App/Resources/prowl-cli/prowl App/Resources/prowl-mirror-relay/prowl-mirror-relay CLI_DEBUG_RESOURCE_PATH := $(APP_DIR)/Resources/prowl-cli/prowl RELAY_RESOURCE_DIR := $(APP_DIR)/Resources/prowl-mirror-relay CLI_VERSION_FILE := $(SHARED_PACKAGE)/Sources/ProwlCLIShared/ProwlVersion.swift CLI_SOURCE_DIRS := $(CLI_PACKAGE) $(SHARED_PACKAGE) CLI_SOURCE_INPUTS := \ $(CURRENT_MAKEFILE_PATH) \ $(VERSION_XCCONFIG) \ $(shell find $(CLI_SOURCE_DIRS) -name .build -prune -o -type f -print 2>/dev/null) VERSION ?= BUILD ?= XCODEBUILD_FLAGS ?= BUILD_BENCHMARK_SCENARIO ?= ci BUILD_BENCHMARK_SAMPLES ?= 1 CLI_INTEGRATION_TEST_FILTER ?= ProwlCLIIntegrationTests FORMAT_BASE_REF ?= origin/main # The same source set as before the move to the product layout (docs-ai 074). SWIFT_FORMAT_PATHS := App/Sources App/Tests Shared/Sources/ProwlCLIShared Mirror/Relay/Sources/MirrorRelayProtocol BUILD_SETTINGS_CACHE := $(CURRENT_MAKEFILE_DIR)/.build_settings_cache.json # Release-only analytics/crash credentials. Included from Config/Secrets.env if present, # or overridable from the environment (e.g. CI). Debug builds skip SDK init regardless. -include Config/Secrets.env PROWL_SENTRY_DSN ?= PROWL_POSTHOG_API_KEY ?= PROWL_POSTHOG_HOST ?= # Local Debug signing. Debug products are ad-hoc signed by default, and an ad-hoc # signature's designated requirement is its cdhash, which changes on every rebuild: # TCC then treats each build as a new app and re-asks for Desktop/Documents/Downloads # access from Prowl Debug and from the commands running in its panes. Setting # PROWL_DEVELOPMENT_TEAM (environment or Config/Secrets.env) makes build-app and # test-app sign the app and the test host with that team's Apple Development identity # through Xcode's automatic signing, so the grants survive rebuilds. Empty keeps # ad-hoc signing, which needs no certificate (CI, contributors). PROWL_DEVELOPMENT_TEAM ?= ifneq ($(strip $(PROWL_DEVELOPMENT_TEAM)),) DEBUG_SIGNING_ARGS := DEVELOPMENT_TEAM=$(PROWL_DEVELOPMENT_TEAM) TEST_SIGNING_ARGS := $(DEBUG_SIGNING_ARGS) else DEBUG_SIGNING_ARGS := TEST_SIGNING_ARGS := CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" endif .DEFAULT_GOAL := help .PHONY: build-ghostty-xcframework ensure-ghostty sync-ghostty _record-ghostty-hash generate ensure-project ensure-mirror-ios-project build-app build-cli build-cli-release embed-cli-debug embed-cli embed-docs embed-skills run-app install-dev-build install-release archive export-archive format format-changed format-lint lint check test test-all test-app test-mirror-ios test-mirror-ios-unit test-mirror-android test-scripts test-cli-smoke test-cli-unit test-cli-integration benchmark-build bump-version log-stream agent-versions .PHONY: test-agent-contracts _test-agent-contract-codex _test-agent-contract-export help: # Display this help. @-+echo "Run make with one of the following targets:" @-+echo @-+grep -Eh "^[a-z-]+:.*#" $(CURRENT_MAKEFILE_PATH) | sed -E 's/^(.*:)(.*#+)(.*)/ \1 @@@ \3 /' | column -t -s "@@@" build-ghostty-xcframework: $(GHOSTTY_BUILD_STAMP) # Build ghostty framework @$(MAKE) _record-ghostty-hash # Internal: actually rebuild ghostty. $(GHOSTTY_BUILD_STAMP): git submodule update --init --recursive ThirdParty/ghostty @cd $(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty && mise exec -- zig build -Doptimize=ReleaseFast -Demit-xcframework=true -Dsentry=false rsync -a ThirdParty/ghostty/macos/GhosttyKit.xcframework "$(APP_DIR)/Frameworks" @src="$(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty/zig-out/share/ghostty"; \ dst="$(GHOSTTY_RESOURCE_PATH)"; \ terminfo_src="$(CURRENT_MAKEFILE_DIR)/ThirdParty/ghostty/zig-out/share/terminfo"; \ terminfo_dst="$(GHOSTTY_TERMINFO_PATH)"; \ mkdir -p "$$dst"; \ rsync -a --delete "$$src/" "$$dst/"; \ mkdir -p "$$terminfo_dst"; \ rsync -a --delete "$$terminfo_src/" "$$terminfo_dst/" touch "$(GHOSTTY_BUILD_STAMP)" # Public entry point: downloads pinned prebuilt artifacts when available, then # falls back to a local Ghostty source build. ensure-ghostty: # Ensure GhosttyKit is up-to-date (fast path when unchanged) @set +e; \ "$(CURRENT_MAKEFILE_DIR)/scripts/ensure-ghosttykit-artifacts.sh"; \ status="$$?"; \ set -e; \ if [ "$$status" -eq 0 ]; then \ exit 0; \ fi; \ if [ "$$status" -ne 2 ]; then \ exit "$$status"; \ fi; \ current_sha="$$(git -C "$(CURRENT_MAKEFILE_DIR)" rev-parse HEAD:ThirdParty/ghostty)"; \ last_sha=""; \ if [ -f "$(GHOSTTY_HASH_FILE)" ]; then \ last_sha="$$(cat "$(GHOSTTY_HASH_FILE)")"; \ fi; \ echo "Building GhosttyKit locally for $$current_sha"; \ $(MAKE) -B build-ghostty-xcframework; \ if [ "$$current_sha" != "$$last_sha" ]; then \ rm -rf ~/Library/Developer/Xcode/DerivedData/Prowl-*; \ echo "Cleared Xcode DerivedData for ghostty header/module changes"; \ fi # Internal: record the current submodule SHA after a successful build. _record-ghostty-hash: @git -C "$(CURRENT_MAKEFILE_DIR)" rev-parse HEAD:ThirdParty/ghostty > "$(GHOSTTY_HASH_FILE)" # Force a clean rebuild of GhosttyKit (ignores cached SHA, useful after submodule updates). sync-ghostty: # Force sync GhosttyKit to current submodule HEAD (always rebuilds) @echo "Forcing GhosttyKit rebuild..." $(MAKE) -B build-ghostty-xcframework rm -rf ~/Library/Developer/Xcode/DerivedData/Prowl-* @echo "Done. Xcode module cache cleared for fresh compilation." embed-docs: # Stage docs/ into App/Resources for bundling into the app (.app/Contents/Resources/docs) @set -euo pipefail; \ src="$(CURRENT_MAKEFILE_DIR)/docs"; \ dst="$(APP_DIR)/Resources/docs"; \ mkdir -p "$$dst"; \ rsync -a --delete --exclude '.sync-meta.json' "$$src/" "$$dst/"; \ echo "embedded docs at $$dst" embed-skills: # Stage skills/ into App/Resources for bundling into the app (.app/Contents/Resources/skills) @set -euo pipefail; \ src="$(CURRENT_MAKEFILE_DIR)/skills"; \ dst="$(APP_DIR)/Resources/skills"; \ mkdir -p "$$dst"; \ rsync -a --delete "$$src/" "$$dst/"; \ echo "embedded skills at $$dst" generate: ensure-ghostty embed-cli-debug embed-docs embed-skills ensure-project # Generate Prowl.xcworkspace and the Xcode projects with Tuist # Internal: generate the workspace when it is absent or older than its inputs. The caller # stages the bundled resources first (Debug or Release), so this target does not build them. ensure-project: @set -euo pipefail; \ cd "$(CURRENT_MAKEFILE_DIR)"; \ for path in $(TUIST_REQUIRED_PATHS); do \ if [ ! -e "$$path" ]; then \ echo "error: $$path is missing. Run: make generate" >&2; \ exit 1; \ fi; \ done; \ if [ -d "$(XCODE_WORKSPACE)" ] && [ -f "$(TUIST_STAMP)" ] \ && [ -z "$$(find $(TUIST_INPUTS) -newer "$(TUIST_STAMP)" -print -quit)" ]; then \ exit 0; \ fi; \ mise exec -- tuist generate --no-open; \ touch "$(TUIST_STAMP)" build-app: ensure-ghostty embed-cli-debug embed-docs embed-skills ensure-project # Build the macOS app (Debug) bash -o pipefail -c 'xcodebuild -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -configuration Debug build -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) SWIFT_COMPILATION_MODE=incremental $(DEBUG_SIGNING_ARGS) 2>&1 | mise exec -- xcsift -w --format toon' sync-cli-version: # Sync app MARKETING_VERSION into ProwlCLIShared/ProwlVersion.swift @version="$$(/usr/bin/awk -F' = ' '/^MARKETING_VERSION = [0-9.]+$$/{print $$2; exit}' "$(VERSION_XCCONFIG)")"; \ if [ -z "$$version" ]; then \ echo "error: MARKETING_VERSION not found in $(VERSION_XCCONFIG)" >&2; \ exit 1; \ fi; \ dst="$(CLI_VERSION_FILE)"; \ tmp="$$(mktemp)"; \ trap 'rm -f "$$tmp"' EXIT; \ printf '// Auto-generated by Makefile (sync-cli-version). Do not edit.\n\npublic enum ProwlVersion {\n public static let current = "%s"\n}\n' "$$version" > "$$tmp"; \ if [ ! -f "$$dst" ] || ! cmp -s "$$tmp" "$$dst"; then \ mv "$$tmp" "$$dst"; \ echo "synced CLI version $$version"; \ fi build-cli: sync-cli-version # Build Swift CLI binary (SPM) swift build --package-path "$(CLI_PACKAGE)" --product prowl swift build --package-path "$(RELAY_PACKAGE)" --product prowl-mirror-relay build-cli-release: sync-cli-version # Build universal CLI binary in release mode swift build --package-path "$(CLI_PACKAGE)" -c release --arch arm64 --arch x86_64 --product prowl swift build --package-path "$(RELAY_PACKAGE)" -c release --arch arm64 --arch x86_64 --product prowl-mirror-relay embed-cli-debug: embed-mirror-relay-debug $(CLI_DEBUG_RESOURCE_PATH) # Build debug CLI and copy into App/Resources for dev builds $(CLI_DEBUG_RESOURCE_PATH): $(CLI_SOURCE_INPUTS) $(MAKE) build-cli @set -euo pipefail; \ bin="$$(swift build --package-path "$(CLI_PACKAGE)" --show-bin-path)/prowl"; \ dst="$(APP_DIR)/Resources/prowl-cli"; \ mkdir -p "$$dst"; \ if [ ! -f "$$dst/prowl" ] || ! cmp -s "$$bin" "$$dst/prowl"; then \ cp "$$bin" "$$dst/prowl"; \ else \ touch "$$dst/prowl"; \ fi; \ chmod +x "$$dst/prowl"; \ echo "embedded CLI binary at $$dst/prowl" embed-cli: embed-mirror-relay-release build-cli-release # Build release CLI and copy into App/Resources for distribution @set -euo pipefail; \ bin="$$(swift build --package-path "$(CLI_PACKAGE)" -c release --arch arm64 --arch x86_64 --show-bin-path)/prowl"; \ dst="$(APP_DIR)/Resources/prowl-cli"; \ mkdir -p "$$dst"; \ cp "$$bin" "$$dst/prowl"; \ strip -S -x "$$dst/prowl"; \ chmod +x "$$dst/prowl"; \ echo "embedded CLI binary at $$dst/prowl" run-app: build-app # Build then launch (Debug) with log streaming @set -euo pipefail; \ cache="$(BUILD_SETTINGS_CACHE)"; \ stamp="$(TUIST_STAMP)"; \ if [ -f "$$cache" ] && [ "$$cache" -nt "$$stamp" ]; then \ settings="$$(cat "$$cache")"; \ else \ settings="$$(xcodebuild -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ printf '%s' "$$settings" > "$$cache"; \ fi; \ build_dir="$$(echo "$$settings" | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -er '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ exec_name="$$(echo "$$settings" | jq -r '.[0].buildSettings.EXECUTABLE_NAME')"; \ if [ -z "$$build_dir" ] || [ -z "$$product" ] || [ "$$build_dir" = "null" ] || [ "$$product" = "null" ] || [ -z "$$exec_name" ] || [ "$$exec_name" = "null" ]; then \ echo "error: failed to resolve app path from build settings"; \ exit 1; \ fi; \ app_path="$$build_dir/$$product/Contents/MacOS/$$exec_name"; \ "$$app_path" install-dev-build: build-app # Build Debug and install to /Applications @set -euo pipefail; \ cache="$(BUILD_SETTINGS_CACHE)"; \ stamp="$(TUIST_STAMP)"; \ if [ -f "$$cache" ] && [ "$$cache" -nt "$$stamp" ]; then \ settings="$$(cat "$$cache")"; \ else \ settings="$$(xcodebuild -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ printf '%s' "$$settings" > "$$cache"; \ fi; \ build_dir="$$(echo "$$settings" | jq -er '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -er '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ if [ -z "$$build_dir" ] || [ -z "$$product" ] || [ "$$build_dir" = "null" ] || [ "$$product" = "null" ]; then \ echo "error: failed to resolve app path from build settings"; \ exit 1; \ fi; \ if [ "$$product" != "$$(basename "$$product")" ]; then \ echo "error: invalid product name (contains path separators): $$product"; \ exit 1; \ fi; \ if [[ "$$product" != *.app ]]; then \ echo "error: unexpected product name: $$product"; \ exit 1; \ fi; \ src="$$build_dir/$$product"; \ dst="/Applications/$$product"; \ dst_parent="$$(cd "$$(dirname "$$dst")" && pwd -P)"; \ if [ "$$dst_parent" != "/Applications" ]; then \ echo "error: refusing to install outside /Applications: $$dst"; \ exit 1; \ fi; \ if [ "$$src" = "/" ] || [ "$$dst" = "/Applications" ] || [ "$$dst" = "/Applications/" ]; then \ echo "error: unsafe install path (src=$$src, dst=$$dst)"; \ exit 1; \ fi; \ case "$$dst" in \ /Applications/*.app) ;; \ *) \ echo "error: refusing to install outside /Applications/*.app: $$dst"; \ exit 1; \ ;; \ esac; \ if [ ! -d "$$src" ]; then \ echo "app not found: $$src"; \ exit 1; \ fi; \ if [ ! -d "$$src/Contents" ]; then \ echo "error: source is not an app bundle: $$src"; \ exit 1; \ fi; \ echo "copying $$src -> $$dst"; \ if [ -e "$$dst" ]; then \ if ! command -v trash >/dev/null 2>&1; then \ echo "error: trash command not found; refusing to remove $$dst"; \ exit 1; \ fi; \ echo "moving existing app to Trash: $$dst"; \ trash "$$dst"; \ fi; \ ditto "$$src" "$$dst"; \ echo "installed $$dst" install-release: build-ghostty-xcframework # Build Release, sign locally, install to /Applications @set -euo pipefail; \ SIGNING_IDENTITY="$$(security find-identity -v -p codesigning 2>/dev/null | awk -F'"' '/Developer ID Application/ {print $$2; exit}')"; \ if [ -z "$$SIGNING_IDENTITY" ]; then \ echo "error: no Developer ID Application identity found"; \ exit 1; \ fi; \ IDENTITY_SHA="$$(security find-identity -v -p codesigning 2>/dev/null | grep "$$SIGNING_IDENTITY" | head -1 | awk '{print $$2}')"; \ TEAM_ID="$$(echo "$$SIGNING_IDENTITY" | grep -oE '\([A-Z0-9]{10}\)$$' | tr -d '()')"; \ echo "identity: $$SIGNING_IDENTITY"; \ echo "team: $$TEAM_ID"; \ APPLE_TEAM_ID="$$TEAM_ID" DEVELOPER_ID_IDENTITY_SHA="$$IDENTITY_SHA" $(MAKE) archive; \ mkdir -p build; \ printf '%s\n' \ '' \ '' \ '' \ '' \ ' method' \ ' developer-id' \ ' signingStyle' \ ' manual' \ ' signingCertificate' \ " $$SIGNING_IDENTITY" \ ' teamID' \ " $$TEAM_ID" \ '' \ '' > build/ExportOptions.plist; \ $(MAKE) export-archive; \ APP_PATH="$$(find build/export -name '*.app' -maxdepth 3 -print -quit)"; \ if [ ! -d "$$APP_PATH" ]; then \ echo "error: exported app not found"; \ exit 1; \ fi; \ SPARKLE="$$APP_PATH/Contents/Frameworks/Sparkle.framework/Versions/B"; \ if [ -d "$$SPARKLE" ]; then \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$SPARKLE/XPCServices/Installer.xpc"; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp --preserve-metadata=entitlements -v "$$SPARKLE/XPCServices/Downloader.xpc"; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$SPARKLE/Updater.app"; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$SPARKLE/Autoupdate"; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$SPARKLE/Sparkle"; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$APP_PATH/Contents/Frameworks/Sparkle.framework"; \ fi; \ SENTRY="$$APP_PATH/Contents/Frameworks/Sentry.framework"; \ if [ -d "$$SENTRY" ]; then \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$SENTRY/Versions/A/Sentry"; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp -v "$$SENTRY"; \ fi; \ codesign -f -s "$$IDENTITY_SHA" -o runtime --timestamp --preserve-metadata=entitlements,requirements,flags -v "$$APP_PATH"; \ codesign -vvv --deep --strict "$$APP_PATH"; \ PRODUCT="$$(basename "$$APP_PATH")"; \ if [ -z "$$PRODUCT" ] || [ "$$PRODUCT" = "." ] || [[ "$$PRODUCT" != *.app ]]; then \ echo "error: unexpected release product name: $$PRODUCT"; \ exit 1; \ fi; \ DST="/Applications/$$PRODUCT"; \ if [ "$$DST" = "/Applications" ] || [ "$$DST" = "/Applications/" ]; then \ echo "error: unsafe install destination: $$DST"; \ exit 1; \ fi; \ case "$$DST" in \ /Applications/*.app) ;; \ *) \ echo "error: refusing to install outside /Applications/*.app: $$DST"; \ exit 1; \ ;; \ esac; \ echo "copying $$APP_PATH -> $$DST"; \ if [ -e "$$DST" ]; then \ if ! command -v trash >/dev/null 2>&1; then \ echo "error: trash command not found; refusing to remove $$DST"; \ exit 1; \ fi; \ echo "moving existing app to Trash: $$DST"; \ trash "$$DST"; \ fi; \ ditto "$$APP_PATH" "$$DST"; \ echo "installed $$DST (Release build, locally signed)" archive: build-ghostty-xcframework embed-cli embed-docs embed-skills ensure-project # Archive Release build for distribution bash -o pipefail -c 'xcodebuild -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -configuration Release -archivePath build/Prowl.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" PROWL_SENTRY_DSN="$(PROWL_SENTRY_DSN)" PROWL_POSTHOG_API_KEY="$(PROWL_POSTHOG_API_KEY)" PROWL_POSTHOG_HOST="$(PROWL_POSTHOG_HOST)" -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcsift -qw --format toon' export-archive: # Export xarchive bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/Prowl.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcsift -qw --format toon' test: ensure-ghostty embed-cli-debug embed-docs embed-skills test-app # Recipe lines, not prerequisites, so that `make -j` cannot start two xcodebuild runs: a second # `xcodebuild test` in the same checkout can hang. The quick suites run first. test-all: # Run every test suite: scripts, CLI, Mac app, iOS mirror (unit and UI), Android mirror $(MAKE) test-scripts $(MAKE) test-cli-unit $(MAKE) test-cli-smoke $(MAKE) test-cli-integration $(MAKE) test $(MAKE) test-mirror-ios $(MAKE) test-mirror-android # Internal: generate only the iOS mirror project. It has no package and does not need the # Mac app's build inputs (GhosttyKit, CLI, bundled resources), so the CI job can skip them. ensure-mirror-ios-project: @cd "$(CURRENT_MAKEFILE_DIR)" && mise exec -- tuist generate --path Mirror/iOS --no-open # Run iOS mirror tests on the iPad simulator. # $(1): result bundle name, $(2): test selection arguments # The UI tests take minutes and xcsift reports only at the end, so a heartbeat on stderr shows # progress, and the raw log stays next to the result bundle. The result bundle keeps failures and # screenshots; the simulator diagnostics collection can wait 10 minutes for a response, so it is off. define run_mirror_ios_tests @set -euo pipefail; \ result_bundle="$(CURRENT_MAKEFILE_DIR)/build/test-results/$(1).xcresult"; \ mkdir -p "$${result_bundle%/*}"; \ destination="$$(python3 "$(CURRENT_MAKEFILE_DIR)/scripts/select_ios_simulator.py" "$(IOS_MIRROR_SIMULATOR_PATTERN)")"; \ rm -rf "$$result_bundle"; \ xcodebuild test -project "$(IOS_MIRROR_PROJECT)" -scheme "$(IOS_MIRROR_SCHEME)" \ -destination "$$destination" -resultBundlePath "$$result_bundle" -parallel-testing-enabled NO \ -collect-test-diagnostics never \ $(2) 2>&1 | tee "$$result_bundle.log" \ | tee >(PROWL_TEST_PROGRESS_LABEL=$(1) PROWL_TEST_PROGRESS_INTERVAL=5 \ awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ | mise exec -- xcsift -w --format toon endef test-mirror-ios: ensure-mirror-ios-project # Run the iOS mirror unit and UI tests $(call run_mirror_ios_tests,mirror-ios,-only-testing:ProwlMirror-iOSTests -only-testing:ProwlMirror-iOSUITests) test-mirror-ios-unit: ensure-mirror-ios-project # Run the iOS mirror unit tests (CI runs only these) $(call run_mirror_ios_tests,mirror-ios-unit,-only-testing:ProwlMirror-iOSTests) test-mirror-android: # Build, unit test and lint the Android mirror (needs JDK 17 and Android SDK 34) cd "$(CURRENT_MAKEFILE_DIR)/Mirror/Android" && ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug test-scripts: # Run tests for the repository's scripts @python3 -m unittest discover -s "$(CURRENT_MAKEFILE_DIR)/scripts" -p 'test_*.py' @bash "$(CURRENT_MAKEFILE_DIR)/scripts/test-performance-measurement-scripts.sh" # Real I/O deadlines run separately from the bulk suite's main-actor work. test-app: ensure-ghostty ensure-project # Run app/unit tests via xcodebuild @set -euo pipefail; \ result_root="$(CURRENT_MAKEFILE_DIR)/build/test-results"; \ mkdir -p "$$result_root"; \ run_xcode_tests() { \ local result_bundle="$$1"; \ local action="$$2"; \ local expected_test_count="$$3"; \ shift 3; \ rm -rf "$$result_bundle"; \ if [ -n "$${PROWL_DERIVED_DATA_PATH:-}" ]; then \ set -- -derivedDataPath "$$PROWL_DERIVED_DATA_PATH" "$$@"; \ fi; \ set +e; \ xcodebuild "$$action" -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -destination "platform=macOS" -resultBundlePath "$$result_bundle" $(TEST_SIGNING_ARGS) -skipMacroValidation -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) -showBuildTimingSummary SWIFT_COMPILATION_MODE=incremental "$$@" 2>&1 | tee "$$result_bundle.log" | tee >(PROWL_TEST_PROGRESS_LABEL="$${result_bundle##*/}" awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) | mise exec -- xcsift -w --format toon; \ local xcodebuild_status=$${PIPESTATUS[0]}; \ set -e; \ if [ "$$action" = "test" ] && [ -d "$$result_bundle" ]; then \ xcrun xcresulttool get log --path "$$result_bundle" --type build --compact > "$$result_bundle.build.json" \ || echo "warning: could not export Xcode build metrics" >&2; \ fi; \ if [ -d "$$result_bundle" ]; then \ xcrun xcresulttool get log --path "$$result_bundle" --type action --compact > "$$result_bundle.action.json" \ || echo "warning: could not export the Xcode test action log" >&2; \ fi; \ if [ "$$xcodebuild_status" -ne 0 ]; then \ bash "$(CURRENT_MAKEFILE_DIR)/scripts/print-xcresult-failures.sh" "$$result_bundle" || true; \ return "$$xcodebuild_status"; \ fi; \ if [ -n "$$expected_test_count" ]; then \ bash "$(CURRENT_MAKEFILE_DIR)/scripts/assert-xcresult-tests.sh" "$$result_bundle" "$$expected_test_count"; \ else \ bash "$(CURRENT_MAKEFILE_DIR)/scripts/assert-xcresult-tests.sh" "$$result_bundle"; \ fi; \ }; \ shell_cancellation_tests=( \ "$(TEST_TARGET)/ShellClientStreamingTests/cancellingRunStreamConsumerTerminatesProcessAfterItIsReady()" \ "$(TEST_TARGET)/ShellClientStreamingTests/runTerminatesReadyProcessWhenCallingTaskIsCancelled()" \ "$(TEST_TARGET)/ShellClientProbeTests/cancellationStopsProbeAndItsChildren()" \ ); \ skip_args=(); \ only_args=(); \ for test_id in "$${shell_cancellation_tests[@]}"; do \ skip_args+=("-skip-testing:$$test_id"); \ only_args+=("-only-testing:$$test_id"); \ done; \ mirror_suites=(MirrorHostTests MirrorDevicePairingTests MirrorConnectionTests MirrorTerminalIntegrationTests MirrorReplicaInputTests); \ mirror_args=(); \ for suite in "$${mirror_suites[@]}"; do \ skip_args+=("-skip-testing:$(TEST_TARGET)/$$suite"); \ mirror_args+=("-only-testing:$(TEST_TARGET)/$$suite"); \ done; \ event_monitor_tests=( \ "$(TEST_TARGET)/GitWorktreeRegistryMonitorTests" \ "$(TEST_TARGET)/CLISocketServerTests/disconnectMonitorActivatesDuringCreation()" \ "$(TEST_TARGET)/CLISocketServerTests/disconnectMonitorOutlivesOriginalDescriptor()" \ ); \ event_args=(); \ for test_id in "$${event_monitor_tests[@]}"; do \ skip_args+=("-skip-testing:$$test_id"); \ event_args+=("-only-testing:$$test_id"); \ done; \ run_xcode_tests "$$result_root/prowl-tests.xcresult" test "" "$${skip_args[@]}"; \ run_xcode_tests "$$result_root/prowl-event-monitor-tests.xcresult" test-without-building "" "$${event_args[@]}"; \ run_xcode_tests "$$result_root/prowl-mirror-tests.xcresult" test-without-building "" "$${mirror_args[@]}"; \ run_xcode_tests "$$result_root/prowl-shell-cancellation-tests.xcresult" test-without-building 3 "$${only_args[@]}" test-cli-smoke: build-cli # Smoke test CLI executable @set -euo pipefail; \ bin="$$(swift build --package-path "$(CLI_PACKAGE)" --show-bin-path)/prowl"; \ tmp_root="$${TMPDIR:-/tmp}"; \ tmp_dir="$$(mktemp -d "$${tmp_root%/}/prowl-smoke.XXXXXX")"; \ trap 'rm -rf "$$tmp_dir"' EXIT; \ help_output="$$("$$bin" --help)"; \ version_output="$$("$$bin" --version)"; \ echo "$$help_output" | grep -q "USAGE:"; \ echo "$$version_output" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$$'; \ socket="$$tmp_dir/cli.sock"; \ response="$$tmp_dir/response.json"; \ PROWL_CLI_SOCKET="$$socket" "$$bin" list --json >"$$response" || true; \ jq -e '.error.code == "APP_NOT_RUNNING"' "$$response" >/dev/null; \ mkdir -p "$$tmp_dir/skills/prowl-cli" "$$tmp_dir/home"; \ cp "$(CURRENT_MAKEFILE_DIR)/skills/prowl-cli/SKILL.md" "$$tmp_dir/skills/prowl-cli/SKILL.md"; \ skills_response="$$tmp_dir/skills.json"; \ PROWL_CLI_SOCKET="$$socket" PROWL_SKILLS_DIR="$$tmp_dir/skills" HOME="$$tmp_dir/home" \ "$$bin" skills list --json >"$$skills_response"; \ jq -e '.ok and .data.action == "list" and (.data.skills | map(.id)) == ["prowl-cli"]' "$$skills_response" >/dev/null test-cli-unit: # Run CLI and mirror relay unit tests via SwiftPM @test_list="$$(swift test --package-path "$(CLI_PACKAGE)" list)"; \ matching_test_count="$$(printf '%s\n' "$$test_list" | grep -Evc '$(CLI_INTEGRATION_TEST_FILTER)' || true)"; \ if [ "$$matching_test_count" -eq 0 ]; then \ echo "error: CLI unit filter matched zero tests" >&2; \ exit 1; \ fi; \ echo "CLI unit filter matched $$matching_test_count test(s)."; \ swift test --package-path "$(CLI_PACKAGE)" --skip-build --skip '$(CLI_INTEGRATION_TEST_FILTER)' 2>&1 \ | tee >(PROWL_TEST_PROGRESS_LABEL=cli-unit awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ | mise exec -- xcsift -w --format toon; \ swift test --package-path "$(RELAY_PACKAGE)" 2>&1 \ | tee >(PROWL_TEST_PROGRESS_LABEL=relay-unit awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ | mise exec -- xcsift -w --format toon test-cli-integration: # Run CLI integration tests via SwiftPM @test_list="$$(swift test --package-path "$(CLI_PACKAGE)" list)"; \ matching_test_count="$$(printf '%s\n' "$$test_list" | grep -Ec '$(CLI_INTEGRATION_TEST_FILTER)' || true)"; \ if [ "$$matching_test_count" -eq 0 ]; then \ echo "error: CLI integration filter matched zero tests: $(CLI_INTEGRATION_TEST_FILTER)" >&2; \ exit 1; \ fi; \ echo "CLI integration filter matched $$matching_test_count test(s)."; \ swift test --package-path "$(CLI_PACKAGE)" --skip-build --filter '$(CLI_INTEGRATION_TEST_FILTER)' 2>&1 \ | tee >(PROWL_TEST_PROGRESS_LABEL=cli-integration awk -f "$(CURRENT_MAKEFILE_DIR)/scripts/test-progress.awk" >&2) \ | mise exec -- xcsift -w --format toon benchmark-build: ensure-ghostty embed-cli-debug embed-docs embed-skills ensure-project # Benchmark clean and compilation-cache build/test time @BUILD_BENCHMARK_ROOT="$(CURRENT_MAKEFILE_DIR)/.build-benchmark/build-time" \ SPM_CACHE_DIR="$(SPM_CACHE_DIR)" \ bash "$(CURRENT_MAKEFILE_DIR)/scripts/benchmark-build.sh" \ "$(BUILD_BENCHMARK_SCENARIO)" "$(BUILD_BENCHMARK_SAMPLES)" bench: ensure-ghostty embed-cli-debug embed-docs embed-skills ensure-project # Run performance benchmarks optimized (-O); append absolute medians to the bench log @set -euo pipefail; \ bench_log_dir="$$HOME/Library/Logs/Prowl/measurements/bench"; \ mkdir -p "$$bench_log_dir"; \ bench_log="$$bench_log_dir/bench.jsonl"; \ touch "$$bench_log"; \ lines_before="$$(wc -l < "$$bench_log")"; \ TEST_RUNNER_PROWL_BENCH_REPORT=1 \ TEST_RUNNER_PROWL_BENCH_GIT_SHA="$$(git rev-parse --short HEAD)" \ TEST_RUNNER_PROWL_BENCH_LOG_DIR="$$bench_log_dir" \ xcodebuild test -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -destination "platform=macOS,arch=$$(uname -m)" \ -configuration Release \ -only-testing:$(TEST_TARGET)/PerformanceBenchmarks \ -parallel-testing-enabled NO \ -derivedDataPath "$(CURRENT_MAKEFILE_DIR)/build/bench-derived-data" \ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation \ -clonedSourcePackagesDirPath $(SPM_CACHE_DIR) \ ENABLE_TESTABILITY=YES 2>&1 | mise exec -- xcsift -w --format toon; \ echo; \ echo "new bench records ($$bench_log):"; \ tail -n "+$$((lines_before + 1))" "$$bench_log" | jq -c . measure-cpu: # Steady-state CPU + per-symbol attribution of the running Prowl Debug app (PROWL_PID=... to target) @bash scripts/measure-agent-detection-cpu.sh capture-spike: # Sample the running Prowl Debug app the moment CPU crosses a threshold (THRESHOLD=150 DURATION=10) @bash scripts/capture-cpu-spike.sh $(or $(THRESHOLD),150) $(or $(DURATION),10) measure-titles: # Black-box check that animated tab titles stay coalesced to ~1 change/s (works on Release builds) @bash scripts/measure-title-coalescing.sh AGENT_VERSIONS_ARGS ?= agent-versions: # Compare installed tier-A agent CLI versions with the managed-hook attestation (AGENT_VERSIONS_ARGS="--json" / "--strict" / "--check-matrix") @python3 "$(CURRENT_MAKEFILE_DIR)/scripts/agent_versions.py" $(AGENT_VERSIONS_ARGS) AGENT_CONTRACT_ARGS ?= test-agent-contracts: # Inventory by default; AGENT_CONTRACT_ARGS="--mode live" opts in to real model/hook checks @python3 "$(CURRENT_MAKEFILE_DIR)/scripts/agent_contracts.py" $(AGENT_CONTRACT_ARGS) _test-agent-contract-codex: ensure-ghostty embed-cli-debug embed-docs embed-skills ensure-project @: "$${PROWL_CONTRACT_CODEX_EXECUTABLE:?Use test-agent-contracts --mode preflight}" \ "$${PROWL_CONTRACT_RECEIPT:?}" "$${PROWL_CONTRACT_NONCE:?}" "$${PROWL_CONTRACT_RESULT:?}" TEST_RUNNER_PROWL_RUN_LIVE_CODEX_CONTRACT=1 \ TEST_RUNNER_PROWL_CONTRACT_CODEX_EXECUTABLE="$$PROWL_CONTRACT_CODEX_EXECUTABLE" \ TEST_RUNNER_PROWL_CONTRACT_RECEIPT="$$PROWL_CONTRACT_RECEIPT" \ TEST_RUNNER_PROWL_CONTRACT_NONCE="$$PROWL_CONTRACT_NONCE" \ xcodebuild test -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -destination "platform=macOS" \ -resultBundlePath "$$PROWL_CONTRACT_RESULT" $(TEST_SIGNING_ARGS) -skipMacroValidation \ -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" SWIFT_COMPILATION_MODE=incremental \ -only-testing:'$(TEST_TARGET)/CodexConfigReadLiveContractTests/scratchPrecedenceAndProjectExclusion()' \ 2>&1 | mise exec -- xcsift -w --format toon _test-agent-contract-export: ensure-ghostty embed-cli-debug embed-docs embed-skills ensure-project @: "$${PROWL_CONTRACT_EXPORT_INPUT:?Use test-agent-contracts --mode live}" \ "$${PROWL_CONTRACT_EXPORT_OUTPUT:?}" "$${PROWL_CONTRACT_NONCE:?}" "$${PROWL_CONTRACT_RESULT:?}" TEST_RUNNER_PROWL_CONTRACT_EXPORT_INPUT="$$PROWL_CONTRACT_EXPORT_INPUT" \ TEST_RUNNER_PROWL_CONTRACT_EXPORT_OUTPUT="$$PROWL_CONTRACT_EXPORT_OUTPUT" \ TEST_RUNNER_PROWL_CONTRACT_NONCE="$$PROWL_CONTRACT_NONCE" \ xcodebuild test -workspace "$(XCODE_WORKSPACE)" -scheme $(APP_SCHEME) -destination "platform=macOS" \ -resultBundlePath "$$PROWL_CONTRACT_RESULT" $(TEST_SIGNING_ARGS) -skipMacroValidation \ -clonedSourcePackagesDirPath "$(SPM_CACHE_DIR)" SWIFT_COMPILATION_MODE=incremental \ -only-testing:'$(TEST_TARGET)/AgentHookContractExportTests/exportPreparedLaunches()' \ 2>&1 | mise exec -- xcsift -w --format toon format: # Format all Swift code with swift-format (full-tree cleanup) swift-format -p --in-place --recursive --configuration ./.swift-format.json $(SWIFT_FORMAT_PATHS) format-changed: # Format Swift files changed from FORMAT_BASE_REF (default: origin/main) @base="$$(git merge-base HEAD "$(FORMAT_BASE_REF)" 2>/dev/null || git rev-parse HEAD)"; \ mapfile -t files < <( \ { \ git diff --name-only --diff-filter=ACMR "$$base" -- $(SWIFT_FORMAT_PATHS); \ git ls-files --others --exclude-standard -- $(SWIFT_FORMAT_PATHS); \ } | awk '/\.swift$$/' | sort -u \ ); \ if [ "$${#files[@]}" -eq 0 ]; then \ echo "No changed Swift files to format."; \ else \ printf 'Formatting %s changed Swift file(s) from %s\n' "$${#files[@]}" "$$base"; \ swift-format -p --in-place --configuration ./.swift-format.json "$${files[@]}"; \ fi format-lint: # Check Swift formatting without rewriting files swift-format lint --strict --recursive --configuration ./.swift-format.json $(SWIFT_FORMAT_PATHS) lint: # Lint code with swiftlint mise exec -- swiftlint lint --quiet --config .swiftlint.yml .PHONY: check-workflow-naming check-workflow-naming: # Check maintained workflow source and references for retired names python3 scripts/check_workflow_naming.py .PHONY: check-legacy-naming check-legacy-naming: # Check that the legacy project name is used only where it must stay (docs-ai 074) python3 scripts/check_legacy_naming.py .PHONY: check-localization audit-localization check-localization: # Check that the string catalog is not broken (no build needed; missing translations are fine) python3 scripts/localization.py check audit-localization: build-app # Release check: missing, unused, and untranslated strings, and unlocalized UI copy (see the sync-l10n skill) python3 scripts/localization.py audit check: format-changed format-lint lint test-scripts check-workflow-naming check-legacy-naming check-localization # Format changed Swift files, then run linters and checks log-stream: # Stream logs from the app via log stream log stream --predicate 'subsystem == "com.onevcat.prowl"' --style compact --color always bump-version: # Bump app version (usage: make bump-version [VERSION=YYYY.M.DD] [BUILD=YYYYMMDD]) @if [ -z "$(VERSION)" ]; then \ version="$$(date +%Y.%-m.%-d)"; \ suffix=1; \ while git rev-parse "v$$version" >/dev/null 2>&1; do \ suffix=$$((suffix + 1)); \ version="$$(date +%Y.%-m.%-d).$$suffix"; \ done; \ else \ if ! echo "$(VERSION)" | grep -qE '^[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}(\.[0-9]+)?$$'; then \ echo "error: VERSION must be in YYYY.M.DD or YYYY.M.DD.N format"; \ exit 1; \ fi; \ version="$(VERSION)"; \ fi; \ if [ -z "$(BUILD)" ]; then \ base_build="$$(date +%Y%m%d)"; \ current_build="$$(/usr/bin/awk -F' = ' '/^CURRENT_PROJECT_VERSION = [0-9]+$$/{print $$2; exit}' "$(VERSION_XCCONFIG)")"; \ if [ "$$current_build" -ge "$$base_build" ] 2>/dev/null; then \ build="$$((current_build + 1))"; \ else \ build="$$base_build"; \ fi; \ else \ if ! echo "$(BUILD)" | grep -qE '^[0-9]+$$'; then \ echo "error: BUILD must be an integer"; \ exit 1; \ fi; \ build="$(BUILD)"; \ fi; \ sed -i '' -E "s/^MARKETING_VERSION = .*/MARKETING_VERSION = $$version/; s/^CURRENT_PROJECT_VERSION = .*/CURRENT_PROJECT_VERSION = $$build/" \ "$(VERSION_XCCONFIG)"; \ if ! grep -qx "MARKETING_VERSION = $$version" "$(VERSION_XCCONFIG)" \ || ! grep -qx "CURRENT_PROJECT_VERSION = $$build" "$(VERSION_XCCONFIG)"; then \ echo "error: failed to write the version to $(VERSION_XCCONFIG)" >&2; \ exit 1; \ fi; \ printf '// Auto-generated by Makefile (sync-cli-version). Do not edit.\n\npublic enum ProwlVersion {\n public static let current = "%s"\n}\n' "$$version" > \ "$(CLI_VERSION_FILE)"; \ git add "$(VERSION_XCCONFIG)" "$(CLI_VERSION_FILE)"; \ git commit -m "bump v$$version"; \ git tag -s "v$$version" -m "v$$version"; \ echo "version bumped to $$version (build $$build), tagged v$$version" .PHONY: embed-mirror-relay-debug embed-mirror-relay-release embed-mirror-relay-debug: swift build --package-path "$(RELAY_PACKAGE)" --product prowl-mirror-relay @mkdir -p "$(RELAY_RESOURCE_DIR)" cp "$$(swift build --package-path "$(RELAY_PACKAGE)" --show-bin-path)/prowl-mirror-relay" "$(RELAY_RESOURCE_DIR)/prowl-mirror-relay" embed-mirror-relay-release: swift build --package-path "$(RELAY_PACKAGE)" -c release --arch arm64 --arch x86_64 --product prowl-mirror-relay @mkdir -p "$(RELAY_RESOURCE_DIR)" cp "$$(swift build --package-path "$(RELAY_PACKAGE)" -c release --arch arm64 --arch x86_64 --show-bin-path)/prowl-mirror-relay" "$(RELAY_RESOURCE_DIR)/prowl-mirror-relay" strip -S -x "$(RELAY_RESOURCE_DIR)/prowl-mirror-relay"