diff --git a/.gitignore b/.gitignore index b5e2abcbc..822644cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ run-tests-automated.sh .tmp/ output/ test-results/ +.keypath-lab/ # Generated coverage reports (produced in CI; never commit) coverage/ diff --git a/External/kanata b/External/kanata index c50826f15..51a01ed23 160000 --- a/External/kanata +++ b/External/kanata @@ -1 +1 @@ -Subproject commit c50826f15329dc9a1eddd59af425ea2d9ba1f392 +Subproject commit 51a01ed2388ab36a2b40bdd3c54b61613d737663 diff --git a/Scripts/build-and-sign.sh b/Scripts/build-and-sign.sh index 2583ca1cd..079331dc5 100755 --- a/Scripts/build-and-sign.sh +++ b/Scripts/build-and-sign.sh @@ -529,6 +529,11 @@ else create_sparkle_archive fi +# A matrix build needs the exact signed artifact but must not replace or restart +# the operator's installed app. Keep the normal release behavior as the default. +if [ "${SKIP_DEPLOY:-0}" = "1" ]; then + echo "Skipping local deployment and restart (SKIP_DEPLOY=1)" +else # Stop running KeyPath and kanata BEFORE replacing the app bundle. # Replacing binaries while the process is live causes macOS to detect # code page mismatches and kill the process with: @@ -598,6 +603,8 @@ fi # Publish help content to website # ───────────────────────────────────────────────────────────────────── +fi + GHPAGES_DIR="$SCRIPT_DIR/../.worktrees/gh-pages" if [ -d "$GHPAGES_DIR" ] && [ "${SKIP_WEBSITE:-0}" != "1" ] && [ "${SKIP_NOTARIZE:-}" != "1" ]; then echo "" diff --git a/Scripts/lab/assert-app-version b/Scripts/lab/assert-app-version new file mode 100755 index 000000000..8bc8f3850 --- /dev/null +++ b/Scripts/lab/assert-app-version @@ -0,0 +1,24 @@ +#!/bin/zsh +set -euo pipefail + +expected=${1:-} +[[ -n "$expected" ]] || { + print -u2 "Usage: $0 EXPECTED_VERSION" + exit 2 +} + +app=/Applications/KeyPath.app +plist="$app/Contents/Info.plist" +[[ -d "$app" && -f "$plist" ]] || { + print -u2 "app version assertion: KeyPath is not installed" + exit 1 +} + +/usr/bin/codesign --verify --deep --strict "$app" +actual=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist") +[[ "$actual" == "$expected" ]] || { + print -u2 "app version assertion: expected $expected, found $actual" + exit 1 +} + +print "app_version\t$actual" diff --git a/Scripts/lab/assert-runtime-state b/Scripts/lab/assert-runtime-state new file mode 100755 index 000000000..8ac4cab44 --- /dev/null +++ b/Scripts/lab/assert-runtime-state @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Independently assert the installed KeyPath runtime is ready or degraded.""" + +import json +import os +import subprocess +import sys + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def main() -> int: + if len(sys.argv) not in {2, 4} or sys.argv[1] not in {"ready", "degraded"}: + print( + f"Usage: {sys.argv[0]} ready|degraded " + "[--managed-policy-manifest PATH]", + file=sys.stderr, + ) + return 2 + managed_manifest = None + if len(sys.argv) == 4: + if sys.argv[2] != "--managed-policy-manifest": + print("runtime assertion: invalid managed-policy option", file=sys.stderr) + return 2 + managed_manifest = sys.argv[3] + + expected = sys.argv[1] + cli = os.environ.get( + "KEYPATH_LAB_CLI", + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", + ) + launchctl = os.environ.get("KEYPATH_LAB_LAUNCHCTL", "/bin/launchctl") + tcp_probe = os.environ.get( + "KEYPATH_LAB_TCP_PROBE", + os.path.join(os.path.dirname(__file__), "probe-kanata-tcp"), + ) + + status_result = run([cli, "service", "status", "--json"]) + if status_result.returncode not in {0, 1}: + print(status_result.stderr, file=sys.stderr, end="") + return status_result.returncode + try: + payload = json.loads(status_result.stdout) + except json.JSONDecodeError as error: + print(f"runtime assertion: invalid service-status JSON: {error}", file=sys.stderr) + return 1 + status = payload.get("data", payload) + if not isinstance(status, dict): + print("runtime assertion: service-status payload is not an object", file=sys.stderr) + return 1 + + launchd_result = run([launchctl, "print", "system/com.keypath.kanata"]) + launchd_running = ( + launchd_result.returncode == 0 + and "state = running" in launchd_result.stdout + ) + + if expected == "degraded": + degraded = ( + status.get("isOperational") is False + and status.get("kanataRunning") is False + and not launchd_running + ) + if not degraded: + print( + "runtime assertion: expected a stopped Kanata service, " + f"got isOperational={status.get('isOperational')!r}, " + f"kanataRunning={status.get('kanataRunning')!r}, " + f"launchdRunning={launchd_running!r}", + file=sys.stderr, + ) + return 1 + print("runtime_state\tdegraded") + return 0 + + managed_accessibility_attested = False + if status.get("kanataAccessibility") is False and managed_manifest: + verify_lane = os.environ.get( + "KEYPATH_LAB_VERIFY_LANE", + os.path.join(os.path.dirname(__file__), "mdm", "verify-lane"), + ) + managed_result = run( + [ + verify_lane, + "managed-functional", + "--manifest", + managed_manifest, + ] + ) + if managed_result.returncode != 0: + print(managed_result.stderr, file=sys.stderr, end="") + return managed_result.returncode + managed_accessibility_attested = True + + required = [ + "isOperational", + "helperInstalled", + "helperWorking", + "keyPathAccessibility", + "keyPathInputMonitoring", + "kanataInputMonitoring", + "kanataBinaryInstalled", + "karabinerDriverInstalled", + "vhidDeviceHealthy", + "kanataRunning", + "karabinerDaemonRunning", + "vhidHealthy", + ] + if not managed_accessibility_attested: + required.append("kanataAccessibility") + failed = [key for key in required if status.get(key) is not True] + if failed or not status.get("helperVersion") or not launchd_running: + print( + "runtime assertion: runtime is not ready; " + f"falseOrMissing={failed}, helperVersion={status.get('helperVersion')!r}, " + f"launchdRunning={launchd_running!r}", + file=sys.stderr, + ) + return 1 + + tcp_result = run([tcp_probe]) + if tcp_result.returncode != 0: + print(tcp_result.stderr, file=sys.stderr, end="") + return tcp_result.returncode + if managed_accessibility_attested: + print("kanata_accessibility_evidence\tmanaged-policy-profile") + print("runtime_state\tready") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/assert-uninstalled-state b/Scripts/lab/assert-uninstalled-state new file mode 100755 index 000000000..d3f754072 --- /dev/null +++ b/Scripts/lab/assert-uninstalled-state @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Independently assert KeyPath-owned state is removed after uninstall.""" + +import argparse +import hashlib +import os +from pathlib import Path +import subprocess +import sys + + +OWNED_SYSTEM_PATHS = ( + "/Applications/KeyPath.app", + "/Library/PrivilegedHelperTools/com.keypath.helper", + "/Library/LaunchDaemons/com.keypath.kanata.plist", + "/Library/LaunchDaemons/com.keypath.karabiner-vhiddaemon.plist", + "/Library/LaunchDaemons/com.keypath.karabiner-vhidmanager.plist", + "/Library/LaunchDaemons/com.keypath.helper.plist", + "/Library/LaunchDaemons/com.keypath.logrotate.plist", + "/Library/KeyPath/bin/kanata", + "/usr/local/etc/kanata", + "/usr/local/bin/keypath-logrotate.sh", + "/etc/newsyslog.d/com.keypath.conf", +) +OWNED_LAUNCHD_LABELS = ( + "system/com.keypath.kanata", + "system/com.keypath.helper", + "system/com.keypath.karabiner-vhiddaemon", + "system/com.keypath.karabiner-vhidmanager", + "system/com.keypath.logrotate", +) +SHARED_DRIVER_ID = "org.pqrs.Karabiner-DriverKit-VirtualHIDDevice" + + +def run(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def rooted(root: Path, path: str) -> Path: + return root / path.removeprefix("/") + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--preserved-config", required=True, type=Path) + parser.add_argument("--expected-sha256", required=True) + args = parser.parse_args() + + root = Path(os.environ.get("KEYPATH_LAB_ROOT", "/")) + home = Path(os.environ.get("KEYPATH_LAB_HOME", str(Path.home()))) + launchctl = os.environ.get("KEYPATH_LAB_LAUNCHCTL", "/bin/launchctl") + pgrep = os.environ.get("KEYPATH_LAB_PGREP", "/usr/bin/pgrep") + systemextensionsctl = os.environ.get( + "KEYPATH_LAB_SYSTEMEXTENSIONSCTL", "/usr/bin/systemextensionsctl" + ) + + failures: list[str] = [] + remaining_paths = [str(rooted(root, path)) for path in OWNED_SYSTEM_PATHS if rooted(root, path).exists()] + user_paths = ( + home / "Library/Application Support/KeyPath", + home / "Library/Logs/KeyPath", + ) + remaining_paths.extend(str(path) for path in user_paths if path.exists()) + remaining_paths.extend(str(path) for path in (home / "Library/Preferences").glob("com.keypath*.plist")) + if remaining_paths: + failures.append("owned paths remain: " + ", ".join(sorted(remaining_paths))) + + active_labels = [label for label in OWNED_LAUNCHD_LABELS if run([launchctl, "print", label]).returncode == 0] + if active_labels: + failures.append("owned launchd jobs remain: " + ", ".join(active_labels)) + + if run([pgrep, "-f", "/Applications/KeyPath.app"]).returncode == 0: + failures.append("a KeyPath application process is still running") + + if not args.preserved_config.is_file(): + failures.append(f"preserved configuration sentinel is missing: {args.preserved_config}") + elif sha256(args.preserved_config) != args.expected_sha256: + failures.append("preserved configuration sentinel changed during uninstall") + + extensions = run([systemextensionsctl, "list"]) + driver_lines = [line for line in extensions.stdout.splitlines() if SHARED_DRIVER_ID in line] + if extensions.returncode != 0: + failures.append("system extension inventory failed") + elif not any("activated enabled" in line for line in driver_lines): + failures.append("the shared VirtualHID driver was removed or is not activated and enabled") + + if failures: + for failure in failures: + print(f"uninstall assertion: {failure}", file=sys.stderr) + return 1 + + print("keypath_owned_state\tremoved") + print("user_configuration\tpreserved") + print("shared_vhid_driver\tpreserved") + print("uninstall_state\tverified") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/assets/peekaboo-lab-host-arm64 b/Scripts/lab/assets/peekaboo-lab-host-arm64 new file mode 100755 index 000000000..b12704a37 Binary files /dev/null and b/Scripts/lab/assets/peekaboo-lab-host-arm64 differ diff --git a/Scripts/lab/assets/peekaboo-lab-host.c b/Scripts/lab/assets/peekaboo-lab-host.c new file mode 100644 index 000000000..fa99288dd --- /dev/null +++ b/Scripts/lab/assets/peekaboo-lab-host.c @@ -0,0 +1,44 @@ +#include +#include +#include +#include +#include +#include + +int main(void) { + const char *home = getenv("HOME"); + char executable[PATH_MAX]; + char support[PATH_MAX]; + char socket_path[PATH_MAX]; + + if (home == NULL || home[0] != '/') { + return 64; + } + if (snprintf(executable, sizeof(executable), "%s/.local/bin/peekaboo", home) >= + (int)sizeof(executable) || + snprintf(support, sizeof(support), "%s/Library/Application Support/Peekaboo", home) >= + (int)sizeof(support) || + snprintf(socket_path, sizeof(socket_path), "%s/daemon.sock", support) >= + (int)sizeof(socket_path)) { + return 65; + } + + if (mkdir(support, 0700) != 0 && errno != EEXIST) { + return 73; + } + unlink(socket_path); + execl(executable, + executable, + "daemon", + "run", + "--mode", + "manual", + "--bridge-socket", + socket_path, + "--idle-timeout-seconds", + "31536000", + "--input-strategy", + "actionFirst", + (char *)NULL); + return 127; +} diff --git a/Scripts/lab/capture-controller-state b/Scripts/lab/capture-controller-state new file mode 100755 index 000000000..9802a7d06 --- /dev/null +++ b/Scripts/lab/capture-controller-state @@ -0,0 +1,38 @@ +#!/bin/zsh +set -euo pipefail + +output="$PWD/.keypath-lab/scenario-output/controller-capture" +logs="$output/logs" +service_logs="$output/service-logs" +capture_home="${KEYPATH_CAPTURE_USER_HOME:-$HOME}" + +mkdir -p "$logs" "$service_logs" +sw_vers > "$output/sw-vers.txt" +date -u +%Y-%m-%dT%H:%M:%SZ > "$output/captured-at.txt" +cp -R "$capture_home/Library/Logs/KeyPath/." "$logs/" 2>/dev/null || true + +/bin/launchctl print system/com.keypath.kanata > "$output/kanata-launchd.txt" 2>&1 || true +/bin/ps -axo pid,ppid,user,state,lstart,command > "$output/processes.txt" 2>&1 || true +/usr/bin/log show --last 10m --style compact \ + --predicate 'process == "kanata" OR process == "kanata-launcher" OR eventMessage CONTAINS[c] "com.keypath.kanata"' \ + > "$output/kanata-unified.log" 2>&1 || true + +for source in \ + /var/log/com.keypath.kanata.stdout.log \ + /var/log/com.keypath.kanata.stderr.log +do + if [[ -r "$source" ]]; then + cp "$source" "$service_logs/${source:t}" + elif /usr/bin/sudo -n /usr/bin/test -r "$source" 2>/dev/null; then + /usr/bin/sudo -n /bin/cp "$source" "$service_logs/${source:t}" 2>/dev/null || true + fi +done + +if [[ -x /Applications/KeyPath.app/Contents/MacOS/keypath-cli ]]; then + /Applications/KeyPath.app/Contents/MacOS/keypath-cli system inspect --json \ + > "$output/system-inspect.json" 2>/dev/null || true +fi + +if (( EUID == 0 )) && [[ -n "${KEYPATH_CAPTURE_OWNER:-}" ]]; then + /usr/sbin/chown -R "$KEYPATH_CAPTURE_OWNER":staff "$output" 2>/dev/null || true +fi diff --git a/Scripts/lab/damage-kanata-service b/Scripts/lab/damage-kanata-service new file mode 100755 index 000000000..6750e3321 --- /dev/null +++ b/Scripts/lab/damage-kanata-service @@ -0,0 +1,20 @@ +#!/bin/zsh +set -euo pipefail + +plist=/Library/LaunchDaemons/com.keypath.kanata.plist +if [[ -f "$plist" ]]; then + /usr/bin/sudo -n /bin/launchctl bootout system "$plist" +else + /usr/bin/sudo -n /bin/launchctl bootout system/com.keypath.kanata +fi + +for _ in {1..20}; do + if ! /bin/launchctl print system/com.keypath.kanata >/dev/null 2>&1; then + print "damage_fixture\tkanata-service-booted-out" + exit 0 + fi + sleep 0.25 +done + +print -u2 "damage fixture: Kanata service remained registered" +exit 1 diff --git a/Scripts/lab/desktop-bootstrap b/Scripts/lab/desktop-bootstrap index 032957a09..fcd1d6616 100755 --- a/Scripts/lab/desktop-bootstrap +++ b/Scripts/lab/desktop-bootstrap @@ -1,6 +1,19 @@ #!/bin/zsh set -euo pipefail +SCRIPT_DIR=${0:A:h} +PEEKABOO_VERSION="3.9.8" +PEEKABOO_URL="https://github.com/openclaw/Peekaboo/releases/download/v${PEEKABOO_VERSION}/peekaboo-macos-universal.tar.gz" +PEEKABOO_SHA256="0336348cb690ccd10834dfb571daf79381dbc06f9a1fc58b8642cbeb388993ed" +PEEKABOO_HOST_SHA256="d1f772b1a6384cbf2f178ff4c924c8007c65a635ca5df7a67864f2aef11ff351" +MCPORTER_VERSION="0.12.3" +MCPORTER_URL="https://github.com/openclaw/mcporter/releases/download/v${MCPORTER_VERSION}/mcporter-macos-arm64-v${MCPORTER_VERSION}.tar.gz" +MCPORTER_SHA256="bb896bde3b7e2eef0a700bcd2598678f4f4184306e63b2e12465d780a1184b78" +UV_VERSION="0.11.32" +UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-aarch64-apple-darwin.tar.gz" +UV_SHA256="ed336d0ba49db8ef89b2b41fffa372ce63bd032f22a56f001c265891aec32829" +PYTHON_VERSION="3.12.13" + die() { print -u2 "desktop-bootstrap: $*"; exit 1; } usage() { @@ -8,6 +21,147 @@ usage() { exit 2 } +download_verified() { + local url=$1 expected=$2 destination=$3 actual + /usr/bin/curl --proto '=https' --tlsv1.2 -LfsS "$url" -o "$destination" + actual=$(/usr/bin/shasum -a 256 "$destination" | /usr/bin/awk '{print $1}') + [[ "$actual" == "$expected" ]] || die "checksum mismatch for ${url:t}" +} + +run_bounded() { + local timeout_seconds=$1 + shift + local pid status=0 elapsed=0 + [[ "$timeout_seconds" == <-> && "$timeout_seconds" -gt 0 ]] || + die "invalid command timeout: $timeout_seconds" + "$@" & + pid=$! + while /bin/kill -0 "$pid" >/dev/null 2>&1; do + if (( elapsed >= timeout_seconds * 10 )); then + /bin/kill -TERM "$pid" >/dev/null 2>&1 || true + wait "$pid" >/dev/null 2>&1 || true + return 124 + fi + sleep 0.1 + (( elapsed += 1 )) + done + wait "$pid" || status=$? + return "$status" +} + +install_desktop_tools() { + local temp local_bin local_lib peekaboo_home host_app host_contents host_executable + local host_launcher_source host_launcher_sha launch_agents host_launch_agent + local python_candidate python_ready=0 + temp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/keypath-desktop-tools.XXXXXX") + trap "/bin/rm -rf '$temp'" EXIT + local_bin="$HOME/.local/bin" + local_lib="$HOME/.local/libexec" + peekaboo_home="$local_lib/peekaboo-$PEEKABOO_VERSION" + /bin/mkdir -p "$local_bin" "$local_lib" + + if [[ ! -x "$local_bin/peekaboo" ]]; then + download_verified "$PEEKABOO_URL" "$PEEKABOO_SHA256" "$temp/peekaboo.tar.gz" + /usr/bin/tar -xzf "$temp/peekaboo.tar.gz" -C "$temp" + /bin/mkdir -p "$peekaboo_home" + /bin/cp -R "$temp/peekaboo-macos-universal/." "$peekaboo_home/" + /bin/ln -sfn "$peekaboo_home/peekaboo" "$local_bin/peekaboo" + fi + + # TCC cannot grant Screen Recording to a standalone executable selected in + # System Settings. Give the signed Peekaboo daemon a stable application + # identity and launch it from the logged-in Aqua session. Tests invoked over + # SSH then use the approved bridge instead of widening Screen Recording to + # every remote shell. + host_app="$HOME/Applications/Peekaboo Lab Host.app" + host_contents="$host_app/Contents" + host_executable="$host_contents/MacOS/PeekabooLabHost" + host_launcher_source="$SCRIPT_DIR/assets/peekaboo-lab-host-arm64" + [[ -x "$host_launcher_source" ]] || die "Peekaboo Lab Host launcher is unavailable" + host_launcher_sha=$(/usr/bin/shasum -a 256 "$host_launcher_source" | /usr/bin/awk '{print $1}') + [[ "$host_launcher_sha" == "$PEEKABOO_HOST_SHA256" ]] || + die "Peekaboo Lab Host launcher checksum mismatch" + /bin/mkdir -p "$host_contents/MacOS" "$HOME/Applications" + /bin/cat > "$host_contents/Info.plist" <<'PLIST' + + + + + CFBundleExecutable + PeekabooLabHost + CFBundleIdentifier + com.keypath.lab.peekaboo-host + CFBundleName + Peekaboo Lab Host + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + LSBackgroundOnly + + NSAccessibilityUsageDescription + Drive the disposable KeyPath lab desktop for automated validation. + NSScreenCaptureUsageDescription + Capture the disposable KeyPath lab desktop for automated validation. + + +PLIST + /bin/cp "$host_launcher_source" "$host_executable" + /bin/chmod 0755 "$host_executable" + /usr/bin/plutil -lint "$host_contents/Info.plist" >/dev/null + /usr/bin/codesign --force --sign - --identifier com.keypath.lab.peekaboo-host "$host_app" + /usr/bin/codesign --verify --strict "$host_app" + + launch_agents="$HOME/Library/LaunchAgents" + host_launch_agent="$launch_agents/com.keypath.lab.peekaboo-host.plist" + /bin/mkdir -p "$launch_agents" + /bin/cat > "$host_launch_agent" < + + + + Label + com.keypath.lab.peekaboo-host + ProgramArguments + + /usr/bin/open + -gj + $host_app + + RunAtLoad + + + +PLIST + /usr/bin/plutil -lint "$host_launch_agent" >/dev/null + + if [[ ! -x "$local_bin/mcporter" ]]; then + download_verified "$MCPORTER_URL" "$MCPORTER_SHA256" "$temp/mcporter.tar.gz" + /usr/bin/tar -xzf "$temp/mcporter.tar.gz" -C "$temp" + /bin/cp "$temp/mcporter" "$local_bin/mcporter" + /bin/chmod 0755 "$local_bin/mcporter" + fi + + if [[ ! -x "$local_bin/uv" ]]; then + download_verified "$UV_URL" "$UV_SHA256" "$temp/uv.tar.gz" + /usr/bin/tar -xzf "$temp/uv.tar.gz" -C "$temp" + /bin/cp "$temp/uv-aarch64-apple-darwin/uv" "$temp/uv-aarch64-apple-darwin/uvx" "$local_bin/" + /bin/chmod 0755 "$local_bin/uv" "$local_bin/uvx" + fi + + export PATH="$local_bin:$PATH" + python_candidate=$(command -v python3 || true) + if [[ -n "$python_candidate" ]]; then + if [[ "$python_candidate" != "/usr/bin/python3" ]] || /usr/bin/xcode-select -p >/dev/null 2>&1; then + "$python_candidate" -c 'import sys; raise SystemExit(sys.version_info < (3, 12))' >/dev/null 2>&1 && + python_ready=1 + fi + fi + if (( ! python_ready )); then + "$local_bin/uv" python install --default "$PYTHON_VERSION" + fi +} + output= install_tools=0 while [[ $# -gt 0 ]]; do @@ -25,12 +179,10 @@ console_user=$(stat -f '%Su' /dev/console) [[ "$console_user" == "$USER" ]] || die "command user $USER does not own console session $console_user" if (( install_tools )); then - command -v brew >/dev/null || die "Homebrew is required to install desktop tools" - command -v python3 >/dev/null || brew install python - command -v peekaboo >/dev/null || brew install steipete/tap/peekaboo - command -v mcporter >/dev/null || brew install steipete/tap/mcporter + install_desktop_tools fi +export PATH="$HOME/.local/bin:$PATH" python_bin=$(command -v python3 || true) [[ -x "$python_bin" ]] || die "Python 3 is required" peekaboo_bin=$(command -v peekaboo || true) @@ -44,28 +196,39 @@ mcporter_bin=$(command -v mcporter || true) open -a "System Settings" sleep 2 # The first capture can display macOS's private-picker bypass consent sheet. -"$peekaboo_bin" see --app "System Settings" --json > "$output/initial-desktop.json" || true -"$peekaboo_bin" see --app UserNotificationCenter --json > "$output/user-notification-center.json" || true - -consent=$("$python_bin" - "$output/user-notification-center.json" <<'PY' -import json, sys -try: - data = json.load(open(sys.argv[1])).get("data", {}) -except Exception: - raise SystemExit(1) -for element in data.get("ui_elements", []): - if element.get("is_actionable") and element.get("label") == "Allow": - print(data["snapshot_id"], element["id"], sep="\t") - break -PY -) || true - -if [[ -n "$consent" ]]; then - snapshot=${consent%%$'\t'*} - element=${consent#*$'\t'} - "$peekaboo_bin" click --on "$element" --snapshot "$snapshot" --no-auto-focus --json \ - > "$output/peekaboo-consent-click.json" +initial_capture_result=0 +"$peekaboo_bin" see --app "System Settings" --json > "$output/initial-desktop.json" || initial_capture_result=$? +capture_prompt=$(run_bounded 10 /usr/bin/osascript -l JavaScript -e ' +function run() { + var systemEvents = Application("System Events"); + var full = systemEvents.processes.whose({name: "NotificationCenter"})(); + if (full.length && full[0].windows().length) { + var fullWindow = full[0].windows[0]; + try { + var fullSize = fullWindow.size(); + var fullMessage = fullWindow.staticTexts().map(function(item) { return item.value() || ""; }).join(" ").toLowerCase(); + var mentionsScreenCapture = fullMessage.indexOf("screen") !== -1 && + (fullMessage.indexOf("record") !== -1 || fullMessage.indexOf("capture") !== -1 || fullMessage.indexOf("share") !== -1); + if (fullWindow.subrole() === "AXSystemDialog" && fullSize[0] === 1024 && fullSize[1] === 768 && mentionsScreenCapture) return "screen-capture"; + } catch (_) {} + } + var privateCenter = systemEvents.processes.whose({name: "UserNotificationCenter"})(); + if (privateCenter.length && privateCenter[0].windows().length) { + var privateWindow = privateCenter[0].windows[0]; + try { + var message = privateWindow.staticTexts().map(function(item) { return item.value() || ""; }).join(" "); + if (privateWindow.subrole() === "AXSystemDialog" && message.indexOf("boo.peekaboo.peekaboo") !== -1 && message.indexOf("private window picker") !== -1) return "private-window-picker"; + } catch (_) {} + } + return ""; +} +' 2>/dev/null || true) +if [[ -n "$capture_prompt" ]]; then + printf '{"status":"waiting","checkpoint":"%s"}\n' "$capture_prompt" > "$output/capture-consent-checkpoint.json" + print "desktop_bootstrap_checkpoint\t$capture_prompt" + exit 4 fi +(( initial_capture_result == 0 )) || die "initial Peekaboo capture failed without a visible consent prompt" "$peekaboo_bin" see --app "System Settings" --json > "$output/desktop-ready.json" "$peekaboo_bin" permissions status --json > "$output/peekaboo-permissions-after.json" diff --git a/Scripts/lab/hid-capture-jig-client b/Scripts/lab/hid-capture-jig-client new file mode 100755 index 000000000..7a538f209 --- /dev/null +++ b/Scripts/lab/hid-capture-jig-client @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Control the KeyPath HID Capture Jig through its local file-RPC channel.""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import pathlib +import sys +import time +import uuid + + +def state_directory() -> pathlib.Path: + override = os.environ.get("KEYPATH_CAPTURE_JIG_STATE_DIR") + if override: + return pathlib.Path(override) + return pathlib.Path.home() / ".local/state/keypath-hid-capture-jig" + + +def atomic_json_write(path: pathlib.Path, value: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +def send(action: str, **fields: object) -> dict[str, object]: + directory = state_directory() + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + lock_path = directory / "command.lock" + with lock_path.open("a+") as lock: + lock_path.chmod(0o600) + # The Jig intentionally exposes one command/response slot. Serialize every + # client for the entire request so a readiness probe cannot overwrite Focus, + # Arm, Finalize, or another status request before its response arrives. + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + request_id = str(uuid.uuid4()) + command = {"id": request_id, "action": action} + command.update({key: value for key, value in fields.items() if value is not None}) + atomic_json_write(directory / "command.json", command) + + timeout = float(os.environ.get("KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT", "5")) + deadline = time.monotonic() + timeout + response_path = directory / "response.json" + while time.monotonic() < deadline: + try: + response = json.loads(response_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + time.sleep(0.025) + continue + if response.get("id") == request_id: + return response + time.sleep(0.025) + raise RuntimeError( + f"HID Capture Jig did not answer within {timeout:g}s; launch it with " + "Scripts/lab/hid-capture-jig-tool open" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status") + commands.add_parser("focus") + commands.add_parser("reset") + commands.add_parser("finalize") + commands.add_parser("quit") + commands.add_parser("bear-reset") + + bear_prepare = commands.add_parser( + "bear-prepare", help="show the Typover Bear focus gate without scheduling keys" + ) + bear_prepare.add_argument("--run-id", required=True) + bear_prepare.add_argument("--case-count", required=True, type=int) + bear_prepare.add_argument("--message", required=True) + + bear_begin = commands.add_parser( + "bear-begin", help="show the nonactivating Typover Bear monitor" + ) + bear_begin.add_argument("--run-id", required=True) + bear_begin.add_argument("--case-index", required=True, type=int) + bear_begin.add_argument("--case-count", required=True, type=int) + bear_begin.add_argument("--interval-ms", required=True, type=int) + bear_begin.add_argument("--word-count", required=True, type=int) + bear_begin.add_argument("--scheduled-text", required=True, type=pathlib.Path) + bear_begin.add_argument("--start-delay-ms", required=True, type=int) + bear_begin.add_argument("--bear-focused", action="store_true") + + bear_update = commands.add_parser( + "bear-update", help="update Typover Bear progress or verified results" + ) + bear_update.add_argument( + "--phase", required=True, + choices=("preparing", "waitingForBear", "countdown", "typing", "analyzing", + "passed", "safeMisses", "failed"), + ) + bear_update.add_argument("--corrected-words", type=int) + bear_update.add_argument("--missed-words", type=int) + bear_update.add_argument("--message") + bear_update.add_argument("--bear-focused", action=argparse.BooleanOptionalAction) + + arm = commands.add_parser("arm") + arm.add_argument("--run-id", required=True) + arm.add_argument("--expected", required=True, type=pathlib.Path) + arm.add_argument("--timeout-ms", type=int, default=10_000) + arm.add_argument("--settle-ms", type=int, default=250) + arm.add_argument("--instruction") + arm.add_argument( + "--demo-mode", action="store_true", + help="bypass host-resource admission for a non-acceptance demo", + ) + + wait = commands.add_parser("wait") + wait.add_argument("--timeout-seconds", type=float, default=15.0) + wait.add_argument("--poll-ms", type=int, default=100) + return parser + + +def print_response(response: dict[str, object]) -> None: + print(json.dumps(response, indent=2, sort_keys=True)) + + +def main() -> int: + arguments = build_parser().parse_args() + try: + if arguments.command == "arm": + if not arguments.expected.is_file(): + raise RuntimeError(f"expected-output file does not exist: {arguments.expected}") + response = send( + "arm", + runID=arguments.run_id, + expected=arguments.expected.read_text(), + timeoutMs=arguments.timeout_ms, + settleMs=arguments.settle_ms, + instruction=arguments.instruction, + demoMode=arguments.demo_mode, + ) + elif arguments.command == "bear-prepare": + response = send( + "bear-monitor-prepare", + runID=arguments.run_id, + caseCount=arguments.case_count, + message=arguments.message, + ) + elif arguments.command == "bear-begin": + if not arguments.scheduled_text.is_file(): + raise RuntimeError( + f"scheduled-text file does not exist: {arguments.scheduled_text}" + ) + response = send( + "bear-monitor-begin", + runID=arguments.run_id, + caseIndex=arguments.case_index, + caseCount=arguments.case_count, + intervalMs=arguments.interval_ms, + wordCount=arguments.word_count, + scheduledText=arguments.scheduled_text.read_text(), + startDelayMs=arguments.start_delay_ms, + bearFocused=arguments.bear_focused, + ) + elif arguments.command == "bear-update": + response = send( + "bear-monitor-update", + phase=arguments.phase, + correctedWords=arguments.corrected_words, + missedWords=arguments.missed_words, + message=arguments.message, + bearFocused=arguments.bear_focused, + ) + elif arguments.command == "bear-reset": + response = send("bear-monitor-reset") + elif arguments.command == "wait": + deadline = time.monotonic() + arguments.timeout_seconds + response = send("status") + while time.monotonic() < deadline: + state = response.get("snapshot", {}).get("state") + if state in {"passed", "failed"}: + print_response(response) + return 0 if state == "passed" else 1 + time.sleep(arguments.poll_ms / 1000) + response = send("status") + response = send("finalize") + else: + response = send(arguments.command) + except (OSError, ValueError, RuntimeError) as error: + print(f"hid-capture-jig-client: {error}", file=sys.stderr) + return 2 + + print_response(response) + return 0 if response.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/hid-capture-jig-tool b/Scripts/lab/hid-capture-jig-tool new file mode 100755 index 000000000..d79c63ab1 --- /dev/null +++ b/Scripts/lab/hid-capture-jig-tool @@ -0,0 +1,154 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +package_dir="$script_dir/hid-capture-jig" +client=${KEYPATH_CAPTURE_JIG_CLIENT:-"$script_dir/hid-capture-jig-client"} +physical_runner=${KEYPATH_PHYSICAL_HID_RUNNER:-"$script_dir/physical-hid-capture-run"} +build_app=${KEYPATH_CAPTURE_JIG_BUILD_APP:-"$package_dir/build-app.sh"} +app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} + +usage() { + printf '%s\n' \ + "Usage: Scripts/lab/hid-capture-jig-tool COMMAND" \ + "" \ + "Commands:" \ + " build Build and ad-hoc sign the isolated capture app" \ + " test Run capture-core and control-client tests" \ + " open Open, focus, and verify the cached capture app" \ + " showroom Run the zero-touch ESP32-to-Jig demo and preserve evidence" \ + " demo Arm the offline top-power/touch demo and wait for its result" \ + " status Query the running capture app" \ + " close Ask the capture app to quit" +} + +app_binary_hash() { + if [[ -f "$app_path/Contents/MacOS/HIDCaptureJig" ]]; then + shasum -a 256 "$app_path/Contents/MacOS/HIDCaptureJig" | awk '{print $1}' + fi +} + +wait_for_app_exit() { + for _ in {1..50}; do + pgrep -x HIDCaptureJig >/dev/null || return 0 + sleep 0.1 + done + printf '%s\n' "hid-capture-jig-tool: existing capture app did not quit" >&2 + return 1 +} + +open_app() { + force_restart=${1:-0} + was_running=0 + pgrep -x HIDCaptureJig >/dev/null && was_running=1 + before_hash=$(app_binary_hash) + "$build_app" >/dev/null + after_hash=$(app_binary_hash) + + if [[ "$was_running" -eq 1 && "$before_hash" == "$after_hash" && "$force_restart" -eq 0 ]]; then + "$client" focus >/dev/null + else + if [[ "$was_running" -eq 1 ]]; then + "$client" quit >/dev/null 2>&1 || true + wait_for_app_exit + fi + open "$app_path" + fi + + result=$(mktemp -t keypath-hid-capture-jig-status.XXXXXX) + for _ in {1..50}; do + if KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT=0.2 "$client" status >"$result" 2>/dev/null; then + cat "$result" + rm -f "$result" + return 0 + fi + sleep 0.1 + done + rm -f "$result" + printf '%s\n' "hid-capture-jig-tool: app opened but did not become controllable" >&2 + return 1 +} + +case ${1:-} in + build) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + "$package_dir/build-app.sh" + ;; + test) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + swift test --package-path "$package_dir" --jobs "${KEYPATH_CAPTURE_JIG_BUILD_JOBS:-4}" + python3 "$script_dir/tests/hid-capture-jig-client-tests.py" + python3 "$script_dir/tests/hid-capture-jig-tool-tests.py" + ;; + open) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + open_app + ;; + showroom) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + # A separately rebuilt app may still have an older executable resident + # in memory. Always relaunch before real HID so the client and Jig use + # the exact same protocol and admission policy. + open_app 1 >/dev/null + run_id="showroom-$(date -u +%Y%m%dT%H%M%SZ)" + artifact="$HOME/.local/state/keypath-hid-capture-jig/artifacts/$run_id.json" + printf '%s\n' \ + "showroom running ESP32 -> real USB HID -> independent Jig" \ + "duration 20 seconds of animated typing" \ + "expected 14 exact KeyPath demo OK + Return cycles" + "$physical_runner" \ + --run-id "$run_id" \ + --text "$script_dir/tests/fixtures/showroom-demo.txt" \ + --interval-ms 60 \ + --hold-ms 16 \ + --shift-lead-ms 5 \ + --shift-release-lag-ms 5 \ + --repeat 14 \ + --cycle-gap-ms 469 \ + --delay-ms 800 \ + --settle-ms 300 \ + --timeout-ms 35000 \ + --demo-mode \ + --output "$artifact" + ;; + demo) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + open_app 1 >/dev/null + expected=$(mktemp -t keypath-hid-demo-expected.XXXXXX) + trap 'rm -f "$expected"' EXIT + for _ in {1..14}; do + printf 'KeyPath demo OK\n' >>"$expected" + done + "$client" reset >/dev/null + "$client" focus >/dev/null + "$client" arm --run-id offline-demo --expected "$expected" \ + --timeout-ms 30000 --settle-ms 300 \ + --demo-mode \ + --instruction "PRESS TOP POWER, THEN TAP DEVICE" >/dev/null + printf '%s\n' \ + "demo armed PRESS TOP POWER, THEN TAP THE DEVICE SCREEN" \ + "waiting exact 20 second Jig result (30 second limit)" + "$client" wait --timeout-seconds 31 --poll-ms 50 + ;; + status) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + "$client" status + ;; + close) + [[ $# -eq 1 ]] || { usage >&2; exit 2; } + "$client" quit + for _ in {1..50}; do + pgrep -x HIDCaptureJig >/dev/null || exit 0 + sleep 0.1 + done + printf '%s\n' "hid-capture-jig-tool: capture app did not quit" >&2 + exit 1 + ;; + help|-h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/Scripts/lab/hid-capture-jig/Package.swift b/Scripts/lab/hid-capture-jig/Package.swift new file mode 100644 index 000000000..068af15d7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "KeyPathHIDCaptureJig", + platforms: [.macOS(.v14)], + products: [ + .library(name: "HIDCaptureCore", targets: ["HIDCaptureCore"]), + .executable(name: "HIDCaptureJig", targets: ["HIDCaptureJig"]), + ], + targets: [ + .target(name: "HIDCaptureCore"), + .executableTarget( + name: "HIDCaptureJig", + dependencies: ["HIDCaptureCore"] + ), + .testTarget( + name: "HIDCaptureCoreTests", + dependencies: ["HIDCaptureCore"] + ), + ], + swiftLanguageModes: [.v5] +) diff --git a/Scripts/lab/hid-capture-jig/Resources/Info.plist b/Scripts/lab/hid-capture-jig/Resources/Info.plist new file mode 100644 index 000000000..4701b41e7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + HIDCaptureJig + CFBundleIdentifier + com.keypath.lab.HIDCaptureJig + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + KeyPath HID Capture Jig + CFBundleIconFile + AppIcon + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + + diff --git a/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift b/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift new file mode 100644 index 000000000..9373517e6 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Resources/generate_jig_icon.swift @@ -0,0 +1,193 @@ +import AppKit +import Foundation + +guard CommandLine.arguments.count == 2 else { + fputs("usage: generate_jig_icon.swift OUTPUT.iconset\n", stderr) + exit(2) +} + +let outputDirectory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true +) + +struct IconVariant { + let points: Int + let scale: Int + + var filename: String { + scale == 1 + ? "icon_\(points)x\(points).png" + : "icon_\(points)x\(points)@\(scale)x.png" + } +} + +let variants = [ + IconVariant(points: 16, scale: 1), + IconVariant(points: 16, scale: 2), + IconVariant(points: 32, scale: 1), + IconVariant(points: 32, scale: 2), + IconVariant(points: 128, scale: 1), + IconVariant(points: 128, scale: 2), + IconVariant(points: 256, scale: 1), + IconVariant(points: 256, scale: 2), + IconVariant(points: 512, scale: 1), + IconVariant(points: 512, scale: 2), +] + +func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(calibratedRed: red, green: green, blue: blue, alpha: alpha) +} + +func roundedRect(_ rect: NSRect, radius: CGFloat, fill: NSColor, stroke: NSColor? = nil, + lineWidth: CGFloat = 1) +{ + let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) + fill.setFill() + path.fill() + if let stroke { + stroke.setStroke() + path.lineWidth = lineWidth + path.stroke() + } +} + +func render(size: CGFloat) -> NSImage { + let image = NSImage(size: NSSize(width: size, height: size)) + image.lockFocus() + defer { image.unlockFocus() } + + NSGraphicsContext.current?.imageInterpolation = .high + let canvas = NSRect(x: 0, y: 0, width: size, height: size) + NSColor.clear.setFill() + canvas.fill() + + let tile = canvas.insetBy(dx: size * 0.045, dy: size * 0.045) + let tilePath = NSBezierPath( + roundedRect: tile, + xRadius: size * 0.205, + yRadius: size * 0.205 + ) + NSGraphicsContext.saveGraphicsState() + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.42) + shadow.shadowBlurRadius = size * 0.055 + shadow.shadowOffset = NSSize(width: 0, height: -size * 0.025) + shadow.set() + color(0.035, 0.075, 0.095).setFill() + tilePath.fill() + NSGraphicsContext.restoreGraphicsState() + + let background = NSGradient(colors: [ + color(0.16, 0.28, 0.32), + color(0.025, 0.07, 0.09), + ])! + background.draw(in: tilePath, angle: -72) + color(0.42, 0.60, 0.62, 0.45).setStroke() + tilePath.lineWidth = max(1, size * 0.009) + tilePath.stroke() + + // Machined base plate and its four registration bolts. + let plate = NSRect( + x: size * 0.17, y: size * 0.20, + width: size * 0.66, height: size * 0.49 + ) + roundedRect( + plate, + radius: size * 0.075, + fill: color(0.60, 0.70, 0.70), + stroke: color(0.84, 0.91, 0.89, 0.78), + lineWidth: size * 0.012 + ) + let insetPlate = plate.insetBy(dx: size * 0.035, dy: size * 0.035) + roundedRect( + insetPlate, + radius: size * 0.055, + fill: color(0.10, 0.18, 0.20), + stroke: color(0.03, 0.07, 0.08), + lineWidth: size * 0.008 + ) + for point in [ + NSPoint(x: plate.minX + size * 0.055, y: plate.minY + size * 0.055), + NSPoint(x: plate.maxX - size * 0.055, y: plate.minY + size * 0.055), + NSPoint(x: plate.minX + size * 0.055, y: plate.maxY - size * 0.055), + NSPoint(x: plate.maxX - size * 0.055, y: plate.maxY - size * 0.055), + ] { + let bolt = NSRect( + x: point.x - size * 0.018, y: point.y - size * 0.018, + width: size * 0.036, height: size * 0.036 + ) + color(0.82, 0.91, 0.89).setFill() + NSBezierPath(ovalIn: bolt).fill() + } + + // Opposing clamp jaws make this read as a fixture instead of a keyboard app. + let jawColor = color(0.76, 0.84, 0.82) + let jawEdge = color(0.38, 0.52, 0.53) + let leftPost = NSRect(x: size * 0.22, y: size * 0.31, width: size * 0.13, height: size * 0.27) + let rightPost = NSRect(x: size * 0.65, y: size * 0.31, width: size * 0.13, height: size * 0.27) + roundedRect(leftPost, radius: size * 0.035, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.01) + roundedRect(rightPost, radius: size * 0.035, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.01) + roundedRect( + NSRect(x: size * 0.29, y: size * 0.39, width: size * 0.14, height: size * 0.10), + radius: size * 0.025, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.008 + ) + roundedRect( + NSRect(x: size * 0.57, y: size * 0.39, width: size * 0.14, height: size * 0.10), + radius: size * 0.025, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.008 + ) + + // The amber test key is intentionally generic: it is not the KeyPath product mark. + let keyBase = NSRect(x: size * 0.37, y: size * 0.31, width: size * 0.26, height: size * 0.27) + roundedRect( + keyBase, + radius: size * 0.055, + fill: color(0.88, 0.29, 0.055), + stroke: color(1.0, 0.62, 0.13), + lineWidth: size * 0.012 + ) + let keyFace = NSRect(x: size * 0.395, y: size * 0.365, width: size * 0.21, height: size * 0.16) + roundedRect( + keyFace, + radius: size * 0.038, + fill: color(1.0, 0.88, 0.58), + stroke: color(1.0, 0.67, 0.19), + lineWidth: size * 0.008 + ) + + // A measurement probe completes the test-jig metaphor. + let probe = NSBezierPath() + probe.move(to: NSPoint(x: size * 0.50, y: size * 0.81)) + probe.line(to: NSPoint(x: size * 0.50, y: size * 0.61)) + color(0.86, 0.95, 0.92).setStroke() + probe.lineWidth = max(1.5, size * 0.026) + probe.lineCapStyle = .round + probe.stroke() + let collar = NSRect(x: size * 0.43, y: size * 0.72, width: size * 0.14, height: size * 0.09) + roundedRect(collar, radius: size * 0.025, fill: jawColor, stroke: jawEdge, lineWidth: size * 0.009) + let probeTip = NSRect(x: size * 0.475, y: size * 0.575, width: size * 0.05, height: size * 0.05) + color(0.34, 0.88, 0.70).setFill() + NSBezierPath(ovalIn: probeTip).fill() + + return image +} + +func writePNG(_ image: NSImage, to url: URL) throws { + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .png, properties: [:]) + else { + throw NSError( + domain: "KeyPathJigIcon", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Could not encode icon PNG"] + ) + } + try data.write(to: url, options: .atomic) +} + +for variant in variants { + let pixels = CGFloat(variant.points * variant.scale) + try writePNG(render(size: pixels), to: outputDirectory.appendingPathComponent(variant.filename)) +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift new file mode 100644 index 000000000..0e6f892b2 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureBrandMotion.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A tiny, deterministic motion model shared by the Jig's branded drawing. +/// Keeping animation math out of AppKit makes it testable and ensures visual +/// feedback never changes capture state or timing. +public struct CaptureBrandMotion: Equatable, Sendable { + public let completion: Double + public let breath: Double + public let eventPulse: Double + public let glintPosition: Double + + public static func resolve( + snapshot: CaptureSnapshot, + nowNs: UInt64, + reduceMotion: Bool + ) -> CaptureBrandMotion { + let expectedCount = snapshot.expected.count + let completion = expectedCount == 0 + ? 0 + : min(1, Double(snapshot.received.count) / Double(expectedCount)) + + if reduceMotion { + return CaptureBrandMotion( + completion: completion, + breath: 0.5, + eventPulse: 0, + glintPosition: completion + ) + } + + let cycleNs: UInt64 = snapshot.state == .capturing ? 1_900_000_000 : 3_800_000_000 + let phase = Double(nowNs % cycleNs) / Double(cycleNs) + let breath = (sin(phase * .pi * 2 - .pi / 2) + 1) / 2 + + let eventPulse: Double + if let lastEventAtNs = snapshot.lastEventAtNs, nowNs >= lastEventAtNs { + let elapsed = Double(nowNs - lastEventAtNs) / 420_000_000 + let remaining = max(0, 1 - min(1, elapsed)) + eventPulse = remaining * remaining * (3 - 2 * remaining) + } else { + eventPulse = 0 + } + + return CaptureBrandMotion( + completion: completion, + breath: breath, + eventPulse: eventPulse, + glintPosition: phase + ) + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift new file mode 100644 index 000000000..62706e345 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureLayout.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum CaptureLayoutMode: String, Sendable { + case regular + case compact + case tiny +} + +public struct CaptureLayoutMetrics: Equatable, Sendable { + public let mode: CaptureLayoutMode + public let padding: Double + public let headerHeight: Double + public let stateFontSize: Double + public let fieldHeight: Double + public let showsFooter: Bool + + public static func resolve(width: Double, height: Double) -> CaptureLayoutMetrics { + if width >= 700, height >= 540 { + return CaptureLayoutMetrics( + mode: .regular, padding: 26, headerHeight: 54, stateFontSize: 38, + fieldHeight: 34, showsFooter: true + ) + } + if width >= 480, height >= 360 { + return CaptureLayoutMetrics( + mode: .compact, padding: 18, headerHeight: 44, stateFontSize: 30, + fieldHeight: 30, showsFooter: true + ) + } + return CaptureLayoutMetrics( + mode: .tiny, padding: 10, headerHeight: 32, stateFontSize: 23, + fieldHeight: 26, showsFooter: false + ) + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift new file mode 100644 index 000000000..1a75feedb --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/CaptureSession.swift @@ -0,0 +1,317 @@ +import Foundation + +public enum CaptureRunState: String, Codable, Sendable { + case idle + case armed + case capturing + case passed + case failed +} + +public enum CapturedEventPhase: String, Codable, Sendable { + case down + case up + case flagsChanged +} + +public struct CapturedKeyEvent: Codable, Equatable, Sendable { + public let sequence: Int + public let phase: CapturedEventPhase + public let keyCode: UInt16 + public let characters: String + public let modifiers: UInt + public let isRepeat: Bool + public let timestampNs: UInt64 + + public init( + sequence: Int, + phase: CapturedEventPhase, + keyCode: UInt16, + characters: String, + modifiers: UInt, + isRepeat: Bool, + timestampNs: UInt64 + ) { + self.sequence = sequence + self.phase = phase + self.keyCode = keyCode + self.characters = characters + self.modifiers = modifiers + self.isRepeat = isRepeat + self.timestampNs = timestampNs + } +} + +public struct CaptureSnapshot: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let runID: String + public let state: CaptureRunState + public let focused: Bool + public let expected: String + public let received: String + public let issues: [String] + public let events: [CapturedKeyEvent] + public let pressedKeyCodes: [UInt16] + public let activeModifiers: UInt + public let repeatEvents: Int + public let duplicateDownEvents: Int + public let unmatchedUpEvents: Int + public let startedAtNs: UInt64? + public let firstEventAtNs: UInt64? + public let lastEventAtNs: UInt64? + public let deadlineAtNs: UInt64? + public let settledForMs: UInt64 + + public var exactMatch: Bool { + received == expected + } + + public var allKeysReleased: Bool { + pressedKeyCodes.isEmpty && activeModifiers == 0 + } +} + +public final class CaptureSession { + private var runID = "" + private var state: CaptureRunState = .idle + private var focused = false + private var expected = "" + private var received = "" + private var events: [CapturedKeyEvent] = [] + private var pressedKeyCodes: Set = [] + private var activeModifiers: UInt = 0 + private var repeatEvents = 0 + private var duplicateDownEvents = 0 + private var unmatchedUpEvents = 0 + private var focusLost = false + private var finalized = false + private var terminalFailure = false + private var startedAtNs: UInt64? + private var firstEventAtNs: UInt64? + private var lastEventAtNs: UInt64? + private var deadlineAtNs: UInt64? + private var settleNs: UInt64 = 250_000_000 + + public init() {} + + public func reset() { + runID = "" + state = .idle + focused = false + expected = "" + received = "" + events.removeAll(keepingCapacity: true) + pressedKeyCodes.removeAll(keepingCapacity: true) + activeModifiers = 0 + repeatEvents = 0 + duplicateDownEvents = 0 + unmatchedUpEvents = 0 + focusLost = false + finalized = false + terminalFailure = false + startedAtNs = nil + firstEventAtNs = nil + lastEventAtNs = nil + deadlineAtNs = nil + } + + @discardableResult + public func arm( + runID: String, + expected: String, + timeoutMs: UInt64, + settleMs: UInt64, + focused: Bool, + nowNs: UInt64 + ) -> Bool { + reset() + guard !runID.isEmpty, runID.count <= 80, !expected.isEmpty, + timeoutMs >= 250, timeoutMs <= 300_000, + settleMs >= 50, settleMs <= 5000, + focused + else { + self.runID = runID + self.expected = expected + self.focused = focused + state = .failed + return false + } + self.runID = runID + self.expected = Self.normalize(expected) + self.focused = true + startedAtNs = nowNs + deadlineAtNs = nowNs &+ timeoutMs &* 1_000_000 + settleNs = settleMs &* 1_000_000 + state = .armed + return true + } + + public func noteFocus(_ focused: Bool, nowNs: UInt64) { + self.focused = focused + if !focused, state == .capturing { + focusLost = true + terminalFailure = true + state = .failed + lastEventAtNs = nowNs + } + } + + public func record( + phase: CapturedEventPhase, + keyCode: UInt16, + characters: String, + modifiers: UInt, + isRepeat: Bool, + nowNs: UInt64 + ) { + guard state != .idle, !runID.isEmpty else { return } + let normalized = phase == .down ? Self.normalize(characters) : "" + events.append(CapturedKeyEvent( + sequence: events.count + 1, + phase: phase, + keyCode: keyCode, + characters: normalized, + modifiers: modifiers, + isRepeat: isRepeat, + timestampNs: nowNs + )) + if firstEventAtNs == nil { + firstEventAtNs = nowNs + } + lastEventAtNs = nowNs + activeModifiers = modifiers + + switch phase { + case .down: + if !pressedKeyCodes.insert(keyCode).inserted { + duplicateDownEvents += 1 + } + if isRepeat { + repeatEvents += 1 + } + received.append(normalized) + case .up: + if pressedKeyCodes.remove(keyCode) == nil { + unmatchedUpEvents += 1 + } + case .flagsChanged: + break + } + + if state != .failed { + state = .capturing + } + evaluate(nowNs: nowNs) + } + + public func finalize(nowNs: UInt64) { + guard state != .idle else { return } + finalized = true + evaluate(nowNs: nowNs) + } + + public func snapshot(nowNs: UInt64) -> CaptureSnapshot { + evaluate(nowNs: nowNs) + let settledForNs = lastEventAtNs.map { nowNs >= $0 ? nowNs - $0 : 0 } ?? 0 + return CaptureSnapshot( + schemaVersion: 1, + runID: runID, + state: state, + focused: focused, + expected: expected, + received: received, + issues: issues(nowNs: nowNs), + events: events, + pressedKeyCodes: pressedKeyCodes.sorted(), + activeModifiers: activeModifiers, + repeatEvents: repeatEvents, + duplicateDownEvents: duplicateDownEvents, + unmatchedUpEvents: unmatchedUpEvents, + startedAtNs: startedAtNs, + firstEventAtNs: firstEventAtNs, + lastEventAtNs: lastEventAtNs, + deadlineAtNs: deadlineAtNs, + settledForMs: settledForNs / 1_000_000 + ) + } + + private func evaluate(nowNs: UInt64) { + guard state != .idle else { return } + if terminalFailure { + state = .failed + return + } + if focusLost || repeatEvents > 0 || duplicateDownEvents > 0 || unmatchedUpEvents > 0 || + mismatchIndex() != nil || received.count > expected.count + { + terminalFailure = true + state = .failed + return + } + let timedOut = deadlineAtNs.map { nowNs >= $0 } ?? false + if timedOut || finalized { + if received == expected, pressedKeyCodes.isEmpty, activeModifiers == 0 { + state = .passed + } else { + terminalFailure = true + state = .failed + } + return + } + if received == expected, pressedKeyCodes.isEmpty, activeModifiers == 0, + let lastEventAtNs, nowNs >= lastEventAtNs, + nowNs - lastEventAtNs >= settleNs + { + state = .passed + } + } + + private func issues(nowNs: UInt64) -> [String] { + var result: [String] = [] + if focusLost { + result.append("capture focus was lost") + } + if repeatEvents > 0 { + result.append("received \(repeatEvents) repeated key-down event(s)") + } + if duplicateDownEvents > 0 { + result.append("received \(duplicateDownEvents) key-down event(s) before release") + } + if unmatchedUpEvents > 0 { + result.append("received \(unmatchedUpEvents) key-up event(s) without a matching key-down") + } + if let index = mismatchIndex() { + result.append("received output differs from expected output at character \(index)") + } + if received.count > expected.count { + result.append("received \(received.count - expected.count) extra character(s)") + } + let terminal = finalized || (deadlineAtNs.map { nowNs >= $0 } ?? false) + if terminal, !focused, firstEventAtNs == nil { + result.append("capture was not focused before input arrived") + } + if terminal, received.count < expected.count { + result.append("missing \(expected.count - received.count) expected character(s)") + } + if terminal, !pressedKeyCodes.isEmpty { + result.append("unreleased key code(s): \(pressedKeyCodes.sorted())") + } + if terminal, activeModifiers != 0 { + result.append("unreleased modifier flags: \(activeModifiers)") + } + return result + } + + private func mismatchIndex() -> Int? { + let actual = Array(received) + let target = Array(expected) + for index in 0 ..< min(actual.count, target.count) where actual[index] != target[index] { + return index + } + return nil + } + + private static func normalize(_ value: String) -> String { + value.replacingOccurrences(of: "\r", with: "\n") + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift new file mode 100644 index 000000000..9472a3f99 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/KeycapBurstModel.swift @@ -0,0 +1,155 @@ +import Foundation + +public struct KeycapBurstItem: Equatable, Sendable { + public let sequence: Int + public let keyCode: UInt16 + public let label: String + public let pressDepth: Double + public let opacity: Double + public let xOffset: Double + public let yOffset: Double + public let scale: Double + public let isPressed: Bool + public let isRepeat: Bool +} + +public struct KeycapBurstOutput: Equatable, Sendable { + public let items: [KeycapBurstItem] + public let totalPresses: Int + public let presentedPresses: Int + public let recentPresses: Int + public let intensity: Double + public let isAnimating: Bool +} + +public enum KeycapBurstModel { + private static let visibleLifetimeNs: UInt64 = 900_000_000 + private static let pressReleaseNs: UInt64 = 160_000_000 + private static let burstWindowNs: UInt64 = 250_000_000 + private static let presentationCadenceNs: UInt64 = 36_000_000 + + public static func resolve( + events: [CapturedKeyEvent], + pressedKeyCodes: [UInt16], + nowNs: UInt64, + reduceMotion: Bool, + limit: Int = 10 + ) -> KeycapBurstOutput { + let downEvents = events.filter { $0.phase == .down } + var lastPresentationNs: UInt64 = 0 + let presented = downEvents.map { event -> (event: CapturedKeyEvent, timestampNs: UInt64) in + let earliest = lastPresentationNs == 0 + ? event.timestampNs + : lastPresentationNs &+ (reduceMotion ? 0 : presentationCadenceNs) + let timestamp = max(event.timestampNs, earliest) + lastPresentationNs = timestamp + return (event, timestamp) + } + let activeEvents = presented.filter { + nowNs >= $0.timestampNs && nowNs - $0.timestampNs <= visibleLifetimeNs + } + let visible = Array(activeEvents.suffix(max(0, limit))) + let pressed = Set(pressedKeyCodes) + let recentRealPresses = downEvents.reduce(into: 0) { count, event in + if nowNs >= event.timestampNs, nowNs - event.timestampNs <= burstWindowNs { + count += 1 + } + } + let recentPresentedPresses = presented.reduce(into: 0) { count, item in + if nowNs >= item.timestampNs, nowNs - item.timestampNs <= burstWindowNs { + count += 1 + } + } + let recentPresses = max(recentRealPresses, recentPresentedPresses) + let intensity = min(1, Double(recentPresses) / 8) + let presentedPresses = presented.reduce(into: 0) { count, item in + if nowNs >= item.timestampNs { + count += 1 + } + } + let isAnimating = lastPresentationNs > 0 && + (nowNs < lastPresentationNs || nowNs - lastPresentationNs <= visibleLifetimeNs) + + let items = visible.enumerated().map { index, presentedEvent in + let event = presentedEvent.event + let ageNs = nowNs - presentedEvent.timestampNs + let age = min(1, Double(ageNs) / Double(visibleLifetimeNs)) + let pressDepth: Double + let opacity: Double + let xOffset: Double + let yOffset: Double + let scale: Double + + if reduceMotion { + pressDepth = 0 + opacity = 1 - 0.42 * age + xOffset = Double(index - visible.count / 2) * 2 + yOffset = Double(index) * 2.5 + scale = 0.96 + Double(index) * 0.004 + } else { + let release = min(1, Double(ageNs) / Double(pressReleaseNs)) + pressDepth = pow(1 - release, 2) + let fadeStart = 0.42 + let fade = age <= fadeStart ? 0 : (age - fadeStart) / (1 - fadeStart) + opacity = 1 - 0.82 * fade * fade + let arrival = 1 - pow(1 - release, 3) + let fan = [-9.0, 6.0, -4.0, 10.0, -7.0, 3.0][event.sequence % 6] + xOffset = fan * arrival * (0.45 + intensity * 0.55) + yOffset = Double(index) * (3.3 + intensity * 1.8) * arrival + scale = (0.94 + Double(index) * 0.005) * (1 - pressDepth * 0.035) + } + + return KeycapBurstItem( + sequence: event.sequence, + keyCode: event.keyCode, + label: label(for: event), + pressDepth: pressDepth, + opacity: max(0, min(1, opacity)), + xOffset: xOffset, + yOffset: yOffset, + scale: scale, + isPressed: pressed.contains(event.keyCode), + isRepeat: event.isRepeat + ) + } + + return KeycapBurstOutput( + items: items, + totalPresses: downEvents.count, + presentedPresses: presentedPresses, + recentPresses: recentPresses, + intensity: intensity, + isAnimating: isAnimating + ) + } + + public static func label(for event: CapturedKeyEvent) -> String { + switch event.characters { + case " ": return "SPACE" + case "\r", "\n": return "RETURN" + case "\t": return "TAB" + case "\u{7f}", "\u{8}": return "DELETE" + case "\u{1b}": return "ESC" + case "\u{f700}": return "UP" + case "\u{f701}": return "DOWN" + case "\u{f702}": return "LEFT" + case "\u{f703}": return "RIGHT" + case "": + switch event.keyCode { + case 36, 76: return "RETURN" + case 48: return "TAB" + case 49: return "SPACE" + case 51, 117: return "DELETE" + case 53: return "ESC" + case 123: return "LEFT" + case 124: return "RIGHT" + case 125: return "DOWN" + case 126: return "UP" + default: return "K\(event.keyCode)" + } + default: + let value = event.characters.uppercased() + return value.count <= 6 ? value : String(value.prefix(5)) + "…" + } + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift new file mode 100644 index 000000000..d579adafa --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift @@ -0,0 +1,178 @@ +import Foundation + +public struct SystemResourceSample: Codable, Equatable, Sendable { + public let timestampNs: UInt64 + public let cpuUtilization: Double? + public let loadAveragePerCore: Double + public let availableMemoryBytes: UInt64 + public let physicalMemoryBytes: UInt64 + public let threadCount: Int + public let logicalProcessorCount: Int + public let memoryPressureLevel: Int + public let thermalState: String + + public init( + timestampNs: UInt64, + cpuUtilization: Double?, + loadAveragePerCore: Double, + availableMemoryBytes: UInt64, + physicalMemoryBytes: UInt64, + threadCount: Int, + logicalProcessorCount: Int, + memoryPressureLevel: Int, + thermalState: String + ) { + self.timestampNs = timestampNs + self.cpuUtilization = cpuUtilization + self.loadAveragePerCore = loadAveragePerCore + self.availableMemoryBytes = availableMemoryBytes + self.physicalMemoryBytes = physicalMemoryBytes + self.threadCount = threadCount + self.logicalProcessorCount = logicalProcessorCount + self.memoryPressureLevel = memoryPressureLevel + self.thermalState = thermalState + } +} + +public enum SystemReadinessState: String, Codable, Sendable { + case calibrating + case ready + case waiting +} + +public struct SystemReadinessAssessment: Codable, Equatable, Sendable { + public let state: SystemReadinessState + public let canProceed: Bool + public let summary: String + public let detail: String + public let issues: [String] + public let suggestions: [String] + public let stableSamples: Int + public let requiredStableSamples: Int + + public static let calibrating = SystemReadinessAssessment( + state: .calibrating, + canProceed: false, + summary: "Measuring CPU, load, memory, and temperature", + detail: "Keep this window open; readiness normally takes three seconds.", + issues: [], + suggestions: ["Wait a few seconds while the Jig establishes a stable baseline."], + stableSamples: 0, + requiredStableSamples: 3 + ) +} + +public enum SystemReadinessModel { + public static let requiredStableSamples = 3 + private static let maximumCPUUtilization = 0.80 + private static let maximumLoadPerCore = 0.90 + private static let minimumAvailableMemoryBytes: UInt64 = 2 * 1024 * 1024 * 1024 + private static let maximumThreadsPerCore = 900 + + public static func resolve( + samples: [SystemResourceSample], + requiredSamples: Int = requiredStableSamples + ) -> SystemReadinessAssessment { + guard requiredSamples > 0, let latest = samples.last else { return .calibrating } + let summary = metricSummary(latest) + let latestIssues = issues(for: latest) + if !latestIssues.isEmpty { + return SystemReadinessAssessment( + state: .waiting, + canProceed: false, + summary: summary, + detail: latestIssues.joined(separator: " · "), + issues: latestIssues, + suggestions: suggestions(for: latestIssues), + stableSamples: 0, + requiredStableSamples: requiredSamples + ) + } + + let recent = Array(samples.suffix(requiredSamples)) + let cleanCount = recent.reversed().prefix { issues(for: $0).isEmpty }.count + guard recent.count == requiredSamples, cleanCount == requiredSamples else { + return SystemReadinessAssessment( + state: .calibrating, + canProceed: false, + summary: summary, + detail: "Conditions are improving; confirming a stable window (\(cleanCount)/\(requiredSamples)).", + issues: [], + suggestions: ["Wait a few seconds; the Jig will recheck automatically."], + stableSamples: cleanCount, + requiredStableSamples: requiredSamples + ) + } + + return SystemReadinessAssessment( + state: .ready, + canProceed: true, + summary: summary, + detail: "Resources have stayed within the capture envelope for \(requiredSamples) checks.", + issues: [], + suggestions: [], + stableSamples: requiredSamples, + requiredStableSamples: requiredSamples + ) + } + + private static func issues(for sample: SystemResourceSample) -> [String] { + var issues: [String] = [] + if let cpu = sample.cpuUtilization, cpu >= maximumCPUUtilization { + issues.append("CPU \(percent(cpu)) (limit \(percent(maximumCPUUtilization)))") + } + if sample.loadAveragePerCore >= maximumLoadPerCore { + issues.append(String( + format: "competing work %.1f×/core (limit %.1f×)", + sample.loadAveragePerCore, maximumLoadPerCore + )) + } + let proportionalMemoryFloor = UInt64(Double(sample.physicalMemoryBytes) * 0.08) + let memoryFloor = max(minimumAvailableMemoryBytes, proportionalMemoryFloor) + if sample.availableMemoryBytes < memoryFloor { + issues.append("memory \(gigabytes(sample.availableMemoryBytes)) available (need \(gigabytes(memoryFloor)))") + } + if sample.memoryPressureLevel > 1 { + issues.append("macOS memory pressure elevated") + } + if sample.thermalState == "serious" || sample.thermalState == "critical" { + issues.append("thermal state is \(sample.thermalState)") + } + let processorCount = max(1, sample.logicalProcessorCount) + if sample.threadCount > processorCount * maximumThreadsPerCore { + issues.append("system thread inventory is unusually high (\(sample.threadCount))") + } + return issues + } + + private static func suggestions(for issues: [String]) -> [String] { + var result: [String] = [] + if issues.contains(where: { $0.contains("CPU") || $0.contains("competing work") }) { + result.append("Pause builds, exports, VMs, or other CPU-heavy jobs.") + } + if issues.contains(where: { $0.contains("memory") || $0.contains("thread inventory") }) { + result.append("Close unused apps or VMs to free memory and background workers.") + } + if issues.contains(where: { $0.contains("thermal") }) { + result.append("Let the Mac cool and keep its vents clear before retrying.") + } + result.append("The Jig will recheck automatically; no restart is required.") + return result + } + + private static func metricSummary(_ sample: SystemResourceSample) -> String { + let cpu = sample.cpuUtilization.map { "\(percent($0)) CPU" } ?? "sampling CPU" + return String( + format: "%@ · load %.1f× · %@ available", + cpu, sample.loadAveragePerCore, gigabytes(sample.availableMemoryBytes) + ) + } + + private static func percent(_ value: Double) -> String { + "\(Int((value * 100).rounded()))%" + } + + private static func gigabytes(_ bytes: UInt64) -> String { + String(format: "%.1f GB", Double(bytes) / 1_073_741_824) + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift new file mode 100644 index 000000000..2e114ef03 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureCore/TypoverBearMonitor.swift @@ -0,0 +1,211 @@ +import Foundation + +public enum TypoverBearMonitorPhase: String, Codable, Equatable, Sendable { + case inactive + case preparing + case waitingForBear + case countdown + case typing + case analyzing + case passed + case safeMisses + case failed +} + +public struct TypoverBearMonitorSnapshot: Codable, Equatable, Sendable { + public let phase: TypoverBearMonitorPhase + public let runID: String + public let caseIndex: Int + public let caseCount: Int + public let intervalMs: Int + public let wordCount: Int + public let scheduledText: String + public let scheduledStartAtNs: UInt64? + public let scheduledEvents: [CapturedKeyEvent] + public let correctedWords: Int + public let missedWords: Int + public let message: String + public let bearFocused: Bool + + public var isActive: Bool { + phase != .inactive + } + + public var totalKeyPresses: Int { + scheduledText.count + } + + public var presentedKeyPresses: Int { + scheduledEvents.count + } + + public var completion: Double { + guard totalKeyPresses > 0 else { return 0 } + return min(1, Double(presentedKeyPresses) / Double(totalKeyPresses)) + } + + public var keysPerSecond: Double { + intervalMs > 0 ? 1000 / Double(intervalMs) : 0 + } +} + +public final class TypoverBearMonitorSession { + private var phase = TypoverBearMonitorPhase.inactive + private var runID = "" + private var caseIndex = 0 + private var caseCount = 0 + private var intervalMs = 0 + private var wordCount = 0 + private var scheduledText = "" + private var scheduledStartAtNs: UInt64? + private var correctedWords = 0 + private var missedWords = 0 + private var message = "" + private var bearFocused = false + + public init() {} + + @discardableResult + public func prepare( + runID: String, + caseCount: Int, + message: String, + nowNs _: UInt64 + ) -> Bool { + guard !runID.isEmpty, runID.count <= 80, + (1 ... 100).contains(caseCount) + else { return false } + reset() + self.runID = runID + self.caseCount = caseCount + self.message = String(message.prefix(240)) + phase = .waitingForBear + return true + } + + @discardableResult + public func begin( + runID: String, + caseIndex: Int, + caseCount: Int, + intervalMs: Int, + wordCount: Int, + scheduledText: String, + startDelayMs: Int, + bearFocused: Bool, + nowNs: UInt64 + ) -> Bool { + guard !runID.isEmpty, runID.count <= 80, + caseIndex >= 1, caseCount >= caseIndex, caseCount <= 100, + (4 ... 2000).contains(intervalMs), + (1 ... 100).contains(wordCount), + !scheduledText.isEmpty, scheduledText.count <= 512, + scheduledText.unicodeScalars.allSatisfy(\.isASCII), + (100 ... 60000).contains(startDelayMs) + else { return false } + + self.runID = runID + self.caseIndex = caseIndex + self.caseCount = caseCount + self.intervalMs = intervalMs + self.wordCount = wordCount + self.scheduledText = scheduledText + scheduledStartAtNs = nowNs &+ UInt64(startDelayMs) &* 1_000_000 + correctedWords = 0 + missedWords = 0 + message = "Bear owns keyboard focus" + self.bearFocused = bearFocused + phase = .countdown + return true + } + + @discardableResult + public func update( + phase: TypoverBearMonitorPhase, + correctedWords: Int? = nil, + missedWords: Int? = nil, + message: String? = nil, + bearFocused: Bool? = nil + ) -> Bool { + guard self.phase != .inactive, phase != .inactive else { return false } + let nextCorrected = correctedWords ?? self.correctedWords + let nextMissed = missedWords ?? self.missedWords + guard nextCorrected >= 0, nextMissed >= 0, + nextCorrected + nextMissed <= wordCount + else { return false } + self.phase = phase + self.correctedWords = nextCorrected + self.missedWords = nextMissed + if let message { + self.message = String(message.prefix(240)) + } + if let bearFocused { + self.bearFocused = bearFocused + } + return true + } + + public func reset() { + phase = .inactive + runID = "" + caseIndex = 0 + caseCount = 0 + intervalMs = 0 + wordCount = 0 + scheduledText = "" + scheduledStartAtNs = nil + correctedWords = 0 + missedWords = 0 + message = "" + bearFocused = false + } + + public func snapshot(nowNs: UInt64) -> TypoverBearMonitorSnapshot { + TypoverBearMonitorSnapshot( + phase: phase, + runID: runID, + caseIndex: caseIndex, + caseCount: caseCount, + intervalMs: intervalMs, + wordCount: wordCount, + scheduledText: scheduledText, + scheduledStartAtNs: scheduledStartAtNs, + scheduledEvents: scheduledEvents(nowNs: nowNs), + correctedWords: correctedWords, + missedWords: missedWords, + message: message, + bearFocused: bearFocused + ) + } + + private func scheduledEvents(nowNs: UInt64) -> [CapturedKeyEvent] { + guard phase != .inactive, let scheduledStartAtNs, + nowNs >= scheduledStartAtNs + else { return [] } + let intervalNs = UInt64(intervalMs) * 1_000_000 + let elapsed = nowNs - scheduledStartAtNs + let count = min(scheduledText.count, Int(elapsed / intervalNs) + 1) + return Array(scheduledText.prefix(count)).enumerated().map { index, character in + CapturedKeyEvent( + sequence: index + 1, + phase: .down, + keyCode: Self.keyCode(for: character), + characters: String(character), + modifiers: 0, + isRepeat: false, + timestampNs: scheduledStartAtNs &+ UInt64(index) &* intervalNs + ) + } + } + + private static func keyCode(for character: Character) -> UInt16 { + switch character { + case "t": 17 + case "e": 14 + case "h": 4 + case " ": 49 + case "\n", "\r": 36 + default: 0 + } + } +} diff --git a/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift new file mode 100644 index 000000000..825a11ddc --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift @@ -0,0 +1,1689 @@ +import AppKit +import Darwin +import Foundation +import HIDCaptureCore + +private struct ControlCommand: Codable { + let id: String + let action: String + let runID: String? + let expected: String? + let timeoutMs: UInt64? + let settleMs: UInt64? + let instruction: String? + let demoMode: Bool? + let phase: String? + let caseIndex: Int? + let caseCount: Int? + let intervalMs: Int? + let wordCount: Int? + let scheduledText: String? + let startDelayMs: Int? + let correctedWords: Int? + let missedWords: Int? + let message: String? + let bearFocused: Bool? +} + +private struct ControlResponse: Codable { + let id: String + let ok: Bool + let message: String + let processID: Int32 + let snapshot: CaptureSnapshot + let systemReadiness: SystemReadinessAssessment + let bearMonitor: TypoverBearMonitorSnapshot +} + +private func monotonicNow() -> UInt64 { + DispatchTime.now().uptimeNanoseconds +} + +private final class SystemResourceMonitor { + private var samples: [SystemResourceSample] = [] + private var lastSampleNs: UInt64 = 0 + private var previousCPUTicks: (busy: UInt64, total: UInt64)? + private(set) var assessment = SystemReadinessAssessment.calibrating + + @discardableResult + func sampleIfNeeded(nowNs: UInt64, force: Bool = false) -> Bool { + guard force || lastSampleNs == 0 || nowNs - lastSampleNs >= 1_000_000_000 else { + return false + } + lastSampleNs = nowNs + samples.append(capture(nowNs: nowNs)) + if samples.count > 12 { + samples.removeFirst(samples.count - 12) + } + assessment = SystemReadinessModel.resolve(samples: samples) + return true + } + + private func capture(nowNs: UInt64) -> SystemResourceSample { + let processors = max(1, ProcessInfo.processInfo.activeProcessorCount) + var averages = [Double](repeating: 0, count: 3) + let averageCount = averages.withUnsafeMutableBufferPointer { + getloadavg($0.baseAddress, Int32($0.count)) + } + let oneMinuteLoad = averageCount > 0 ? averages[0] : 0 + return SystemResourceSample( + timestampNs: nowNs, + cpuUtilization: cpuUtilization(), + loadAveragePerCore: max(0, oneMinuteLoad / Double(processors)), + availableMemoryBytes: availableMemoryBytes(), + physicalMemoryBytes: ProcessInfo.processInfo.physicalMemory, + threadCount: integerSysctl("kern.num_taskthreads"), + logicalProcessorCount: processors, + memoryPressureLevel: integerSysctl("kern.memorystatus_vm_pressure_level"), + thermalState: thermalStateName(ProcessInfo.processInfo.thermalState) + ) + } + + private func cpuUtilization() -> Double? { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t( + MemoryLayout.size(ofValue: info) / MemoryLayout.size + ) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard result == KERN_SUCCESS else { return nil } + let busy = UInt64(info.cpu_ticks.0) + UInt64(info.cpu_ticks.1) + UInt64(info.cpu_ticks.3) + let total = busy + UInt64(info.cpu_ticks.2) + defer { previousCPUTicks = (busy, total) } + guard let previousCPUTicks, total > previousCPUTicks.total else { return nil } + let totalDelta = total - previousCPUTicks.total + let busyDelta = busy >= previousCPUTicks.busy ? busy - previousCPUTicks.busy : 0 + return min(1, max(0, Double(busyDelta) / Double(totalDelta))) + } + + private func availableMemoryBytes() -> UInt64 { + var info = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.size(ofValue: info) / MemoryLayout.size + ) + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return 0 } + var pageSize: vm_size_t = 0 + guard host_page_size(mach_host_self(), &pageSize) == KERN_SUCCESS else { return 0 } + let reclaimable = UInt64(info.free_count) + UInt64(info.inactive_count) + + UInt64(info.speculative_count) + UInt64(info.purgeable_count) + return reclaimable * UInt64(pageSize) + } + + private func integerSysctl(_ name: String) -> Int { + var value: Int32 = 0 + var size = MemoryLayout.size + let result = name.withCString { + sysctlbyname($0, &value, &size, nil, 0) + } + return result == 0 ? Int(value) : 0 + } + + private func thermalStateName(_ state: ProcessInfo.ThermalState) -> String { + switch state { + case .nominal: return "nominal" + case .fair: return "fair" + case .serious: return "serious" + case .critical: return "critical" + @unknown default: return "unknown" + } + } +} + +private final class CaptureCanvas: NSView { + let session: CaptureSession + let bearMonitor: TypoverBearMonitorSession + var onFocusChange: ((Bool) -> Void)? + var systemReadiness = SystemReadinessAssessment.calibrating + var operatorInstruction = "" + private var fontCache: [String: NSFont] = [:] + private var paragraphCache: [Int: NSParagraphStyle] = [:] + private let brandAmber = NSColor( + calibratedRed: 0.95, green: 0.56, blue: 0.12, alpha: 1 + ) + private let brandOrange = NSColor( + calibratedRed: 0.86, green: 0.31, blue: 0.07, alpha: 1 + ) + private let brandLight = NSColor( + calibratedRed: 1.0, green: 0.96, blue: 0.83, alpha: 1 + ) + private let bearCoral = NSColor( + calibratedRed: 0.88, green: 0.25, blue: 0.18, alpha: 1 + ) + private let bearMint = NSColor( + calibratedRed: 0.40, green: 0.79, blue: 0.64, alpha: 1 + ) + private let bearAmber = NSColor( + calibratedRed: 0.95, green: 0.72, blue: 0.29, alpha: 1 + ) + private let bearViolet = NSColor( + calibratedRed: 0.61, green: 0.54, blue: 0.98, alpha: 1 + ) + private let bearCream = NSColor( + calibratedRed: 1.0, green: 0.94, blue: 0.88, alpha: 1 + ) + private let bearCanvasBackground = NSColor(name: nil) { appearance in + let match = appearance.bestMatch(from: [.darkAqua, .aqua]) + return match == .darkAqua + ? NSColor(calibratedRed: 0.086, green: 0.059, blue: 0.071, alpha: 1) + : NSColor(calibratedRed: 1.0, green: 0.976, blue: 0.961, alpha: 1) + } + + private lazy var jigLogo: NSImage? = { + guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns") else { + return nil + } + return NSImage(contentsOf: url) + }() + + private lazy var bearLogo: NSImage? = { + guard let url = NSWorkspace.shared.urlForApplication( + withBundleIdentifier: "net.shinyfrog.bear" + ) else { + return nil + } + return NSWorkspace.shared.icon(forFile: url.path) + }() + + init(session: CaptureSession, bearMonitor: TypoverBearMonitorSession) { + self.session = session + self.bearMonitor = bearMonitor + super.init(frame: .zero) + wantsLayer = true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { + true + } + + override var isOpaque: Bool { + true + } + + override func becomeFirstResponder() -> Bool { + onFocusChange?(true) + needsDisplay = true + return true + } + + override func resignFirstResponder() -> Bool { + onFocusChange?(false) + needsDisplay = true + return true + } + + override func keyDown(with event: NSEvent) { + session.record( + phase: .down, + keyCode: event.keyCode, + characters: event.characters ?? "", + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue, + isRepeat: event.isARepeat, + nowNs: monotonicNow() + ) + needsDisplay = true + } + + override func keyUp(with event: NSEvent) { + session.record( + phase: .up, + keyCode: event.keyCode, + characters: "", + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue, + isRepeat: false, + nowNs: monotonicNow() + ) + needsDisplay = true + } + + override func flagsChanged(with event: NSEvent) { + session.record( + phase: .flagsChanged, + keyCode: event.keyCode, + characters: "", + modifiers: event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue, + isRepeat: false, + nowNs: monotonicNow() + ) + needsDisplay = true + } + + override func mouseDown(with _: NSEvent) { + window?.makeFirstResponder(self) + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + let nowNs = monotonicNow() + let snapshot = session.snapshot(nowNs: nowNs) + let motion = CaptureBrandMotion.resolve( + snapshot: snapshot, + nowNs: nowNs, + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ) + let layout = CaptureLayoutMetrics.resolve( + width: Double(bounds.width), height: Double(bounds.height) + ) + let padding = CGFloat(layout.padding) + let inset = bounds.insetBy(dx: padding, dy: padding) + let opaqueBackground = ( + NSColor.windowBackgroundColor.usingColorSpace(.deviceRGB) ?? + NSColor(calibratedWhite: 0.97, alpha: 1) + ).withAlphaComponent(1) + opaqueBackground.setFill() + bounds.fill() + + let bearSnapshot = bearMonitor.snapshot(nowNs: nowNs) + if bearSnapshot.isActive { + drawBearMonitor( + bearSnapshot, + nowNs: nowNs, + layout: layout, + inset: inset + ) + return + } + + let accent = systemReadiness.state == .waiting && snapshot.state == .idle + ? brandOrange : color(for: snapshot.state) + let headerHeight = CGFloat(layout.headerHeight) + let header = NSRect( + x: inset.minX, y: inset.maxY - headerHeight, + width: inset.width, height: headerHeight + ) + let headerPath = NSBezierPath( + roundedRect: header, + xRadius: layout.mode == .tiny ? 10 : 14, + yRadius: layout.mode == .tiny ? 10 : 14 + ) + brandAmber.withAlphaComponent(0.08 + motion.breath * 0.025).setFill() + headerPath.fill() + brandAmber.withAlphaComponent(0.22).setStroke() + headerPath.lineWidth = 1 + headerPath.stroke() + + let markSize: CGFloat = layout.mode == .tiny ? 22 : 34 + let markFrame = NSRect( + x: header.minX + (layout.mode == .tiny ? 8 : 14), + y: header.midY - markSize / 2, + width: markSize, + height: markSize + ) + drawJigLogo(in: markFrame) + + let focusWidth: CGFloat = layout.mode == .tiny ? 72 : 130 + let titleX = markFrame.maxX + (layout.mode == .tiny ? 6 : 10) + drawText( + layout.mode == .tiny ? "HID JIG" : "HID CAPTURE JIG", + rect: NSRect( + x: titleX, + y: header.midY - 10, + width: max(40, header.maxX - focusWidth - titleX - 14), height: 22 + ), + font: font( + size: layout.mode == .tiny ? 10 : 14, weight: .semibold, + monospaced: true + ), + color: accent + ) + drawText( + snapshot.focused ? (layout.mode == .tiny ? "FOCUS" : "FOCUSED") : "NO FOCUS", + rect: NSRect( + x: header.maxX - focusWidth - (layout.mode == .tiny ? 10 : 18), + y: header.midY - 10, width: focusWidth, height: 22 + ), + font: font( + size: layout.mode == .tiny ? 9 : 12, weight: .bold, + monospaced: true + ), + color: snapshot.focused ? NSColor.systemGreen : NSColor.systemRed, + alignment: .right + ) + drawActivityRail(in: header, motion: motion, stateAccent: accent) + + var cursor = header.minY - (layout.mode == .tiny ? 5 : 12) + let stateHeight: CGFloat = layout.mode == .tiny ? 28 : 48 + let visibleState: String = if snapshot.state == .idle, systemReadiness.state == .waiting { + layout.mode == .tiny ? "MAC BUSY" : "PAUSED · MAC BUSY" + } else if snapshot.state == .idle, systemReadiness.state == .calibrating { + layout.mode == .tiny ? "CHECKING" : "CHECKING THE MAC" + } else { + snapshot.state.rawValue.uppercased() + } + drawText( + visibleState, + rect: NSRect( + x: inset.minX, y: cursor - stateHeight, + width: inset.width, height: stateHeight + ), + font: font(size: CGFloat(layout.stateFontSize), weight: .bold), + color: accent + ) + cursor -= stateHeight + let runHeight: CGFloat = layout.mode == .tiny ? 14 : 22 + let runSummary = snapshot.runID.isEmpty ? systemReadiness.summary : snapshot.runID + drawText( + runSummary, + rect: NSRect(x: inset.minX, y: cursor - runHeight, width: inset.width, height: runHeight), + font: font( + size: layout.mode == .tiny ? 9 : 14, weight: .regular, + monospaced: true + ), + color: NSColor.secondaryLabelColor + ) + cursor -= runHeight + (layout.mode == .tiny ? 4 : 10) + + let labelHeight: CGFloat = layout.mode == .tiny ? 11 : 16 + let fieldHeight = CGFloat(layout.fieldHeight) + let fieldBlockHeight = labelHeight + fieldHeight + (layout.mode == .tiny ? 4 : 8) + drawField( + label: "EXPECTED", value: snapshot.expected, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, compact: layout.mode != .regular + ) + cursor -= fieldBlockHeight + drawField( + label: "RECEIVED", value: snapshot.received, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, compact: layout.mode != .regular, + valueColor: snapshot.received == snapshot.expected && !snapshot.received.isEmpty + ? NSColor.systemGreen : NSColor.labelColor + ) + cursor -= fieldBlockHeight + + let issueText: String = if !snapshot.issues.isEmpty { + snapshot.issues.joined(separator: " • ") + } else if snapshot.state == .idle, systemReadiness.state != .ready { + ([systemReadiness.detail] + systemReadiness.suggestions).joined(separator: " • ") + } else if snapshot.state == .idle { + "System resources are stable · ready to arm" + } else { + "No anomalies detected" + } + let resourceBlocked = snapshot.state == .idle && systemReadiness.state == .waiting + let issueHeight: CGFloat = resourceBlocked + ? (layout.mode == .regular ? 68 : (layout.mode == .compact ? 58 : 24)) + : (layout.mode == .tiny ? 18 : 28) + let issueFrame = NSRect( + x: inset.minX, y: cursor - issueHeight, width: inset.width, height: issueHeight + ) + if resourceBlocked, layout.mode != .tiny { + let why = systemReadiness.issues.prefix(2).joined(separator: " · ") + let help = systemReadiness.suggestions.first ?? "Wait; the Jig will recheck automatically." + drawWrappedText( + "WHY \(why)\nHELP \(help)", + rect: issueFrame, + font: font(size: layout.mode == .regular ? 13 : 11, weight: .medium), + color: brandOrange + ) + } else { + drawText( + issueText, + rect: issueFrame, + font: font(size: layout.mode == .tiny ? 9 : 13, weight: .medium), + color: !snapshot.issues.isEmpty ? NSColor.systemRed : NSColor.secondaryLabelColor + ) + } + cursor -= issueHeight + (layout.mode == .regular ? 12 : 6) + + let footerClearance: CGFloat = layout.showsFooter ? 28 : 0 + let burstFrame = NSRect( + x: inset.minX, + y: inset.minY + footerClearance, + width: inset.width, + height: max(54, cursor - inset.minY - footerClearance) + ) + drawKeycapBurst( + snapshot: snapshot, + nowNs: nowNs, + frame: burstFrame, + mode: layout.mode, + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ) + + if layout.showsFooter { + drawText( + "Click this window to restore focus. Tests fail closed if focus moves elsewhere.", + rect: NSRect(x: inset.minX, y: inset.minY, width: inset.width, height: 20), + font: font( + size: layout.mode == .regular ? 12 : 10, weight: .regular + ), + color: NSColor.tertiaryLabelColor + ) + } + + if !operatorInstruction.isEmpty, + snapshot.state == .armed || snapshot.state == .capturing + { + drawOperatorInstruction( + operatorInstruction, + in: inset, + mode: layout.mode, + pulse: motion.breath + ) + } + } + + private func drawBearMonitor( + _ snapshot: TypoverBearMonitorSnapshot, + nowNs: UInt64, + layout: CaptureLayoutMetrics, + inset: NSRect + ) { + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + let output = KeycapBurstModel.resolve( + events: snapshot.scheduledEvents, + pressedKeyCodes: [], + nowNs: nowNs, + reduceMotion: reduceMotion + ) + let accent: NSColor = switch snapshot.phase { + case .passed: bearMint + case .safeMisses: bearAmber + case .failed: bearViolet + default: bearCoral + } + let cycleNs: UInt64 = snapshot.phase == .typing ? 1_900_000_000 : 3_800_000_000 + let cycle = Double(nowNs % cycleNs) / Double(cycleNs) + let breath = reduceMotion ? 0.5 : (sin(cycle * .pi * 2 - .pi / 2) + 1) / 2 + bearCanvasBackground.setFill() + bounds.fill() + let headerHeight = CGFloat(layout.headerHeight) + let header = NSRect( + x: inset.minX, y: inset.maxY - headerHeight, + width: inset.width, height: headerHeight + ) + let headerPath = NSBezierPath( + roundedRect: header, + xRadius: layout.mode == .tiny ? 10 : 14, + yRadius: layout.mode == .tiny ? 10 : 14 + ) + bearCoral.withAlphaComponent(0.09 + breath * 0.025).setFill() + headerPath.fill() + bearCoral.withAlphaComponent(0.30).setStroke() + headerPath.lineWidth = 1 + headerPath.stroke() + + let markSize: CGFloat = layout.mode == .tiny ? 24 : 36 + let markFrame = NSRect( + x: header.minX + (layout.mode == .tiny ? 8 : 14), + y: header.midY - markSize / 2, + width: markSize, + height: markSize + ) + drawBearLogo(in: markFrame) + let focusWidth: CGFloat = layout.mode == .tiny ? 88 : 142 + let titleX = markFrame.maxX + (layout.mode == .tiny ? 6 : 10) + let titleWidth = max(40, header.maxX - focusWidth - titleX - 14) + if layout.mode == .tiny { + drawText( + "BEAR · TYPOVER", + rect: NSRect(x: titleX, y: header.midY - 9, width: titleWidth, height: 20), + font: font(size: 10, weight: .bold), + color: bearCoral + ) + } else { + drawText( + "BEAR", + rect: NSRect(x: titleX, y: header.midY - 2, width: titleWidth, height: 22), + font: font(size: 17, weight: .bold), + color: bearCoral + ) + drawText( + "TYPOVER · PHYSICAL HID TEST", + rect: NSRect(x: titleX, y: header.midY - 17, width: titleWidth, height: 15), + font: font(size: 9, weight: .semibold, monospaced: true), + color: NSColor.secondaryLabelColor + ) + } + let focusLabel = if snapshot.bearFocused { + "BEAR FOCUSED" + } else if snapshot.phase == .waitingForBear || snapshot.phase == .preparing { + "FOCUS BEAR" + } else { + "FOCUS LOST" + } + let focusColor = if snapshot.bearFocused { + bearCoral + } else if snapshot.phase == .waitingForBear || snapshot.phase == .preparing { + bearAmber + } else { + bearViolet + } + drawText( + focusLabel, + rect: NSRect( + x: header.maxX - focusWidth - (layout.mode == .tiny ? 10 : 18), + y: header.midY - 10, width: focusWidth, height: 22 + ), + font: font( + size: layout.mode == .tiny ? 9 : 12, + weight: .bold, + monospaced: true + ), + color: focusColor, + alignment: .right + ) + drawActivityRail( + in: header, + completion: snapshot.completion, + breath: breath, + eventPulse: output.recentPresses > 0 ? min(1, output.intensity + 0.25) : 0, + glintPosition: cycle, + stateAccent: accent + ) + + var cursor = header.minY - (layout.mode == .tiny ? 5 : 12) + let stateHeight: CGFloat = layout.mode == .tiny ? 28 : 48 + drawText( + bearPhaseTitle(snapshot, nowNs: nowNs), + rect: NSRect( + x: inset.minX, y: cursor - stateHeight, + width: inset.width, height: stateHeight + ), + font: font(size: CGFloat(layout.stateFontSize), weight: .bold), + color: accent + ) + cursor -= stateHeight + let runHeight: CGFloat = layout.mode == .tiny ? 14 : 22 + let caseSummary = if snapshot.caseIndex == 0 { + "\(snapshot.caseCount) CASE MATRIX · WAITING FOR BEAR" + } else { + String( + format: "CASE %d OF %d · %d MS / KEY · %.1f KEYS / SEC", + snapshot.caseIndex, + snapshot.caseCount, + snapshot.intervalMs, + snapshot.keysPerSecond + ) + } + drawText( + caseSummary, + rect: NSRect( + x: inset.minX, y: cursor - runHeight, + width: inset.width, height: runHeight + ), + font: font( + size: layout.mode == .tiny ? 9 : 14, + weight: .regular, + monospaced: true + ), + color: .secondaryLabelColor + ) + cursor -= runHeight + (layout.mode == .tiny ? 4 : 10) + + let labelHeight: CGFloat = layout.mode == .tiny ? 11 : 16 + let fieldHeight = CGFloat(layout.fieldHeight) + let fieldBlockHeight = labelHeight + fieldHeight + (layout.mode == .tiny ? 4 : 8) + let inputSummary = snapshot.wordCount == 0 + ? "Continuous \"teh\" bursts · \(snapshot.caseCount) repeatable timing cases" + : "\"teh\" × \(snapshot.wordCount) · \(snapshot.totalKeyPresses) scheduled key presses" + drawField( + label: "PHYSICAL TEST INPUT", + value: inputSummary, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, + compact: layout.mode != .regular + ) + cursor -= fieldBlockHeight + let resultText = switch snapshot.phase { + case .passed: + "\(snapshot.correctedWords) corrected · no safe misses" + case .safeMisses: + "\(snapshot.correctedWords) corrected · \(snapshot.missedWords) safe misses" + case .failed: + "Evidence invalid · no result credited" + default: + "Waiting for exact Bear text + Typover log evidence" + } + let resultColor: NSColor = switch snapshot.phase { + case .passed: bearMint + case .safeMisses: bearAmber + case .failed: bearViolet + default: .labelColor + } + drawField( + label: "VERIFIED TYPOVER RESULT", + value: resultText, + frame: NSRect( + x: inset.minX, y: cursor - labelHeight - fieldHeight, + width: inset.width, height: fieldHeight + ), + labelHeight: labelHeight, + compact: layout.mode != .regular, + valueColor: resultColor + ) + cursor -= fieldBlockHeight + + let issueHeight: CGFloat = layout.mode == .tiny ? 18 : 28 + let detail = snapshot.message.isEmpty + ? "The monitor is presentation-only; Bear owns the physical keyboard." + : snapshot.message + let detailColor = if snapshot.bearFocused + || snapshot.phase == .waitingForBear + || snapshot.phase == .preparing + { + NSColor.secondaryLabelColor + } else { + bearViolet + } + drawText( + detail, + rect: NSRect( + x: inset.minX, y: cursor - issueHeight, + width: inset.width, height: issueHeight + ), + font: font( + size: layout.mode == .tiny ? 9 : 13, + weight: .medium + ), + color: detailColor + ) + cursor -= issueHeight + (layout.mode == .regular ? 12 : 6) + + let footerClearance: CGFloat = layout.showsFooter ? 28 : 0 + let burstFrame = NSRect( + x: inset.minX, + y: inset.minY + footerClearance, + width: inset.width, + height: max(54, cursor - inset.minY - footerClearance) + ) + let keyEvidence: String = if output.totalPresses == 0 { + snapshot.phase == .countdown + ? "WAITING FOR ESP32 START" : "NO SCHEDULED KEYS YET" + } else if output.presentedPresses < snapshot.totalKeyPresses { + "SCHEDULED \(output.presentedPresses)/\(snapshot.totalKeyPresses) · BURST \(output.recentPresses)" + } else { + "\(snapshot.totalKeyPresses) PHYSICAL PRESSES SCHEDULED" + } + drawKeycapBurst( + output: output, + title: layout.mode == .tiny ? "ESP32 KEYS" : "SCHEDULED ESP32 KEYCAP STACK", + evidence: keyEvidence, + frame: burstFrame, + mode: layout.mode, + bearMode: true + ) + + if layout.showsFooter { + drawText( + "Presentation only · Bear remains frontmost · results require Bear text and Typover logs", + rect: NSRect(x: inset.minX, y: inset.minY, width: inset.width, height: 20), + font: font(size: layout.mode == .regular ? 12 : 10, weight: .regular), + color: .tertiaryLabelColor + ) + } + } + + private func bearPhaseTitle( + _ snapshot: TypoverBearMonitorSnapshot, + nowNs: UInt64 + ) -> String { + switch snapshot.phase { + case .inactive: return "READY" + case .preparing: return "PREPARING FIXTURE" + case .waitingForBear: return "FOCUS THE BEAR NOTE" + case .countdown: + guard let start = snapshot.scheduledStartAtNs, start > nowNs else { + return "RAPID TYPING" + } + return String(format: "STARTING IN %.1F S", Double(start - nowNs) / 1_000_000_000) + case .typing: return "RAPID TYPING" + case .analyzing: return "VERIFYING IN BEAR" + case .passed: return "ALL CORRECTIONS OBSERVED" + case .safeMisses: return "SAFE MISSES OBSERVED" + case .failed: return "EVIDENCE INVALID" + } + } + + private func drawOperatorInstruction( + _ instruction: String, + in bounds: NSRect, + mode: CaptureLayoutMode, + pulse: Double + ) { + let horizontalInset: CGFloat = mode == .tiny ? 6 : (mode == .compact ? 24 : 56) + let height: CGFloat = mode == .tiny ? 104 : (mode == .compact ? 132 : 164) + let frame = NSRect( + x: bounds.minX + horizontalInset, + y: bounds.midY - height / 2, + width: max(120, bounds.width - horizontalInset * 2), + height: height + ) + let path = NSBezierPath( + roundedRect: frame, + xRadius: mode == .tiny ? 14 : 20, + yRadius: mode == .tiny ? 14 : 20 + ) + let background = NSColor.windowBackgroundColor.withAlphaComponent(0.97) + background.setFill() + path.fill() + brandOrange.withAlphaComponent(0.72 + 0.2 * pulse).setStroke() + path.lineWidth = mode == .tiny ? 2 : 3 + path.stroke() + + let labelHeight: CGFloat = mode == .tiny ? 18 : 24 + drawText( + "ACTION REQUIRED", + rect: NSRect( + x: frame.minX + 16, + y: frame.maxY - labelHeight - (mode == .tiny ? 10 : 16), + width: frame.width - 32, + height: labelHeight + ), + font: font(size: mode == .tiny ? 12 : 16, weight: .bold, monospaced: true), + color: brandOrange, + alignment: .center + ) + drawWrappedText( + instruction, + rect: NSRect( + x: frame.minX + (mode == .tiny ? 12 : 24), + y: frame.minY + (mode == .tiny ? 10 : 18), + width: frame.width - (mode == .tiny ? 24 : 48), + height: frame.height - labelHeight - (mode == .tiny ? 28 : 48) + ), + font: font(size: mode == .tiny ? 14 : 22, weight: .semibold), + color: NSColor.labelColor, + alignment: .center + ) + } + + private func drawField( + label: String, value: String, frame: NSRect, labelHeight: CGFloat, + compact: Bool, valueColor: NSColor = .labelColor + ) { + drawText( + label, + rect: NSRect( + x: frame.minX, y: frame.maxY + 1, + width: frame.width, height: labelHeight + ), + font: font(size: compact ? 9 : 11, weight: .semibold, monospaced: true), + color: NSColor.tertiaryLabelColor + ) + NSColor.controlBackgroundColor.setFill() + NSBezierPath( + roundedRect: frame, xRadius: compact ? 6 : 8, yRadius: compact ? 6 : 8 + ).fill() + drawText( + value.isEmpty ? "—" : value.debugDescription, + rect: NSRect( + x: frame.minX + (compact ? 8 : 12), y: frame.minY + (compact ? 5 : 8), + width: frame.width - (compact ? 16 : 24), height: frame.height - 8 + ), + font: font(size: compact ? 11 : 15, weight: .medium, monospaced: true), + color: valueColor + ) + } + + private func drawKeycapBurst( + snapshot: CaptureSnapshot, + nowNs: UInt64, + frame: NSRect, + mode: CaptureLayoutMode, + reduceMotion: Bool + ) { + let output = KeycapBurstModel.resolve( + events: snapshot.events, + pressedKeyCodes: snapshot.pressedKeyCodes, + nowNs: nowNs, + reduceMotion: reduceMotion + ) + let evidence = if output.totalPresses == 0 { + "WAITING FOR KEY-DOWN" + } else if output.presentedPresses < output.totalPresses { + "PLAYING \(output.presentedPresses)/\(output.totalPresses) · BURST \(output.recentPresses)" + } else if output.recentPresses > 0 { + "\(output.recentPresses) NOW · \(output.totalPresses) TOTAL" + } else { + "\(output.totalPresses) PRESSES CAPTURED" + } + drawKeycapBurst( + output: output, + title: mode == .tiny ? "LIVE KEYS" : "LIVE KEYCAP STACK", + evidence: evidence, + frame: frame, + mode: mode + ) + } + + private func drawKeycapBurst( + output: KeycapBurstOutput, + title: String, + evidence: String, + frame: NSRect, + mode: CaptureLayoutMode, + bearMode: Bool = false + ) { + let primary = bearMode ? bearCoral : brandAmber + let secondary = bearMode ? bearAmber : brandOrange + let panel = NSBezierPath( + roundedRect: frame, + xRadius: mode == .tiny ? 10 : 16, + yRadius: mode == .tiny ? 10 : 16 + ) + NSColor.controlBackgroundColor.withAlphaComponent(0.62).setFill() + panel.fill() + primary.withAlphaComponent(0.12 + 0.13 * output.intensity).setStroke() + panel.lineWidth = 1 + panel.stroke() + + let titleHeight: CGFloat = mode == .tiny ? 14 : 20 + let titleInset: CGFloat = mode == .tiny ? 9 : 14 + drawText( + title, + rect: NSRect( + x: frame.minX + titleInset, + y: frame.maxY - titleHeight - (mode == .tiny ? 5 : 9), + width: frame.width * 0.5, + height: titleHeight + ), + font: font(size: mode == .tiny ? 8 : 11, weight: .semibold, monospaced: true), + color: NSColor.tertiaryLabelColor + ) + drawText( + evidence, + rect: NSRect( + x: frame.midX, + y: frame.maxY - titleHeight - (mode == .tiny ? 5 : 9), + width: frame.width * 0.5 - titleInset, + height: titleHeight + ), + font: font(size: mode == .tiny ? 8 : 10, weight: .medium, monospaced: true), + color: output.recentPresses > 0 ? secondary : NSColor.tertiaryLabelColor, + alignment: .right + ) + + let stageTop = frame.maxY - titleHeight - (mode == .tiny ? 8 : 16) + let stage = NSRect( + x: frame.minX + titleInset, + y: frame.minY + (mode == .tiny ? 5 : 10), + width: frame.width - titleInset * 2, + height: max(28, stageTop - frame.minY - (mode == .tiny ? 7 : 12)) + ) + + guard !output.items.isEmpty else { + drawEmptyKeycapStack(in: stage, mode: mode, bearMode: bearMode) + return + } + + let maxWidth = mode == .regular ? 118.0 : (mode == .compact ? 96.0 : 66.0) + let capWidth = min(CGFloat(maxWidth), stage.width * (mode == .tiny ? 0.28 : 0.30)) + let capHeight = min( + capWidth * 0.68, + stage.height * (mode == .tiny ? 0.72 : 0.62) + ) + let center = NSPoint(x: stage.midX, y: stage.midY - capHeight * 0.08) + for item in output.items { + drawKeycap( + item, + center: center, + size: NSSize(width: capWidth, height: capHeight), + bearMode: bearMode + ) + } + } + + private func drawEmptyKeycapStack( + in frame: NSRect, + mode: CaptureLayoutMode, + bearMode: Bool + ) { + let primary = bearMode ? bearCoral : brandAmber + let capWidth = min(mode == .tiny ? 54 : 82, frame.width * 0.28) + let capHeight = capWidth * 0.64 + for index in 0 ..< 3 { + let cap = NSRect( + x: frame.midX - capWidth / 2 + CGFloat(index - 1) * 3, + y: frame.midY - capHeight / 2 + CGFloat(index) * 3, + width: capWidth, + height: capHeight + ) + let path = NSBezierPath(roundedRect: cap, xRadius: 11, yRadius: 11) + primary.withAlphaComponent(0.08 + CGFloat(index) * 0.035).setFill() + path.fill() + primary.withAlphaComponent(0.18).setStroke() + path.lineWidth = 1 + path.stroke() + } + if mode != .tiny { + drawText( + "physical input appears here", + rect: NSRect( + x: frame.minX, + y: max(frame.minY, frame.midY - capHeight / 2 - 28), + width: frame.width, + height: 18 + ), + font: font(size: 11, weight: .regular, monospaced: true), + color: NSColor.tertiaryLabelColor, + alignment: .center + ) + } + } + + private func drawKeycap( + _ item: KeycapBurstItem, + center: NSPoint, + size: NSSize, + bearMode: Bool + ) { + let primary = bearMode ? bearCoral : brandAmber + let secondary = bearMode ? bearAmber : brandOrange + let light = bearMode ? bearCream : brandLight + let repeatColor = bearMode ? bearViolet : NSColor.systemRed + let width = size.width * CGFloat(item.scale) + let height = size.height * CGFloat(item.scale) + let x = center.x - width / 2 + CGFloat(item.xOffset) + let y = center.y - height / 2 + CGFloat(item.yOffset) + let opacity = CGFloat(item.opacity) + let baseDepth = max(4, height * 0.12) + let radius = max(8, width * 0.13) + + let base = NSRect(x: x, y: y, width: width, height: height) + let basePath = NSBezierPath(roundedRect: base, xRadius: radius, yRadius: radius) + (item.isRepeat ? repeatColor : secondary).withAlphaComponent(opacity * 0.88).setFill() + basePath.fill() + + let compression = CGFloat(item.pressDepth) * baseDepth * 0.76 + let face = NSRect( + x: x + width * 0.045, + y: y + baseDepth - compression, + width: width * 0.91, + height: height - baseDepth - height * 0.055 + compression * 0.24 + ) + let facePath = NSBezierPath( + roundedRect: face, + xRadius: max(7, radius * 0.76), + yRadius: max(7, radius * 0.76) + ) + let pressedGlow = item.isPressed || item.pressDepth > 0.15 + let faceColor = pressedGlow + ? light.blended(withFraction: 0.16, of: primary) ?? light + : light + faceColor.withAlphaComponent(opacity).setFill() + facePath.fill() + primary.withAlphaComponent(opacity * (pressedGlow ? 0.95 : 0.60)).setStroke() + facePath.lineWidth = pressedGlow ? 2 : 1 + facePath.stroke() + + let fontSize = min(width * (item.label.count > 2 ? 0.16 : 0.30), height * 0.38) + drawText( + item.label, + rect: NSRect( + x: face.minX + 4, + y: face.midY - fontSize * 0.63, + width: face.width - 8, + height: fontSize * 1.35 + ), + font: font(size: max(8, fontSize), weight: .bold, monospaced: true), + color: NSColor(calibratedWhite: 0.14, alpha: opacity), + alignment: .center + ) + } + + private func drawText( + _ text: String, + rect: NSRect, + font: NSFont, + color: NSColor, + alignment: NSTextAlignment = .left + ) { + let paragraph: NSParagraphStyle + if let cached = paragraphCache[alignment.rawValue] { + paragraph = cached + } else { + let value = NSMutableParagraphStyle() + value.alignment = alignment + value.lineBreakMode = .byTruncatingTail + paragraph = value.copy() as! NSParagraphStyle + paragraphCache[alignment.rawValue] = paragraph + } + (text as NSString).draw(in: rect, withAttributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ]) + } + + private func drawWrappedText( + _ text: String, + rect: NSRect, + font: NSFont, + color: NSColor, + alignment: NSTextAlignment = .left + ) { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byWordWrapping + paragraph.lineSpacing = 2 + paragraph.alignment = alignment + (text as NSString).draw( + with: rect, + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [ + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraph, + ] + ) + } + + private func font( + size: CGFloat, weight: NSFont.Weight, monospaced: Bool = false + ) -> NSFont { + let key = "\(monospaced ? "mono" : "system"):\(size):\(weight.rawValue)" + if let cached = fontCache[key] { + return cached + } + let value = monospaced + ? NSFont.monospacedSystemFont(ofSize: size, weight: weight) + : NSFont.systemFont(ofSize: size, weight: weight) + fontCache[key] = value + return value + } + + private func color(for state: CaptureRunState) -> NSColor { + switch state { + case .idle: brandAmber + case .armed: brandOrange + case .capturing: brandAmber + case .passed: NSColor.systemGreen + case .failed: NSColor.systemRed + } + } + + private func drawJigLogo(in frame: NSRect) { + guard let jigLogo else { + drawText( + "JIG", + rect: frame, + font: font(size: frame.height * 0.30, weight: .bold, monospaced: true), + color: brandAmber, + alignment: .center + ) + return + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.imageInterpolation = .high + jigLogo.draw( + in: frame, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + NSGraphicsContext.restoreGraphicsState() + } + + private func drawBearLogo(in frame: NSRect) { + guard let bearLogo else { + let badge = NSBezierPath( + roundedRect: frame, + xRadius: frame.width * 0.24, + yRadius: frame.height * 0.24 + ) + bearCoral.setFill() + badge.fill() + drawText( + "B", + rect: NSRect( + x: frame.minX, + y: frame.midY - frame.height * 0.27, + width: frame.width, + height: frame.height * 0.58 + ), + font: font(size: frame.height * 0.48, weight: .heavy), + color: .white, + alignment: .center + ) + return + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.imageInterpolation = .high + bearLogo.draw( + in: frame, + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high] + ) + NSGraphicsContext.restoreGraphicsState() + } + + private func drawActivityRail( + in header: NSRect, + motion: CaptureBrandMotion, + stateAccent: NSColor + ) { + drawActivityRail( + in: header, + completion: motion.completion, + breath: motion.breath, + eventPulse: motion.eventPulse, + glintPosition: motion.glintPosition, + stateAccent: stateAccent + ) + } + + private func drawActivityRail( + in header: NSRect, + completion: Double, + breath _: Double, + eventPulse: Double, + glintPosition: Double, + stateAccent: NSColor + ) { + let rail = NSRect(x: header.minX + 12, y: header.minY + 3, width: header.width - 24, height: 2) + NSColor.labelColor.withAlphaComponent(0.07).setFill() + NSBezierPath(roundedRect: rail, xRadius: 1, yRadius: 1).fill() + + if completion > 0 { + let fill = NSRect( + x: rail.minX, y: rail.minY, + width: max(2, rail.width * CGFloat(completion)), height: rail.height + ) + stateAccent.withAlphaComponent(0.72).setFill() + NSBezierPath(roundedRect: fill, xRadius: 1, yRadius: 1).fill() + } + + let travel = rail.minX + rail.width * CGFloat(glintPosition) + drawGlint( + center: NSPoint(x: travel, y: rail.midY), + size: 5 + CGFloat(eventPulse * 4), + color: brandLight.withAlphaComponent(0.55 + eventPulse * 0.4) + ) + } + + private func drawGlint(center: NSPoint, size: CGFloat, color: NSColor) { + let half = size / 2 + let waist = max(0.6, size * 0.12) + let path = NSBezierPath() + path.move(to: NSPoint(x: center.x, y: center.y + half)) + path.line(to: NSPoint(x: center.x + waist, y: center.y + waist)) + path.line(to: NSPoint(x: center.x + half, y: center.y)) + path.line(to: NSPoint(x: center.x + waist, y: center.y - waist)) + path.line(to: NSPoint(x: center.x, y: center.y - half)) + path.line(to: NSPoint(x: center.x - waist, y: center.y - waist)) + path.line(to: NSPoint(x: center.x - half, y: center.y)) + path.line(to: NSPoint(x: center.x - waist, y: center.y + waist)) + path.close() + color.setFill() + path.fill() + } +} + +private final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + private let session = CaptureSession() + private let bearMonitor = TypoverBearMonitorSession() + private let resourceMonitor = SystemResourceMonitor() + private var window: NSWindow! + private var canvas: CaptureCanvas! + private var timer: Timer? + private var lastCommandID = "" + private var persistedTerminalKey = "" + private var runActive = false + private var lastRenderedState: CaptureRunState? + private var ambientFrame = 0 + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + + private lazy var stateDirectory: URL = { + if let override = ProcessInfo.processInfo.environment["KEYPATH_CAPTURE_JIG_STATE_DIR"], !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/keypath-hid-capture-jig", isDirectory: true) + }() + + func applicationDidFinishLaunching(_: Notification) { + NSApp.setActivationPolicy(.regular) + buildWindow() + resourceMonitor.sampleIfNeeded(nowNs: monotonicNow(), force: true) + canvas.systemReadiness = resourceMonitor.assessment + prepareStateDirectory() + writeReadyFile() + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.tick() + } + RunLoop.main.add(timer!, forMode: .common) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + true + } + + func windowDidBecomeKey(_: Notification) { + window.makeFirstResponder(canvas) + session.noteFocus(true, nowNs: monotonicNow()) + canvas.needsDisplay = true + } + + func windowDidResignKey(_: Notification) { + session.noteFocus(false, nowNs: monotonicNow()) + canvas.needsDisplay = true + } + + private func buildWindow() { + canvas = CaptureCanvas(session: session, bearMonitor: bearMonitor) + canvas.onFocusChange = { [weak self] focused in + self?.session.noteFocus(focused, nowNs: monotonicNow()) + } + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 860, height: 660), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "KeyPath HID Capture Jig" + window.minSize = NSSize(width: 380, height: 280) + let frameAutosaveName = "KeyPathHIDCaptureJigWindow" + if !window.setFrameUsingName(frameAutosaveName) { + window.center() + } + window.setFrameAutosaveName(frameAutosaveName) + window.delegate = self + window.contentView = canvas + } + + private func prepareStateDirectory() { + try? FileManager.default.createDirectory(at: stateDirectory, withIntermediateDirectories: true) + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: stateDirectory.path) + let artifacts = stateDirectory.appendingPathComponent("artifacts", isDirectory: true) + try? FileManager.default.createDirectory(at: artifacts, withIntermediateDirectories: true) + } + + private func writeReadyFile() { + let value: [String: Any] = [ + "schemaVersion": 1, + "processID": ProcessInfo.processInfo.processIdentifier, + "status": "ready", + "stateDirectory": stateDirectory.path, + ] + guard let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) else { return } + writeAtomically(data, to: stateDirectory.appendingPathComponent("ready.json")) + } + + private func tick() { + processCommandIfNeeded() + let nowNs = monotonicNow() + if resourceMonitor.sampleIfNeeded(nowNs: nowNs) { + canvas.systemReadiness = resourceMonitor.assessment + canvas.needsDisplay = true + } + let snapshot = session.snapshot(nowNs: nowNs) + let bearSnapshot = bearMonitor.snapshot(nowNs: nowNs) + if bearSnapshot.isActive { + canvas.needsDisplay = true + return + } + let burstAnimating = KeycapBurstModel.resolve( + events: snapshot.events, + pressedKeyCodes: snapshot.pressedKeyCodes, + nowNs: nowNs, + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ).isAnimating + if !runActive { + ambientFrame = (ambientFrame + 1) % 2 + if burstAnimating || + (!NSWorkspace.shared.accessibilityDisplayShouldReduceMotion && ambientFrame == 0) + { + canvas.needsDisplay = true + } + lastRenderedState = snapshot.state + return + } + if snapshot.state == .armed, !snapshot.focused { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + } + if snapshot.state == .armed || snapshot.state == .capturing || + snapshot.state != lastRenderedState + { + canvas.needsDisplay = true + } + lastRenderedState = snapshot.state + persistTerminalResultIfNeeded(snapshot) + if snapshot.state == .passed || snapshot.state == .failed { + runActive = false + } + } + + private func processCommandIfNeeded() { + let commandURL = stateDirectory.appendingPathComponent("command.json") + guard let data = try? Data(contentsOf: commandURL), + let command = try? JSONDecoder().decode(ControlCommand.self, from: data), + command.id != lastCommandID + else { return } + lastCommandID = command.id + if command.action == "arm" { + beginArm(command, attempt: 0) + return + } + if command.action == "focus" { + beginFocus(command, attempt: 0) + return + } + let response = handle(command) + write(response) + } + + private func beginFocus(_ command: ControlCommand, attempt: Int) { + leaveBearMonitorMode() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + let focused = window.isKeyWindow && window.firstResponder === canvas + if !focused, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.beginFocus(command, attempt: attempt + 1) + } + return + } + write(ControlResponse( + id: command.id, + ok: focused, + message: focused ? "capture jig focused" : "capture jig could not acquire keyboard focus", + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: session.snapshot(nowNs: monotonicNow()), + systemReadiness: resourceMonitor.assessment, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) + )) + } + + private func beginArm(_ command: ControlCommand, attempt: Int) { + leaveBearMonitorMode() + let readiness = resourceMonitor.assessment + let demoMode = command.demoMode == true + guard demoMode || readiness.canProceed else { + runActive = false + canvas.systemReadiness = readiness + canvas.needsDisplay = true + let guidance = readiness.suggestions.first.map { " \($0)" } ?? "" + write(ControlResponse( + id: command.id, + ok: false, + message: "capture paused: \(readiness.detail)\(guidance)", + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: session.snapshot(nowNs: monotonicNow()), + systemReadiness: readiness, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) + )) + return + } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window.makeFirstResponder(canvas) + let focused = window.isKeyWindow && window.firstResponder === canvas + if !focused, attempt < 10 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.beginArm(command, attempt: attempt + 1) + } + return + } + let ok = session.arm( + runID: command.runID ?? "", + expected: command.expected ?? "", + timeoutMs: command.timeoutMs ?? 10000, + settleMs: command.settleMs ?? 250, + focused: focused, + nowNs: monotonicNow() + ) + canvas.operatorInstruction = String((command.instruction ?? "").prefix(240)) + persistedTerminalKey = "" + runActive = ok + lastRenderedState = nil + canvas.needsDisplay = true + write(ControlResponse( + id: command.id, + ok: ok, + message: ok + ? (demoMode + ? "demo capture armed and focused; host resource admission bypassed" + : "capture armed and focused") + : "capture refused: focus or command bounds invalid", + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: session.snapshot(nowNs: monotonicNow()), + systemReadiness: readiness, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) + )) + } + + private func write(_ response: ControlResponse) { + guard let encoded = try? encoder.encode(response) else { return } + writeAtomically(encoded, to: stateDirectory.appendingPathComponent("response.json")) + } + + private func handle(_ command: ControlCommand) -> ControlResponse { + var ok = true + var message = "status captured" + switch command.action { + case "status": + break + case "bear-monitor-prepare": + session.reset() + ok = bearMonitor.prepare( + runID: command.runID ?? "", + caseCount: command.caseCount ?? 0, + message: command.message ?? "Focus the disposable Bear note", + nowNs: monotonicNow() + ) + if ok { + enterBearMonitorMode() + message = "Typover Bear focus gate visible without taking keyboard focus" + } else { + bearMonitor.reset() + message = "Typover Bear focus gate refused invalid matrix bounds" + } + case "bear-monitor-begin": + session.reset() + ok = bearMonitor.begin( + runID: command.runID ?? "", + caseIndex: command.caseIndex ?? 0, + caseCount: command.caseCount ?? 0, + intervalMs: command.intervalMs ?? 0, + wordCount: command.wordCount ?? 0, + scheduledText: command.scheduledText ?? "", + startDelayMs: command.startDelayMs ?? 0, + bearFocused: command.bearFocused == true, + nowNs: monotonicNow() + ) + if ok { + enterBearMonitorMode() + message = "Typover Bear monitor visible without taking keyboard focus" + } else { + bearMonitor.reset() + message = "Typover Bear monitor refused invalid case bounds" + } + case "bear-monitor-update": + guard let rawPhase = command.phase, + let phase = TypoverBearMonitorPhase(rawValue: rawPhase) + else { + ok = false + message = "Typover Bear monitor update needs a valid phase" + break + } + ok = bearMonitor.update( + phase: phase, + correctedWords: command.correctedWords, + missedWords: command.missedWords, + message: command.message, + bearFocused: command.bearFocused + ) + message = ok + ? "Typover Bear monitor updated" + : "Typover Bear monitor refused inconsistent evidence" + case "bear-monitor-reset": + leaveBearMonitorMode() + message = "Typover Bear monitor reset" + case "reset": + session.reset() + leaveBearMonitorMode() + canvas.operatorInstruction = "" + persistedTerminalKey = "" + runActive = false + lastRenderedState = nil + message = "capture session reset" + case "finalize": + session.finalize(nowNs: monotonicNow()) + canvas.operatorInstruction = "" + message = "capture finalized" + case "quit": + message = "capture jig quitting" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { NSApp.terminate(nil) } + default: + ok = false + message = "unknown action: \(command.action)" + } + canvas.needsDisplay = true + let snapshot = session.snapshot(nowNs: monotonicNow()) + return ControlResponse( + id: command.id, + ok: ok, + message: message, + processID: ProcessInfo.processInfo.processIdentifier, + snapshot: snapshot, + systemReadiness: resourceMonitor.assessment, + bearMonitor: bearMonitor.snapshot(nowNs: monotonicNow()) + ) + } + + private func enterBearMonitorMode() { + runActive = false + canvas.operatorInstruction = "" + window.title = "Typover · Bear Physical HID Monitor" + window.level = .floating + window.hidesOnDeactivate = false + window.ignoresMouseEvents = true + window.orderFrontRegardless() + canvas.needsDisplay = true + } + + private func leaveBearMonitorMode() { + bearMonitor.reset() + window?.ignoresMouseEvents = false + window?.level = .normal + window?.title = "KeyPath HID Capture Jig" + canvas?.needsDisplay = true + } + + private func persistTerminalResultIfNeeded(_ snapshot: CaptureSnapshot) { + guard snapshot.state == .passed || snapshot.state == .failed, !snapshot.runID.isEmpty else { return } + let key = "\(snapshot.runID):\(snapshot.state.rawValue):\(snapshot.events.count)" + guard key != persistedTerminalKey, let data = try? encoder.encode(snapshot) else { return } + persistedTerminalKey = key + let safeRunID = snapshot.runID.map { $0.isLetter || $0.isNumber || "-_.".contains($0) ? $0 : "_" } + let filename = "\(String(safeRunID))-\(snapshot.state.rawValue).json" + writeAtomically(data, to: stateDirectory.appendingPathComponent("artifacts/\(filename)")) + } + + private func writeAtomically(_ data: Data, to url: URL) { + do { + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + NSLog("HID Capture Jig could not write %@: %@", url.path, error.localizedDescription) + } + } +} + +@main +private enum HIDCaptureJigMain { + static func main() { + let application = NSApplication.shared + installApplicationIcon(on: application) + installMainMenu(on: application) + let delegate = AppDelegate() + application.delegate = delegate + application.run() + withExtendedLifetime(delegate) {} + } + + private static func installApplicationIcon(on application: NSApplication) { + guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), + let image = NSImage(contentsOf: url) + else { return } + image.isTemplate = false + application.applicationIconImage = image + } + + private static func installMainMenu(on application: NSApplication) { + let applicationName = "KeyPath HID Capture Jig" + let mainMenu = NSMenu() + let applicationMenuItem = NSMenuItem() + let applicationMenu = NSMenu(title: applicationName) + + applicationMenu.addItem( + withTitle: "About \(applicationName)", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "" + ) + applicationMenu.addItem(.separator()) + + let servicesMenu = NSMenu(title: "Services") + let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + servicesItem.submenu = servicesMenu + applicationMenu.addItem(servicesItem) + application.servicesMenu = servicesMenu + + applicationMenu.addItem(.separator()) + applicationMenu.addItem( + withTitle: "Hide \(applicationName)", + action: #selector(NSApplication.hide(_:)), + keyEquivalent: "h" + ) + + let hideOthersItem = applicationMenu.addItem( + withTitle: "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h" + ) + hideOthersItem.keyEquivalentModifierMask = [.command, .option] + applicationMenu.addItem( + withTitle: "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "" + ) + + applicationMenu.addItem(.separator()) + applicationMenu.addItem( + withTitle: "Quit \(applicationName)", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + + applicationMenuItem.submenu = applicationMenu + mainMenu.addItem(applicationMenuItem) + application.mainMenu = mainMenu + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift new file mode 100644 index 000000000..be06bb1a8 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureBrandMotionTests.swift @@ -0,0 +1,68 @@ +import HIDCaptureCore +import XCTest + +final class CaptureBrandMotionTests: XCTestCase { + private let start: UInt64 = 1_000_000_000 + + func testCompletionAndMotionStayBounded() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "brand", expected: "abcd", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record( + phase: .down, keyCode: 0, characters: "a", modifiers: 0, + isRepeat: false, nowNs: start + 10 + ) + let snapshot = session.snapshot(nowNs: start + 20) + let motion = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 20, reduceMotion: false + ) + + XCTAssertEqual(motion.completion, 0.25, accuracy: 0.001) + XCTAssertTrue((0 ... 1).contains(motion.breath)) + XCTAssertTrue((0 ... 1).contains(motion.eventPulse)) + XCTAssertTrue((0 ... 1).contains(motion.glintPosition)) + } + + func testEventPulseDecaysWithoutInventingProgress() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "pulse", expected: "ab", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record( + phase: .down, keyCode: 0, characters: "a", modifiers: 0, + isRepeat: false, nowNs: start + 10 + ) + let snapshot = session.snapshot(nowNs: start + 20) + let immediate = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 10, reduceMotion: false + ) + let settled = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 500_000_000, reduceMotion: false + ) + + XCTAssertEqual(immediate.eventPulse, 1, accuracy: 0.001) + XCTAssertEqual(settled.eventPulse, 0, accuracy: 0.001) + XCTAssertEqual(settled.completion, 0.5, accuracy: 0.001) + } + + func testReduceMotionProducesStableEvidenceDrivenState() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "reduced", expected: "ab", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + let snapshot = session.snapshot(nowNs: start) + let first = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start, reduceMotion: true + ) + let later = CaptureBrandMotion.resolve( + snapshot: snapshot, nowNs: start + 9_000_000_000, reduceMotion: true + ) + + XCTAssertEqual(first, later) + XCTAssertEqual(first.eventPulse, 0) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift new file mode 100644 index 000000000..59a036d53 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureLayoutTests.swift @@ -0,0 +1,24 @@ +import HIDCaptureCore +import XCTest + +final class CaptureLayoutTests: XCTestCase { + func testRegularLayoutKeepsFullCaptureStage() { + let layout = CaptureLayoutMetrics.resolve(width: 860, height: 660) + XCTAssertEqual(layout.mode, .regular) + XCTAssertEqual(layout.fieldHeight, 34) + XCTAssertTrue(layout.showsFooter) + } + + func testCompactLayoutPreservesCoreState() { + let layout = CaptureLayoutMetrics.resolve(width: 620, height: 460) + XCTAssertEqual(layout.mode, .compact) + XCTAssertGreaterThan(layout.fieldHeight, 0) + } + + func testTinyLayoutFitsMinimumWindow() { + let layout = CaptureLayoutMetrics.resolve(width: 360, height: 250) + XCTAssertEqual(layout.mode, .tiny) + XCTAssertFalse(layout.showsFooter) + XCTAssertLessThan(layout.padding, 18) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift new file mode 100644 index 000000000..4775458e7 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/CaptureSessionTests.swift @@ -0,0 +1,148 @@ +import HIDCaptureCore +import XCTest + +final class CaptureSessionTests: XCTestCase { + private let start: UInt64 = 1_000_000_000 + + func testExactSequencePassesOnlyAfterReleaseAndSettle() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "exact", expected: "qw", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 12, characters: "q", modifiers: 0, isRepeat: false, nowNs: start + 10) + session.record(phase: .up, keyCode: 12, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 20) + session.record(phase: .down, keyCode: 13, characters: "w", modifiers: 0, isRepeat: false, nowNs: start + 30) + session.record(phase: .up, keyCode: 13, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 40) + + XCTAssertEqual(session.snapshot(nowNs: start + 50).state, .capturing) + let result = session.snapshot(nowNs: start + 100_000_041) + XCTAssertEqual(result.state, .passed) + XCTAssertEqual(result.received, "qw") + XCTAssertTrue(result.allKeysReleased) + XCTAssertTrue(result.issues.isEmpty) + } + + func testDuplicateAndRepeatFailWithExactEvidence() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "repeat", expected: "aa", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: true, nowNs: start + 20) + let result = session.snapshot(nowNs: start + 30) + XCTAssertEqual(result.state, .failed) + XCTAssertEqual(result.repeatEvents, 1) + XCTAssertEqual(result.duplicateDownEvents, 1) + XCTAssertEqual(result.pressedKeyCodes, [0]) + } + + func testWrongOrderFailsAtFirstMismatchedCharacter() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "order", expected: "abc", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 20) + session.record(phase: .down, keyCode: 2, characters: "c", modifiers: 0, isRepeat: false, nowNs: start + 30) + let result = session.snapshot(nowNs: start + 40) + XCTAssertEqual(result.state, .failed) + XCTAssertTrue(result.issues.contains { $0.contains("character 1") }) + } + + func testTimeoutReportsMissingAndStuckKeys() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "timeout", expected: "ab", timeoutMs: 250, + settleMs: 50, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 10) + let result = session.snapshot(nowNs: start + 250_000_001) + XCTAssertEqual(result.state, .failed) + XCTAssertTrue(result.issues.contains { $0.contains("missing 1") }) + XCTAssertTrue(result.issues.contains { $0.contains("unreleased key") }) + } + + func testLateInputCannotTurnTimedOutFailureIntoPass() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "late", expected: "a", timeoutMs: 250, + settleMs: 50, focused: true, nowNs: start + )) + XCTAssertEqual(session.snapshot(nowNs: start + 250_000_001).state, .failed) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 300_000_000) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 300_000_010) + let result = session.snapshot(nowNs: start + 400_000_000) + XCTAssertEqual(result.state, .failed) + XCTAssertEqual(result.received, "a") + } + + func testFocusLossFailsClosed() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "focus", expected: "a", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 5) + session.noteFocus(false, nowNs: start + 10) + let result = session.snapshot(nowNs: start + 20) + XCTAssertEqual(result.state, .failed) + XCTAssertEqual(result.issues, ["capture focus was lost"]) + } + + func testArmedSessionCanRecoverFocusBeforeFirstInput() { + let session = CaptureSession() + XCTAssertTrue(session.arm( + runID: "focus-recovery", expected: "a", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.noteFocus(false, nowNs: start + 10) + XCTAssertEqual(session.snapshot(nowNs: start + 20).state, .armed) + session.noteFocus(true, nowNs: start + 30) + session.record(phase: .down, keyCode: 0, characters: "a", modifiers: 0, isRepeat: false, nowNs: start + 40) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: 0, isRepeat: false, nowNs: start + 50) + XCTAssertEqual(session.snapshot(nowNs: start + 100_000_051).state, .passed) + } + + func testArmRejectsMissingFocusAndUnsafeBounds() { + let session = CaptureSession() + XCTAssertFalse(session.arm( + runID: "not-focused", expected: "a", timeoutMs: 2000, + settleMs: 100, focused: false, nowNs: start + )) + XCTAssertEqual(session.snapshot(nowNs: start).state, .failed) + XCTAssertFalse(session.arm( + runID: "empty", expected: "", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + } + + func testPassWaitsForModifierRelease() { + let session = CaptureSession() + let shift: UInt = 1 << 17 + XCTAssertTrue(session.arm( + runID: "modifier-release", expected: "A", timeoutMs: 2000, + settleMs: 100, focused: true, nowNs: start + )) + session.record(phase: .flagsChanged, keyCode: 56, characters: "", modifiers: shift, + isRepeat: false, nowNs: start + 10) + session.record(phase: .down, keyCode: 0, characters: "A", modifiers: shift, + isRepeat: false, nowNs: start + 20) + session.record(phase: .up, keyCode: 0, characters: "", modifiers: shift, + isRepeat: false, nowNs: start + 30) + + var result = session.snapshot(nowNs: start + 200_000_000) + XCTAssertEqual(result.state, .capturing) + XCTAssertEqual(result.activeModifiers, shift) + XCTAssertFalse(result.allKeysReleased) + + session.record(phase: .flagsChanged, keyCode: 56, characters: "", modifiers: 0, + isRepeat: false, nowNs: start + 200_000_010) + result = session.snapshot(nowNs: start + 300_000_011) + XCTAssertEqual(result.state, .passed) + XCTAssertEqual(result.activeModifiers, 0) + XCTAssertTrue(result.allKeysReleased) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift new file mode 100644 index 000000000..791d06e4d --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/KeycapBurstModelTests.swift @@ -0,0 +1,129 @@ +import HIDCaptureCore +import XCTest + +final class KeycapBurstModelTests: XCTestCase { + func testOnlyKeyDownEventsBecomeKeycaps() { + let events = [ + event(1, .down, "q", at: 100), + event(2, .up, at: 110), + event(3, .flagsChanged, at: 120), + ] + + let output = KeycapBurstModel.resolve( + events: events, pressedKeyCodes: [12], nowNs: 130, reduceMotion: false + ) + + XCTAssertEqual(output.items.map(\.label), ["Q"]) + XCTAssertEqual(output.totalPresses, 1) + } + + func testDenseBurstIsCappedAndRaisesIntensity() { + let events = (1 ... 30).map { index in + event(index, .down, "x", at: 1_000_000_000 + UInt64(index) * 4_000_000) + } + + let output = KeycapBurstModel.resolve( + events: events, + pressedKeyCodes: [], + nowNs: 2_050_000_000, + reduceMotion: false, + limit: 10 + ) + + XCTAssertEqual(output.items.count, 10) + XCTAssertEqual(output.items.map(\.sequence), Array(21 ... 30)) + XCTAssertGreaterThanOrEqual(output.intensity, 0.85) + XCTAssertEqual(output.presentedPresses, 30) + XCTAssertTrue(output.isAnimating) + } + + func testFasterThanDisplayInputIsPresentedInOrderAtReadableCadence() { + let events = (1 ... 5).map { index in + event(index, .down, String(index), at: 1_000_000_000 + UInt64(index)) + } + + let early = KeycapBurstModel.resolve( + events: events, + pressedKeyCodes: [], + nowNs: 1_050_000_000, + reduceMotion: false + ) + let later = KeycapBurstModel.resolve( + events: events, + pressedKeyCodes: [], + nowNs: 1_160_000_000, + reduceMotion: false + ) + + XCTAssertEqual(early.presentedPresses, 2) + XCTAssertEqual(early.items.map(\.label), ["1", "2"]) + XCTAssertEqual(later.presentedPresses, 5) + XCTAssertEqual(later.items.map(\.label), ["1", "2", "3", "4", "5"]) + } + + func testPressCompressesImmediatelyThenReleases() { + let input = [event(1, .down, "a", at: 1_000_000_000)] + let initial = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [12], nowNs: 1_000_000_000, reduceMotion: false + ) + let released = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [], nowNs: 1_180_000_000, reduceMotion: false + ) + + XCTAssertEqual(initial.items[0].pressDepth, 1, accuracy: 0.001) + XCTAssertTrue(initial.items[0].isPressed) + XCTAssertEqual(released.items[0].pressDepth, 0, accuracy: 0.001) + XCTAssertFalse(released.items[0].isPressed) + } + + func testOldKeycapsAgeOut() { + let output = KeycapBurstModel.resolve( + events: [event(1, .down, "a", at: 1_000_000_000)], + pressedKeyCodes: [], + nowNs: 1_900_000_001, + reduceMotion: false + ) + + XCTAssertTrue(output.items.isEmpty) + } + + func testReducedMotionKeepsStackPositionStableWhileItIsVisible() { + let input = [event(1, .down, "a", at: 1_000_000_000)] + let early = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [12], nowNs: 1_020_000_000, reduceMotion: true + ) + let later = KeycapBurstModel.resolve( + events: input, pressedKeyCodes: [], nowNs: 1_400_000_000, reduceMotion: true + ) + + XCTAssertEqual(early.items[0].xOffset, later.items[0].xOffset) + XCTAssertEqual(early.items[0].yOffset, later.items[0].yOffset) + XCTAssertEqual(early.items[0].scale, later.items[0].scale) + XCTAssertEqual(early.items[0].pressDepth, 0) + } + + func testSpecialKeyLabelsAreReadable() { + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, " ", at: 0)), "SPACE") + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, "\r", at: 0)), "RETURN") + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, "\u{7f}", at: 0)), "DELETE") + XCTAssertEqual(KeycapBurstModel.label(for: event(1, .down, "", at: 0, keyCode: 53)), "ESC") + } + + private func event( + _ sequence: Int, + _ phase: CapturedEventPhase, + _ characters: String = "", + at timestampNs: UInt64, + keyCode: UInt16 = 12 + ) -> CapturedKeyEvent { + CapturedKeyEvent( + sequence: sequence, + phase: phase, + keyCode: keyCode, + characters: characters, + modifiers: 0, + isRepeat: false, + timestampNs: timestampNs + ) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift new file mode 100644 index 000000000..72af87b48 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/SystemReadinessTests.swift @@ -0,0 +1,69 @@ +import HIDCaptureCore +import XCTest + +final class SystemReadinessTests: XCTestCase { + private func sample( + at timestamp: UInt64, + cpu: Double? = 0.25, + load: Double = 0.35, + availableGB: UInt64 = 8, + pressure: Int = 1, + thermal: String = "nominal", + threads: Int = 3000 + ) -> SystemResourceSample { + SystemResourceSample( + timestampNs: timestamp, + cpuUtilization: cpu, + loadAveragePerCore: load, + availableMemoryBytes: availableGB * 1024 * 1024 * 1024, + physicalMemoryBytes: 32 * 1024 * 1024 * 1024, + threadCount: threads, + logicalProcessorCount: 10, + memoryPressureLevel: pressure, + thermalState: thermal + ) + } + + func testRequiresStableWindowBeforeProceeding() { + let first = SystemReadinessModel.resolve(samples: [sample(at: 1)]) + XCTAssertEqual(first.state, .calibrating) + XCTAssertFalse(first.canProceed) + + let ready = SystemReadinessModel.resolve(samples: [ + sample(at: 1), sample(at: 2), sample(at: 3), + ]) + XCTAssertEqual(ready.state, .ready) + XCTAssertTrue(ready.canProceed) + XCTAssertEqual(ready.stableSamples, 3) + } + + func testBusyCPUAndLoadFailWithActionableGuidance() { + let assessment = SystemReadinessModel.resolve(samples: [ + sample(at: 1), sample(at: 2), sample(at: 3, cpu: 0.91, load: 1.4), + ]) + XCTAssertEqual(assessment.state, .waiting) + XCTAssertFalse(assessment.canProceed) + XCTAssertTrue(assessment.issues.contains { $0.contains("CPU") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("competing work") }) + XCTAssertTrue(assessment.suggestions.contains { $0.contains("Pause builds") }) + } + + func testMemoryThermalAndThreadPressureFailClosed() { + let assessment = SystemReadinessModel.resolve(samples: [sample( + at: 1, availableGB: 1, pressure: 2, thermal: "serious", threads: 9100 + )]) + XCTAssertFalse(assessment.canProceed) + XCTAssertTrue(assessment.issues.contains { $0.contains("memory") && $0.contains("available") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("memory pressure") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("thermal") }) + XCTAssertTrue(assessment.issues.contains { $0.contains("thread inventory") }) + } + + func testRecentBusySampleMustAgeOutOfWindow() { + let samples = [ + sample(at: 1, cpu: 0.95), sample(at: 2), sample(at: 3), sample(at: 4), + ] + XCTAssertTrue(SystemReadinessModel.resolve(samples: samples).canProceed) + XCTAssertFalse(SystemReadinessModel.resolve(samples: Array(samples.dropLast())).canProceed) + } +} diff --git a/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift new file mode 100644 index 000000000..970d10fe5 --- /dev/null +++ b/Scripts/lab/hid-capture-jig/Tests/HIDCaptureCoreTests/TypoverBearMonitorTests.swift @@ -0,0 +1,92 @@ +import HIDCaptureCore +import XCTest + +final class TypoverBearMonitorTests: XCTestCase { + private let start: UInt64 = 1_000_000_000 + + func testBeginCreatesCountdownWithoutInventingObservedKeys() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.begin( + runID: "bear-1", caseIndex: 1, caseCount: 4, + intervalMs: 40, wordCount: 2, scheduledText: "teh teh \n", + startDelayMs: 1500, bearFocused: true, nowNs: start + )) + + let snapshot = session.snapshot(nowNs: start + 1_000_000_000) + XCTAssertEqual(snapshot.phase, .countdown) + XCTAssertTrue(snapshot.scheduledEvents.isEmpty) + XCTAssertEqual(snapshot.totalKeyPresses, 9) + XCTAssertEqual(snapshot.keysPerSecond, 25, accuracy: 0.001) + } + + func testPrepareShowsBearFocusGateWithoutStartingAKeySchedule() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.prepare( + runID: "bear-matrix", caseCount: 4, + message: "Focus the disposable Bear note", nowNs: start + )) + + let snapshot = session.snapshot(nowNs: start + 90_000_000_000) + XCTAssertEqual(snapshot.phase, .waitingForBear) + XCTAssertEqual(snapshot.caseIndex, 0) + XCTAssertEqual(snapshot.caseCount, 4) + XCTAssertTrue(snapshot.scheduledEvents.isEmpty) + XCTAssertNil(snapshot.scheduledStartAtNs) + } + + func testScheduledKeycapsFollowThePhysicalCadence() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.begin( + runID: "bear-2", caseIndex: 2, caseCount: 4, + intervalMs: 100, wordCount: 1, scheduledText: "teh \n", + startDelayMs: 500, bearFocused: true, nowNs: start + )) + XCTAssertTrue(session.update(phase: .typing)) + + let first = session.snapshot(nowNs: start + 500_000_000) + let third = session.snapshot(nowNs: start + 700_000_000) + XCTAssertEqual(first.scheduledEvents.map(\.characters), ["t"]) + XCTAssertEqual(third.scheduledEvents.map(\.characters), ["t", "e", "h"]) + XCTAssertEqual(third.scheduledEvents.map(\.timestampNs), [ + start + 500_000_000, + start + 600_000_000, + start + 700_000_000, + ]) + } + + func testAnalysisCountsAreBoundedByTheCaseWordCount() { + let session = TypoverBearMonitorSession() + XCTAssertTrue(session.begin( + runID: "bear-3", caseIndex: 3, caseCount: 4, + intervalMs: 80, wordCount: 20, scheduledText: "teh ", + startDelayMs: 500, bearFocused: true, nowNs: start + )) + + XCTAssertTrue(session.update( + phase: .safeMisses, + correctedWords: 14, + missedWords: 6, + message: "14 applied · 6 safe misses", + bearFocused: true + )) + XCTAssertFalse(session.update( + phase: .passed, + correctedWords: 21, + missedWords: 0 + )) + let snapshot = session.snapshot(nowNs: start) + XCTAssertEqual(snapshot.correctedWords, 14) + XCTAssertEqual(snapshot.missedWords, 6) + XCTAssertEqual(snapshot.phase, .safeMisses) + } + + func testInvalidOrNonASCIIPlansFailClosed() { + let session = TypoverBearMonitorSession() + XCTAssertFalse(session.begin( + runID: "", caseIndex: 0, caseCount: 0, + intervalMs: 3, wordCount: 0, scheduledText: "é", + startDelayMs: 0, bearFocused: false, nowNs: start + )) + XCTAssertEqual(session.snapshot(nowNs: start).phase, .inactive) + } +} diff --git a/Scripts/lab/hid-capture-jig/build-app.sh b/Scripts/lab/hid-capture-jig/build-app.sh new file mode 100755 index 000000000..3b311fddd --- /dev/null +++ b/Scripts/lab/hid-capture-jig/build-app.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +app_path=${KEYPATH_CAPTURE_JIG_APP:-"$HOME/.cache/keypath-hid-capture-jig/KeyPath HID Capture Jig.app"} +source_stamp="$app_path/Contents/Resources/source.sha256" +source_hash=$( + { + find "$script_dir/Sources" "$script_dir/Resources" -type f -print + printf '%s\n' "$script_dir/Package.swift" + } | LC_ALL=C sort | while IFS= read -r source; do + shasum -a 256 "$source" + done | shasum -a 256 | awk '{print $1}' +) + +if [[ ${KEYPATH_CAPTURE_JIG_FORCE_BUILD:-0} != 1 && + -x "$app_path/Contents/MacOS/HIDCaptureJig" && + -f "$source_stamp" && + $(<"$source_stamp") == "$source_hash" ]]; then + printf '%s\n' "$app_path" + exit 0 +fi +icon_work=$(mktemp -d "${TMPDIR:-/tmp}/keypath-hid-jig-icon.XXXXXX") +trap 'rm -rf "$icon_work"' EXIT + +swift build --package-path "$script_dir" --product HIDCaptureJig --jobs "${KEYPATH_CAPTURE_JIG_BUILD_JOBS:-4}" +bin_path=$(swift build --package-path "$script_dir" --show-bin-path) + +mkdir -p "$app_path/Contents/MacOS" +mkdir -p "$app_path/Contents/Resources" +# Keep incremental app builds from retaining resources removed from the bundle. +rm -f "$app_path/Contents/Resources/KeyPathLogo.icns" +install -m 755 "$bin_path/HIDCaptureJig" "$app_path/Contents/MacOS/HIDCaptureJig" +install -m 644 "$script_dir/Resources/Info.plist" "$app_path/Contents/Info.plist" +xcrun swift "$script_dir/Resources/generate_jig_icon.swift" "$icon_work/JigIcon.iconset" +iconutil -c icns "$icon_work/JigIcon.iconset" -o "$app_path/Contents/Resources/AppIcon.icns" +printf '%s\n' "$source_hash" >"$source_stamp" +codesign --force --sign - --timestamp=none "$app_path" >/dev/null +printf '%s\n' "$app_path" diff --git a/Scripts/lab/install-keypath-artifact b/Scripts/lab/install-keypath-artifact new file mode 100755 index 000000000..74a01e17c --- /dev/null +++ b/Scripts/lab/install-keypath-artifact @@ -0,0 +1,36 @@ +#!/bin/zsh +set -euo pipefail + +archive=${1:-} +expected=${2:-} +[[ -f "$archive" && ! -L "$archive" && -n "$expected" ]] || { + print -u2 "Usage: $0 ARCHIVE EXPECTED_VERSION" + exit 2 +} + +staging=$(mktemp -d /tmp/keypath-upgrade.XXXXXXXX) +trap '/bin/rm -rf "$staging"' EXIT + +/usr/bin/ditto -x -k "$archive" "$staging" +app="$staging/KeyPath.app" +[[ -d "$app" ]] || { + print -u2 "artifact install: archive does not contain root KeyPath.app" + exit 3 +} + +/usr/bin/codesign --verify --deep --strict "$app" +actual=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist") +[[ "$actual" == "$expected" ]] || { + print -u2 "artifact install: expected $expected, found $actual" + exit 3 +} + +manifest=/Library/KeyPathLab/managed-policy/manifest.json +if [[ -f "$manifest" ]]; then + "$(dirname "$0")/mdm/verify-artifact-policy" --app "$app" --manifest "$manifest" +fi + +/usr/bin/pkill -x KeyPath >/dev/null 2>&1 || true +/usr/bin/sudo -n /bin/rm -rf /Applications/KeyPath.app +/usr/bin/sudo -n /usr/bin/ditto "$app" /Applications/KeyPath.app +"$(dirname "$0")/assert-app-version" "$expected" diff --git a/Scripts/lab/install-runtime b/Scripts/lab/install-runtime new file mode 100755 index 000000000..95f0646d1 --- /dev/null +++ b/Scripts/lab/install-runtime @@ -0,0 +1,263 @@ +#!/bin/zsh +set -euo pipefail + +lane=${1:-} +[[ "$lane" == "managed-functional" || "$lane" == "unmanaged-ui" ]] || { + print -u2 "usage: install-runtime managed-functional|unmanaged-ui" + exit 2 +} + +output="$PWD/.keypath-lab/scenario-output/install-runtime" +state="$output/state.tsv" +cli=${KEYPATH_INSTALL_RUNTIME_CLI:-/Applications/KeyPath.app/Contents/MacOS/keypath-cli} +assert_runtime=${KEYPATH_INSTALL_RUNTIME_ASSERT:-Scripts/lab/assert-runtime-state} +app_open=${KEYPATH_INSTALL_RUNTIME_OPEN:-/usr/bin/open} +app_quit=${KEYPATH_INSTALL_RUNTIME_QUIT:-/usr/bin/osascript} +config_path=${KEYPATH_INSTALL_RUNTIME_CONFIG:-$HOME/.config/keypath/keypath.kbd} +bootstrap_timeout=${KEYPATH_INSTALL_RUNTIME_BOOTSTRAP_TIMEOUT:-60} +allow_no_input_device=${KEYPATH_INSTALL_RUNTIME_ALLOW_NO_INPUT_DEVICE:-0} +kanata_binary=${KEYPATH_INSTALL_RUNTIME_KANATA_BINARY:-/Applications/KeyPath.app/Contents/Library/KeyPath/Kanata Engine.app/Contents/MacOS/kanata} +launchctl_binary=${KEYPATH_INSTALL_RUNTIME_LAUNCHCTL:-/bin/launchctl} +mkdir -p "$output" + +[[ -x "$cli" ]] || { + print -u2 "installed KeyPath CLI is unavailable" + exit 3 +} + +json_field() { + local value + value=$(/usr/bin/plutil -extract "data.$2" raw -o - "$1" 2>/dev/null || true) + if [[ -z "$value" ]]; then + value=$(/usr/bin/plutil -extract "$2" raw -o - "$1" 2>/dev/null || true) + fi + print -r -- "$value" +} + +state_field() { + [[ -f "$state" ]] || return 0 + /usr/bin/awk -F '\t' -v key="$1" '$1 == key { value=$2 } END { print value }' "$state" +} + +valid_id() { + [[ "$1" =~ '^[A-Fa-f0-9-]{36}$' ]] +} + +ensure_default_config() { + [[ -s "$config_path" ]] && return 0 + + "$app_open" -a KeyPath + local waited=0 + while (( waited < bootstrap_timeout )); do + [[ -s "$config_path" ]] && break + sleep 1 + (( waited += 1 )) + done + + if [[ "$app_quit" == "/usr/bin/osascript" ]]; then + "$app_quit" -e 'tell application "KeyPath" to quit' >/dev/null 2>&1 || true + else + "$app_quit" >/dev/null 2>&1 || true + fi + + [[ -s "$config_path" ]] || { + print -u2 "KeyPath first launch did not create its default user config" + return 1 + } +} + +write_state() { + local runtime_status=$1 + local previous_preflight_plan_id previous_preflight_snapshot_id previous_run_id previous_plan_id + local previous_before_snapshot_id previous_after_snapshot_id + previous_preflight_plan_id=$(state_field preflight_plan_id) + previous_preflight_snapshot_id=$(state_field preflight_snapshot_id) + previous_run_id=$(state_field run_id) + previous_plan_id=$(state_field plan_id) + previous_before_snapshot_id=$(state_field before_snapshot_id) + previous_after_snapshot_id=$(state_field after_snapshot_id) + { + print "status\t$runtime_status" + print "preflight_plan_id\t${preflight_plan_id:-$previous_preflight_plan_id}" + print "preflight_snapshot_id\t${preflight_snapshot_id:-$previous_preflight_snapshot_id}" + print "run_id\t${run_id:-$previous_run_id}" + print "plan_id\t${plan_id:-$previous_plan_id}" + print "before_snapshot_id\t${before_snapshot_id:-$previous_before_snapshot_id}" + print "after_snapshot_id\t${after_snapshot_id:-$previous_after_snapshot_id}" + print "updated_at\t$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$state" +} + +verify_postconditions() { + set +e + "$cli" system inspect --json --quiet --timeout 180 > "$output/post-inspect.json" 2> "$output/post-inspect.stderr" + local inspect_exit=$? + set -e + local operational + operational=$(json_field "$output/post-inspect.json" isOperational) + + set +e + if [[ "$lane" == "managed-functional" ]]; then + "$assert_runtime" ready \ + --managed-policy-manifest /Library/KeyPathLab/managed-policy/manifest.json \ + > "$output/observed-ready.tsv" 2> "$output/observed-ready.stderr" + else + "$assert_runtime" ready \ + > "$output/observed-ready.tsv" 2> "$output/observed-ready.stderr" + fi + local observed_exit=$? + set -e + + if (( inspect_exit == 0 && observed_exit == 0 )) && [[ "$operational" == "true" ]]; then + cat > "$output/assert-state.json" < "$output/no-input-launchd.txt" 2>&1 || return 1 + "$kanata_binary" --list > "$output/no-input-device-list.txt" 2>&1 || return 1 + /usr/bin/grep -Fq 'managed_by = com.apple.xpc.ServiceManagement' "$output/no-input-launchd.txt" || return 1 + /usr/bin/grep -Fq 'last exit code = 1' "$output/no-input-launchd.txt" || return 1 + /usr/bin/grep -Fq 'No devices found.' "$output/no-input-device-list.txt" || return 1 + + cat > "$output/assert-state.json" < "$output/preflight-inspect.json" 2> "$output/preflight-inspect.stderr" +preflight_plan_id=$(json_field "$output/preflight-inspect.json" planID) +preflight_snapshot_id=$(json_field "$output/preflight-inspect.json" snapshotID) +valid_id "$preflight_plan_id" || { + print -u2 "system inspect omitted a valid planID" + exit 3 +} +valid_id "$preflight_snapshot_id" || { + print -u2 "system inspect omitted a valid snapshotID" + exit 3 +} + +ensure_default_config || { + write_state failed + exit 1 +} + +set +e +"$cli" system install --json --quiet --timeout 600 \ + > "$output/install-report.json" 2> "$output/install-report.stderr" +install_exit=$? +set -e + +run_id=$(json_field "$output/install-report.json" runID) +plan_id=$(json_field "$output/install-report.json" planID) +before_snapshot_id=$(json_field "$output/install-report.json" beforeSnapshotID) +after_snapshot_id=$(json_field "$output/install-report.json" afterSnapshotID) +completion_state=$(json_field "$output/install-report.json" completionState) +user_action_required=$(json_field "$output/install-report.json" userActionRequired) +claimed_success=$(json_field "$output/install-report.json" success) + +for identifier in "$run_id" "$plan_id" "$before_snapshot_id"; do + valid_id "$identifier" || { + print -u2 "installer report omitted required plan lineage" + exit 3 + } +done +[[ -z "$after_snapshot_id" ]] || valid_id "$after_snapshot_id" || { + print -u2 "installer report contained an invalid afterSnapshotID" + exit 3 +} + +if [[ "$completion_state" == "awaiting-approval" || "$completion_state" == "awaitingApproval" || + ("$completion_state" == "completed" && "$user_action_required" == "true" && "$claimed_success" != "true") ]]; then + write_state awaiting-approval + print "install_runtime\twaiting" + print "user_action_required\tComplete the KeyPath approvals visible in the disposable guest, then resume this checkpoint." + exit 4 +fi + +if (( install_exit != 0 )) || [[ "$claimed_success" != "true" ]]; then + if verify_no_input_device_boundary; then + exit 0 + fi + write_state failed + print -u2 "KeyPath's installer reported failure" + exit 1 +fi + +write_state verifying +if verify_postconditions; then + exit 0 +fi + +cat > "$output/assert-state.json" < [options] Commands: preflight - create --macos 15|26|27 --lane managed-functional|unmanaged-ui --commit SHA --installer PATH [--ttl 2h] [--desktop] [--tart-usb-passthrough] + create --macos 15|26|27 --lane managed-functional|unmanaged-ui --commit SHA --installer PATH [--fixture PATH] [--ttl 2h] [--desktop] [--tart-usb-passthrough] install-app LEASE_ID + install-runtime LEASE_ID + install-upgrade-runtime LEASE_ID + install-fixture LEASE_ID console-login LEASE_ID + verify-console-login LEASE_ID reset-guest-password LEASE_ID + reset-desktop-keychain LEASE_ID + reboot-guest LEASE_ID secure-console-submit LEASE_ID + console-key LEASE_ID --key PARALLELS_KEY_CODE [--modifier PARALLELS_KEY_CODE] rfb-pointer-probe LEASE_ID --x X --y Y desktop-bootstrap LEASE_ID [--install-tools] nameplate LEASE_ID enable|show|hide|status protected-click LEASE_ID --app APP --window TITLE (--x X --y Y | --ax-x X --ax-y Y) [--after-window TITLE] [--count 1|2] + approve-input-monitoring LEASE_ID desktop-type LEASE_ID --text TEXT secure-dialog-input LEASE_ID --app APP --field LABEL [--submit BUTTON] [--already-focused] resume-managed-policy LEASE_ID run LEASE_ID -- COMMAND [ARG...] status LEASE_ID list - artifacts LEASE_ID + artifacts LEASE_ID [--output DIRECTORY] scenario LEASE_ID NAME destroy LEASE_ID cleanup [--dry-run] @@ -66,7 +74,8 @@ remote() { printf -v quoted '%q' "$argument" remote_command="$remote_command $quoted" done - ssh -o BatchMode=yes "$host" "$remote_command" < "$REMOTE_SCRIPT" + ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 \ + "$host" "$remote_command" < "$REMOTE_SCRIPT" } require_lease_id() { @@ -83,6 +92,7 @@ case "$command" in lane= commit= installer= + fixture= ttl=2h desktop=0 tart_usb_passthrough=0 @@ -92,6 +102,7 @@ case "$command" in --lane) lane=${2:-}; shift 2 ;; --commit) commit=${2:-}; shift 2 ;; --installer) installer=${2:-}; shift 2 ;; + --fixture) fixture=${2:-}; shift 2 ;; --ttl) ttl=${2:-}; shift 2 ;; --desktop) desktop=1; shift ;; --tart-usb-passthrough) tart_usb_passthrough=1; shift ;; @@ -104,18 +115,93 @@ case "$command" in [[ $tart_usb_passthrough == "0" || $macos == "15" ]] || die "--tart-usb-passthrough requires --macos 15" [[ $commit =~ ^[0-9a-fA-F]{40}$ ]] || die "--commit must be a full 40-character SHA" [[ -f $installer ]] || die "installer not found: $installer" + [[ -z $fixture || -f $fixture ]] || die "fixture not found: $fixture" resolved=$(git -C "$REPO_ROOT" rev-parse --verify "$commit^{commit}") [[ $resolved == "$commit" ]] || die "commit does not resolve exactly: $commit" + harness_commit=$(git -C "$REPO_ROOT" rev-parse --verify HEAD^{commit}) + [[ ${KEYPATH_LAB_ALLOW_DIRTY_HARNESS:-0} == 1 || -z $(git -C "$REPO_ROOT" status --short --untracked-files=no) ]] || die "committed lab harness is required before creating an archive" installer_sha=$(shasum -a 256 "$installer" | awk '{print $1}') - archive_key="${commit}-${installer_sha}" + installer_name=$(basename "$installer") + [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" + base_product_archive_key="${commit}-${installer_sha}" + base_archive_key="$base_product_archive_key" + candidate_archive_key="${base_product_archive_key}-h${harness_commit}" + archive_key="$candidate_archive_key" + fixture_name= + fixture_sha= + if [[ -n $fixture ]]; then + fixture_name=$(basename "$fixture") + [[ $fixture_name =~ ^[A-Za-z0-9._-]+$ ]] || die "fixture filename must contain only letters, numbers, dots, underscores, and dashes" + fixture_sha=$(shasum -a 256 "$fixture" | awk '{print $1}') + base_archive_key="${base_archive_key}-${fixture_sha}" + archive_key="${archive_key}-${fixture_sha}" + fi + if remote archive-status "$archive_key" "$commit" "$installer_sha" "$installer_name"; then + remote create "$macos" "$lane" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$ttl" "$desktop" "$tart_usb_passthrough" + exit $? + fi temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/keypath-lab.XXXXXX") trap 'rm -rf "$temp_dir"' EXIT + if [[ -n $fixture ]] && ! remote archive-status "$candidate_archive_key" "$commit" "$installer_sha" "$installer_name" >/dev/null \ + && remote archive-status "$base_product_archive_key" "$commit" "$installer_sha" "$installer_name" >/dev/null; then + mkdir -p "$temp_dir/candidate-overlay" + git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/candidate-overlay" + if [[ $lane == "managed-functional" ]]; then + mkdir -p "$temp_dir/candidate-installer-extract" + /usr/bin/ditto -x -k "$installer" "$temp_dir/candidate-installer-extract" + [[ -d $temp_dir/candidate-installer-extract/KeyPath.app ]] || die "managed installer must contain KeyPath.app at its root" + "$SCRIPT_DIR/mdm/generate-keypath-profiles" \ + --app "$temp_dir/candidate-installer-extract/KeyPath.app" \ + --output "$temp_dir/candidate-overlay/.keypath-lab/managed-policy" >/dev/null + fi + tar -czf "$temp_dir/candidate-overlay.tgz" -C "$temp_dir/candidate-overlay" . + upload_ticket=$(remote prepare-upload "$candidate_archive_key") + [[ $upload_ticket == /tmp/keypath-lab.* ]] || die "host returned an invalid upload ticket" + scp -q "$temp_dir/candidate-overlay.tgz" "$host:$upload_ticket" + remote derive-archive "$upload_ticket" "$base_product_archive_key" "$candidate_archive_key" "$commit" "$installer_sha" "$installer_name" "$harness_commit" + fi + if [[ -n $fixture ]] && remote archive-status "$candidate_archive_key" "$commit" "$installer_sha" "$installer_name" >/dev/null; then + fixture_archive_output=$(remote find-fixture-archive "$fixture_sha" "$fixture_name" || true) + fixture_source_key=$(printf '%s\n' "$fixture_archive_output" | awk -F '\t' '$1 == "fixture_archive" {print $2; exit}') + if [[ $fixture_source_key =~ ^[A-Za-z0-9._-]+$ ]]; then + remote derive-fixture-archive "$candidate_archive_key" "$fixture_source_key" "$archive_key" \ + "$commit" "$installer_sha" "$installer_name" "$harness_commit" "$fixture_sha" "$fixture_name" + remote create "$macos" "$lane" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$ttl" "$desktop" "$tart_usb_passthrough" + exit $? + fi + fi + if remote archive-status "$base_archive_key" "$commit" "$installer_sha" "$installer_name"; then + mkdir -p "$temp_dir/overlay" + git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/overlay" + if [[ $lane == "managed-functional" ]]; then + mkdir -p "$temp_dir/installer-extract" + /usr/bin/ditto -x -k "$installer" "$temp_dir/installer-extract" + [[ -d $temp_dir/installer-extract/KeyPath.app ]] || die "managed installer must contain KeyPath.app at its root" + "$SCRIPT_DIR/mdm/generate-keypath-profiles" \ + --app "$temp_dir/installer-extract/KeyPath.app" \ + --output "$temp_dir/overlay/.keypath-lab/managed-policy" >/dev/null + fi + tar -czf "$temp_dir/overlay.tgz" -C "$temp_dir/overlay" . + upload_ticket=$(remote prepare-upload "$archive_key") + [[ $upload_ticket == /tmp/keypath-lab.* ]] || die "host returned an invalid upload ticket" + scp -q "$temp_dir/overlay.tgz" "$host:$upload_ticket" + remote derive-archive "$upload_ticket" "$base_archive_key" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$harness_commit" + remote create "$macos" "$lane" "$archive_key" "$commit" "$installer_sha" "$installer_name" "$ttl" "$desktop" "$tart_usb_passthrough" + exit $? + fi mkdir -p "$temp_dir/repo/.keypath-lab/installer" git -C "$REPO_ROOT" archive "$commit" | tar -x -C "$temp_dir/repo" - installer_name=$(basename "$installer") - [[ $installer_name =~ ^[A-Za-z0-9._-]+$ ]] || die "installer filename must contain only letters, numbers, dots, underscores, and dashes" + # Product identity remains pinned to `commit`, while lab-only execution + # comes from an independently recorded harness commit. Including both + # in the archive key makes the payload immutable without forcing a new + # signed product build for every harness correction. + git -C "$REPO_ROOT" archive "$harness_commit" Scripts/lab | tar -x -C "$temp_dir/repo" cp "$installer" "$temp_dir/repo/.keypath-lab/installer/$installer_name" + if [[ -n $fixture ]]; then + mkdir -p "$temp_dir/repo/.keypath-lab/fixtures" + cp "$fixture" "$temp_dir/repo/.keypath-lab/fixtures/$fixture_name" + fi if [[ $lane == "managed-functional" ]]; then mkdir -p "$temp_dir/installer-extract" /usr/bin/ditto -x -k "$installer" "$temp_dir/installer-extract" @@ -126,8 +212,11 @@ case "$command" in fi cat > "$temp_dir/repo/.keypath-lab/source.tsv" </dev/null && pwd -P) + printf 'local_artifact_dir\t%s\n' "$local_output" + fi ;; scenario) [[ $# -eq 2 ]] || usage diff --git a/Scripts/lab/macos-26-selector-driver b/Scripts/lab/macos-26-selector-driver index 5a8a4bb16..531b41ddf 100755 --- a/Scripts/lab/macos-26-selector-driver +++ b/Scripts/lab/macos-26-selector-driver @@ -43,6 +43,8 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", required=True, type=pathlib.Path) parser.add_argument("--expect", action="append", default=[], help="required text in fresh System Settings AX evidence") + parser.add_argument("--expect-any", action="append", default=[], + help="pipe-separated semantic selector alternatives; at least one must appear") parser.add_argument("--scenario", default="macos-26-selector-probe") args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) @@ -66,14 +68,29 @@ def main() -> int: "--status", "failed", "--summary", "System Settings selector evidence was not valid JSON.", "--classification", "harness-transport-failure", "--step", "ax-snapshot"], check=True) return 1 + alternatives = [[selector for selector in group.split("|") if selector] for group in args.expect_any] missing = [selector for selector in args.expect if not contains(evidence, selector)] + missing += ["one of [" + ", ".join(group) + "]" for group in alternatives + if not group or not any(contains(evidence, selector) for selector in group)] if missing: subprocess.run([str(RESULT), "record", "--output", str(args.output / "result.json"), "--scenario", args.scenario, "--status", "blocked", "--summary", "macOS 26 System Settings surface did not expose the required selector evidence.", "--classification", "unsupported-os-selector", "--step", "selector-admission", "--message", "Missing selectors: " + ", ".join(missing), "--evidence", "system-settings-ax.json"], check=True) return 4 - (args.output / "selector-contract.json").write_text(json.dumps({"schemaVersion": 1, "macOSMajor": 26, "expected": args.expect, "evidence": "system-settings-ax.json"}, indent=2) + "\n") + (args.output / "selector-contract.json").write_text(json.dumps({ + "schemaVersion": 1, + "macOSMajor": 26, + "expected": args.expect, + "expectedAny": alternatives, + "evidence": "system-settings-ax.json", + }, indent=2) + "\n") + subprocess.run([ + str(RESULT), "record", "--output", str(args.output / "result.json"), + "--scenario", args.scenario, "--status", "passed", + "--summary", "macOS 26 exposed every accepted fail-closed System Settings selector.", + "--evidence", "system-settings-ax.json", "--evidence", "selector-contract.json", + ], check=True) return 0 diff --git a/Scripts/lab/macos-26-selector-scenario b/Scripts/lab/macos-26-selector-scenario new file mode 100755 index 000000000..5da88afbd --- /dev/null +++ b/Scripts/lab/macos-26-selector-scenario @@ -0,0 +1,61 @@ +#!/bin/zsh +set -euo pipefail + +die() { print -u2 "macos-26-selector-scenario: $*"; exit 1; } +usage() { print -u2 "Usage: Scripts/lab/macos-26-selector-scenario --output DIRECTORY"; exit 2; } + +output= +while [[ $# -gt 0 ]]; do + case "$1" in + --output) [[ -n "${2:-}" ]] || usage; output=$2; shift 2 ;; + *) usage ;; + esac +done +[[ -n "$output" && "$output" != -* ]] || usage + +root=${0:A:h} +driver=${KEYPATH_SELECTOR_DRIVER:-$root/macos-26-selector-driver} +peekaboo=${KEYPATH_SELECTOR_PEEKABOO:-$root/peekaboo-ui} +open_bin=${KEYPATH_SELECTOR_OPEN:-/usr/bin/open} +osascript_bin=${KEYPATH_SELECTOR_OSASCRIPT:-/usr/bin/osascript} +sleep_bin=${KEYPATH_SELECTOR_SLEEP:-/bin/sleep} +url='x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' +readiness="$output/accessibility-readiness.json" + +contains_any() { + local file=$1 needle + shift + for needle in "$@"; do + /usr/bin/grep -Fq "$needle" "$file" && return 0 + done + return 1 +} + +mkdir -p "$output" +"$osascript_bin" -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true +"$sleep_bin" 1 +opened=0 +for attempt in {1..5}; do + if "$open_bin" "$url"; then opened=1; break; fi + "$sleep_bin" 1 +done +(( opened )) || die "could not open the Accessibility privacy pane after five attempts" + +ready=0 +for attempt in {1..15}; do + if "$peekaboo" snapshot --app "System Settings" --output "$readiness" >/dev/null 2>&1 \ + && /usr/bin/grep -Fq 'Allow the applications below to control your computer.' "$readiness" \ + && contains_any "$readiness" 'com.apple.settings.accessibility' 'com.apple.Accessibility-Settings.extension' \ + && contains_any "$readiness" 'Peekaboo Lab Host' 'peekaboo_Title'; then + ready=1 + break + fi + "$sleep_bin" 1 +done +(( ready )) || print -u2 "macos-26-selector-scenario: pane readiness timed out; driver will record the fail-closed result" +exec "$driver" \ + --output "$output" \ + --scenario macos-26-selector-probe \ + --expect-any 'com.apple.settings.accessibility|com.apple.Accessibility-Settings.extension' \ + --expect 'Allow the applications below to control your computer.' \ + --expect-any 'Peekaboo Lab Host|peekaboo_Title' diff --git a/Scripts/lab/macos-27-selector-driver b/Scripts/lab/macos-27-selector-driver new file mode 100755 index 000000000..b3c113b0b --- /dev/null +++ b/Scripts/lab/macos-27-selector-driver @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Capture and validate macOS 27 System Settings selector evidence.""" + +import argparse +import json +import os +import pathlib +import subprocess + + +ROOT = pathlib.Path(__file__).resolve().parent +PEEKABOO = pathlib.Path(os.environ.get("KEYPATH_SELECTOR_PEEKABOO", ROOT / "peekaboo-ui")) +RESULT = pathlib.Path(os.environ.get("KEYPATH_SELECTOR_RESULT", ROOT / "scenario-result")) + + +def run(command, output=None): + if output is None: + return subprocess.run(command, check=False, capture_output=True, text=True) + with output.open("w") as handle: + return subprocess.run(command, check=False, stdout=handle, stderr=subprocess.STDOUT, text=True) + + +def macos_version() -> tuple[str, str]: + version = run(["sw_vers", "-productVersion"]) + build = run(["sw_vers", "-buildVersion"]) + if version.returncode != 0 or build.returncode != 0: + raise RuntimeError("could not read macOS version") + return version.stdout.strip(), build.stdout.strip() + + +def contains(value, expected: str) -> bool: + needle = expected.casefold() + if isinstance(value, str): + return needle in value.casefold() + if isinstance(value, dict): + return any(contains(item, expected) for item in value.values()) + if isinstance(value, list): + return any(contains(item, expected) for item in value) + return False + + +def record(output: pathlib.Path, scenario: str, status: str, summary: str, + classification: str, step: str, *extra: str) -> None: + subprocess.run([ + str(RESULT), "record", "--output", str(output / "result.json"), + "--scenario", scenario, "--status", status, "--summary", summary, + "--classification", classification, "--step", step, *extra, + ], check=True) + + +def required_permissions(preflight: object) -> list[str]: + if not isinstance(preflight, dict): + return ["unknown"] + data = preflight.get("data", {}) + permissions = data.get("permissions", []) if isinstance(data, dict) else [] + if not isinstance(permissions, list): + return ["unknown"] + return [ + str(item.get("name", "unknown")) + for item in permissions + if isinstance(item, dict) and item.get("isRequired") is True and item.get("isGranted") is not True + ] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=pathlib.Path) + parser.add_argument("--expect", action="append", required=True, + help="required text in fresh System Settings AX evidence") + parser.add_argument("--scenario", default="macos-27-selector-probe") + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + version, build = macos_version() + if version.split(".")[0] != "27": + record(args.output, args.scenario, "blocked", + "macOS 27 selector driver requires a macOS 27 guest.", + "unsupported-os-selector", "os-admission") + return 4 + run(["sw_vers"], args.output / "sw-vers.txt") + preflight_path = args.output / "peekaboo-preflight.json" + preflight_result = run([str(PEEKABOO), "preflight"], preflight_path) + if preflight_result.returncode != 0: + record(args.output, args.scenario, "failed", + "Peekaboo preflight could not verify the macOS 27 desktop session.", + "environment-precondition-failure", "peekaboo-preflight", + "--evidence", preflight_path.name) + return preflight_result.returncode + try: + preflight = json.loads(preflight_path.read_text()) + except json.JSONDecodeError: + record(args.output, args.scenario, "failed", + "Peekaboo preflight evidence was not valid JSON.", + "harness-transport-failure", "peekaboo-preflight", + "--evidence", preflight_path.name) + return 1 + missing_permissions = required_permissions(preflight) + if missing_permissions: + record(args.output, args.scenario, "blocked", + "Peekaboo lacks required macOS 27 desktop permissions.", + "environment-precondition-failure", "peekaboo-permissions", + "--message", "Missing permissions: " + ", ".join(missing_permissions), + "--evidence", preflight_path.name) + return 4 + + snapshot = args.output / "system-settings-ax.json" + capture = run([str(PEEKABOO), "snapshot", "--app", "System Settings", "--output", str(snapshot)]) + if capture.returncode != 0: + record(args.output, args.scenario, "failed", + "Could not capture current System Settings selector evidence.", + "harness-transport-failure", "ax-snapshot") + return capture.returncode + try: + evidence = json.loads(snapshot.read_text()) + except json.JSONDecodeError: + record(args.output, args.scenario, "failed", + "System Settings selector evidence was not valid JSON.", + "harness-transport-failure", "ax-snapshot") + return 1 + missing = [selector for selector in args.expect if not contains(evidence, selector)] + if missing: + record(args.output, args.scenario, "blocked", + "macOS 27 System Settings surface did not expose the required selector evidence.", + "unsupported-os-selector", "selector-admission", + "--message", "Missing selectors: " + ", ".join(missing), + "--evidence", snapshot.name) + return 4 + (args.output / "selector-contract.json").write_text(json.dumps({ + "schemaVersion": 1, + "macOSMajor": 27, + "macOSVersion": version, + "macOSBuild": build, + "expected": args.expect, + "evidence": snapshot.name, + "preflightEvidence": preflight_path.name, + }, indent=2) + "\n") + subprocess.run([ + str(RESULT), "record", "--output", str(args.output / "result.json"), + "--scenario", args.scenario, "--status", "passed", + "--summary", "macOS 27 exposed every accepted fail-closed System Settings selector.", + "--evidence", snapshot.name, "--evidence", "selector-contract.json", + "--evidence", preflight_path.name, + ], check=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/macos-27-selector-scenario b/Scripts/lab/macos-27-selector-scenario new file mode 100755 index 000000000..70add6a55 --- /dev/null +++ b/Scripts/lab/macos-27-selector-scenario @@ -0,0 +1,61 @@ +#!/bin/zsh +set -euo pipefail + +die() { print -u2 "macos-27-selector-scenario: $*"; exit 1; } + +usage() { + print -u2 "Usage: Scripts/lab/macos-27-selector-scenario --output DIRECTORY" + exit 2 +} + +output= +while [[ $# -gt 0 ]]; do + case "$1" in + --output) [[ -n "${2:-}" ]] || usage; output=$2; shift 2 ;; + *) usage ;; + esac +done +[[ -n "$output" && "$output" != -* ]] || usage + +root=${0:A:h} +driver=${KEYPATH_SELECTOR_DRIVER:-$root/macos-27-selector-driver} +peekaboo=${KEYPATH_SELECTOR_PEEKABOO:-$root/peekaboo-ui} +open_bin=${KEYPATH_SELECTOR_OPEN:-/usr/bin/open} +osascript_bin=${KEYPATH_SELECTOR_OSASCRIPT:-/usr/bin/osascript} +sleep_bin=${KEYPATH_SELECTOR_SLEEP:-/bin/sleep} +url='x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' +readiness="$output/accessibility-readiness.json" + +mkdir -p "$output" +"$osascript_bin" -e 'tell application "System Settings" to quit' >/dev/null 2>&1 || true +"$sleep_bin" 1 + +opened=0 +for attempt in {1..5}; do + if "$open_bin" "$url"; then + opened=1 + break + fi + "$sleep_bin" 1 +done +(( opened )) || die "could not open the Accessibility privacy pane after five attempts" + +ready=0 +for attempt in {1..15}; do + if "$peekaboo" snapshot --app "System Settings" --output "$readiness" >/dev/null 2>&1 \ + && /usr/bin/grep -Fq 'com.apple.settings.accessibility' "$readiness" \ + && /usr/bin/grep -Fq 'Allow the applications below to control your computer.' "$readiness" \ + && /usr/bin/grep -Fq 'Peekaboo Lab Host' "$readiness"; then + ready=1 + break + fi + "$sleep_bin" 1 +done + +(( ready )) || print -u2 "macos-27-selector-scenario: pane readiness timed out; driver will record the fail-closed result" +exec "$driver" \ + --output "$output" \ + --scenario macos-27-selector-probe \ + --expect com.apple.settings.accessibility \ + --expect 'Allow the applications below to control your computer.' \ + --expect 'Peekaboo Lab Host' diff --git a/Scripts/lab/mdm/probe-managed-capabilities b/Scripts/lab/mdm/probe-managed-capabilities index b1c391b65..f8930d5f7 100755 --- a/Scripts/lab/mdm/probe-managed-capabilities +++ b/Scripts/lab/mdm/probe-managed-capabilities @@ -2,13 +2,19 @@ set -euo pipefail usage() { - echo "Usage: $0 --output DIR" >&2 + echo "Usage: $0 --output DIR [--managed-policy-manifest PATH]" >&2 exit 2 } output= -[[ ${1:-} == --output && -n ${2:-} && $# -eq 2 ]] || usage +managed_manifest= +[[ ${1:-} == --output && -n ${2:-} && ($# -eq 2 || $# -eq 4) ]] || usage output=$2 +if [[ $# -eq 4 ]]; then + [[ $3 == --managed-policy-manifest && -n $4 ]] || usage + managed_manifest=$4 + [[ -f $managed_manifest ]] || { echo "managed capability probe: managed-policy manifest missing" >&2; exit 3; } +fi mkdir -p "$output" cli=${KEYPATH_LAB_CLI:-/Applications/KeyPath.app/Contents/MacOS/keypath-cli} @@ -17,13 +23,14 @@ tcp_probe=${KEYPATH_LAB_TCP_PROBE:-$(cd "$(dirname "$0")/.." >/dev/null && pwd - systemextensionsctl=${KEYPATH_LAB_SYSTEMEXTENSIONSCTL:-/usr/bin/systemextensionsctl} sfltool=${KEYPATH_LAB_SFLTOOL:-/usr/bin/sfltool} launchctl=${KEYPATH_LAB_LAUNCHCTL:-/bin/launchctl} +verify_lane=${KEYPATH_LAB_VERIFY_LANE:-$(dirname "$0")/verify-lane} [[ -x $cli ]] || { echo "managed capability probe: installed CLI missing" >&2; exit 3; } set +e "$cli" service status --json > "$output/service-status.json" 2> "$output/service-status.stderr" status_exit=$? set -e -"$python" - "$output/service-status.json" <<'PY' +"$python" - "$output/service-status.json" "$([[ -n $managed_manifest ]] && echo true || echo false)" <<'PY' import json import sys @@ -37,7 +44,6 @@ required = [ "helperWorking", "keyPathAccessibility", "keyPathInputMonitoring", - "kanataAccessibility", "kanataInputMonitoring", "kanataBinaryInstalled", "karabinerDriverInstalled", @@ -46,6 +52,8 @@ required = [ "karabinerDaemonRunning", "vhidHealthy", ] +if sys.argv[2] != "true": + required.append("kanataAccessibility") failed = [key for key in required if status.get(key) is not True] if failed: raise SystemExit("managed capabilities missing: " + ", ".join(failed)) @@ -54,6 +62,11 @@ if not status.get("helperVersion"): PY [[ $status_exit -eq 0 ]] || { echo "managed capability probe: CLI status exited $status_exit" >&2; exit "$status_exit"; } +if [[ -n $managed_manifest ]]; then + "$verify_lane" managed-functional --manifest "$managed_manifest" \ + > "$output/managed-policy-verification.tsv" +fi + "$tcp_probe" > "$output/tcp-readiness.json" 2> "$output/tcp-readiness.stderr" "$systemextensionsctl" list > "$output/system-extensions.txt" 2>&1 grep -Fq 'org.pqrs.Karabiner-DriverKit-VirtualHIDDevice' "$output/system-extensions.txt" || { diff --git a/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh b/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh index 6af70cdae..1362b529c 100755 --- a/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh +++ b/Scripts/lab/mdm/tests/managed-capability-probe-tests.sh @@ -26,7 +26,7 @@ run_probe() { KEYPATH_LAB_SYSTEMEXTENSIONSCTL="$TMP/systemextensionsctl" \ KEYPATH_LAB_SFLTOOL="$TMP/sfltool" \ KEYPATH_LAB_LAUNCHCTL="$TMP/launchctl" \ - "$MDM_DIR/probe-managed-capabilities" --output "$1" + "$MDM_DIR/probe-managed-capabilities" --output "$@" } run_probe "$TMP/pass" >/dev/null @@ -45,6 +45,12 @@ if run_probe "$TMP/missing-permission" >"$TMP/missing.stdout" 2>"$TMP/missing.st fi grep -Fq 'managed capabilities missing: kanataAccessibility' "$TMP/missing.stderr" +write_tool verify-lane '[[ $1 == managed-functional && $2 == --manifest && -f $3 ]] || exit 1; echo "managed_policy_verification passed"' +printf '{"schemaVersion":1}\n' > "$TMP/managed-manifest.json" +KEYPATH_LAB_VERIFY_LANE="$TMP/verify-lane" \ + run_probe "$TMP/managed-attestation" --managed-policy-manifest "$TMP/managed-manifest.json" >/dev/null +grep -Fq 'managed_policy_verification passed' "$TMP/managed-attestation/managed-policy-verification.tsv" + write_tool keypath-cli 'cat < str: + if token := os.environ.get("KEYPATH_FIXTURE_TOKEN"): + return token + secrets = pathlib.Path(os.environ.get( + "KEYPATH_FIXTURE_SECRETS_FILE", pathlib.Path.home() / "dotfiles/secrets.env" + )) + try: + decrypted = subprocess.run( + ["sops", "-d", str(secrets)], text=True, capture_output=True, check=True + ).stdout + except (FileNotFoundError, subprocess.CalledProcessError) as error: + raise RuntimeError("could not load KEYPATH_FIXTURE_TOKEN from encrypted secrets") from error + for line in decrypted.splitlines(): + name, separator, value = line.partition("=") + if separator and name == "KEYPATH_FIXTURE_TOKEN" and value: + return value + raise RuntimeError("KEYPATH_FIXTURE_TOKEN is not configured") + + +def run_json( + command: list[str], environment: dict[str, str], allowed_returncodes: tuple[int, ...] = (0,) +) -> dict[str, Any]: + result = subprocess.run(command, text=True, capture_output=True, env=environment) + if result.returncode not in allowed_returncodes: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + concise = detail.splitlines()[-1] if detail else f"exit {result.returncode}" + raise RuntimeError(f"{' '.join(command[:3])} failed: {concise[:600]}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"{' '.join(command[:3])} returned invalid JSON") from error + + +def run_checked(command: list[str], environment: dict[str, str]) -> None: + result = subprocess.run(command, text=True, capture_output=True, env=environment) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + raise RuntimeError(f"{' '.join(command[:3])} failed: {detail}") + + +def fixture_command( + host: str, action: str, *arguments: str, timeout: float | None = None +) -> list[str]: + command = [str(FIXTURE_CLIENT), "--host", host] + if timeout is not None: + command.extend(["--timeout", f"{timeout:g}"]) + return [*command, action, *arguments] + + +def capture_command(action: str, *arguments: str) -> list[str]: + return [str(CAPTURE_CLIENT), action, *arguments] + + +def record_control_error( + errors: list[dict[str, Any]], action: str, error: BaseException +) -> None: + errors.append({ + "timestampEpoch": time.time(), + "action": action, + "error": str(error)[:800], + }) + + +def run_fixture_json_with_retry( + host: str, + action: str, + *arguments: str, + environment: dict[str, str], + retry_seconds: float, + errors: list[dict[str, Any]], + request_timeout: float = 1.5, +) -> dict[str, Any]: + deadline = time.monotonic() + max(0, retry_seconds) + while True: + try: + return run_json( + fixture_command(host, action, *arguments, timeout=request_timeout), + environment, + ) + except (OSError, ValueError, RuntimeError, KeyError) as error: + record_control_error(errors, action, error) + if time.monotonic() >= deadline: + raise RuntimeError( + f"fixture {action} unavailable after {retry_seconds:g}s: {error}" + ) from error + time.sleep(0.25) + + +def run_fixture_state_change_with_confirmation( + host: str, + action: str, + run_id: str, + *arguments: str, + include_run_id_argument: bool = True, + expected_states: set[str], + environment: dict[str, str], + retry_seconds: float, + errors: list[dict[str, Any]], + action_request_timeout: float = 10, + status_request_timeout: float = 10, + require_run_id_match: bool = True, +) -> dict[str, Any]: + """Retry an idempotent intent, confirming a lost response from fixture status.""" + deadline = time.monotonic() + max(0, retry_seconds) + latest_error: BaseException | None = None + while True: + try: + command_arguments = ( + (run_id, *arguments) if include_run_id_argument else arguments + ) + return run_json( + fixture_command( + host, action, *command_arguments, timeout=action_request_timeout + ), + environment, + ) + except (OSError, ValueError, RuntimeError, KeyError) as error: + latest_error = error + record_control_error(errors, action, error) + try: + status = run_json( + fixture_command(host, "status", timeout=status_request_timeout), environment + ) + observed_run_id = status.get("runId", status.get("runID")) + run_id_matches = observed_run_id == run_id or not require_run_id_match + if run_id_matches and status.get("state") in expected_states: + return { + "ok": True, + "message": f"{action} confirmed from fixture status after response loss", + "confirmedByStatus": True, + "status": status, + } + except (OSError, ValueError, RuntimeError, KeyError) as error: + latest_error = error + record_control_error(errors, "status", error) + if time.monotonic() >= deadline: + raise RuntimeError( + f"fixture {action} unavailable after {retry_seconds:g}s: {latest_error}" + ) from latest_error + time.sleep(0.25) + + +def resolve_fixture_host( + initial_host: str, + environment: dict[str, str], + errors: list[dict[str, Any]] | None = None, +) -> tuple[str, dict[str, Any]]: + control_errors = errors if errors is not None else [] + status = run_fixture_json_with_retry( + initial_host, "status", environment=environment, + # The firmware intentionally owns a bounded 60-second Wi-Fi reconnect + # window. Wait through it before declaring a hands-off campaign blocked. + retry_seconds=60, errors=control_errors, + ) + address = str(status.get("address", "")) + try: + ipaddress.ip_address(address) + except ValueError: + return initial_host, status + return address, status + + +def safe_run_id(value: str) -> str: + return "".join(character if character.isalnum() or character in "-_." else "_" for character in value) + + +def wait_for_fixture_terminal( + host: str, environment: dict[str, str], timeout_seconds: float, + errors: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + control_errors = errors if errors is not None else [] + deadline = time.monotonic() + timeout_seconds + latest: dict[str, Any] | None = None + while True: + remaining = max(0, deadline - time.monotonic()) + try: + latest = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=min(2, remaining), errors=control_errors, + request_timeout=min(1, max(0.2, remaining)), + ) + except RuntimeError: + if time.monotonic() >= deadline: + break + continue + if latest.get("state") not in {"armed", "running"}: + return latest + if time.monotonic() >= deadline: + break + time.sleep(0.05) + if latest is not None: + return latest + raise RuntimeError("fixture status remained unavailable through the terminal wait") + + +def wait_for_capture_settle( + environment: dict[str, str], settle_ms: int, timeout_seconds: float +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + while True: + response = run_json(capture_command("status"), environment) + snapshot = response.get("snapshot", {}) + settled_for_ms = snapshot.get("settledForMs") + if settled_for_ms is None or ( + settled_for_ms >= settle_ms and + not snapshot.get("pressedKeyCodes") and + snapshot.get("activeModifiers", 0) == 0 + ): + return response + if time.monotonic() >= deadline: + return response + time.sleep(0.05) + + +def fetch_fixture_trace( + host: str, environment: dict[str, str], errors: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + """Fetch the fixture's bounded trace in chronological order after a run.""" + control_errors = errors if errors is not None else [] + try: + response = run_json( + fixture_command( + host, "trace-all", "--limit", "8", "--retry-seconds", "60", timeout=10 + ), + environment, + ) + except (OSError, ValueError, RuntimeError, KeyError) as error: + record_control_error(control_errors, "trace-all", error) + raise + if not isinstance(response, list) or any(not isinstance(entry, dict) for entry in response): + raise RuntimeError("fixture trace-all returned invalid JSON") + return response + + +def wait_for_capture_release( + environment: dict[str, str], timeout_seconds: float = 3 +) -> tuple[dict[str, Any] | None, bool]: + deadline = time.monotonic() + timeout_seconds + latest: dict[str, Any] | None = None + while time.monotonic() < deadline: + try: + latest = run_json(capture_command("status"), environment) + except (OSError, ValueError, RuntimeError, KeyError): + time.sleep(0.05) + continue + snapshot = latest.get("snapshot", {}) + if not snapshot.get("pressedKeyCodes") and snapshot.get("activeModifiers", 0) == 0: + return latest, True + time.sleep(0.05) + return latest, False + + +def write_artifact(path: pathlib.Path, artifact: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(artifact, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +class ControlledCPULoad: + """Bounded synthetic CPU pressure with samples suitable for test artifacts.""" + + def __init__(self, workers: int, sample_ms: int): + self.workers = workers + self.sample_seconds = sample_ms / 1000 + self.logical_cpus = max(1, os.cpu_count() or 1) + self.processes: list[subprocess.Popen] = [] + self.samples: list[dict[str, float]] = [] + self.stop_event = threading.Event() + self.sampler: threading.Thread | None = None + self.started_at = 0.0 + + def start(self) -> None: + self.started_at = time.time() + self.processes = [ + subprocess.Popen( + ["/usr/bin/yes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + for _ in range(self.workers) + ] + self.sampler = threading.Thread(target=self._sample_loop, daemon=True) + self.sampler.start() + + def stop(self) -> dict[str, Any]: + self.stop_event.set() + for process in self.processes: + process.terminate() + for process in self.processes: + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1) + if self.sampler is not None: + self.sampler.join(timeout=2) + cpu_values = [sample["cpuPercent"] for sample in self.samples] + return { + "workers": self.workers, + "logicalCPUs": self.logical_cpus, + "sampleIntervalMs": round(self.sample_seconds * 1000), + "startedAtEpoch": self.started_at, + "averageCPUPercent": round(sum(cpu_values) / len(cpu_values), 1) if cpu_values else None, + "maximumCPUPercent": round(max(cpu_values), 1) if cpu_values else None, + "samples": self.samples, + } + + def _sample_loop(self) -> None: + while not self.stop_event.is_set(): + try: + result = subprocess.run( + ["/bin/ps", "-A", "-o", "%cpu="], + text=True, capture_output=True, check=True, timeout=2, + ) + process_cpu = sum(float(value) for value in result.stdout.split()) + cpu_percent = min(100.0, process_cpu / self.logical_cpus) + load_1m, load_5m, load_15m = os.getloadavg() + self.samples.append({ + "elapsedMs": round((time.time() - self.started_at) * 1000), + "cpuPercent": round(cpu_percent, 1), + "load1m": round(load_1m, 2), + "load5m": round(load_5m, 2), + "load15m": round(load_15m, 2), + }) + except (OSError, ValueError, subprocess.SubprocessError): + pass + self.stop_event.wait(self.sample_seconds) + + +class ControlledSwiftCompileLoad: + """Bounded, generated Swift type-check pressure that never mutates the worktree.""" + + def __init__(self): + self.temporary: tempfile.TemporaryDirectory[str] | None = None + self.process: subprocess.Popen | None = None + self.started_at = 0.0 + self.source_bytes = 0 + self.compiler = "" + self.sdk = "" + + def start(self) -> None: + try: + result = subprocess.run( + ["/usr/bin/xcrun", "--find", "swiftc"], text=True, + capture_output=True, check=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError) as error: + raise RuntimeError("could not locate swiftc for the bounded compile load") from error + self.compiler = result.stdout.strip() + if not self.compiler: + raise RuntimeError("xcrun returned an empty swiftc path") + try: + result = subprocess.run( + ["/usr/bin/xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True, + capture_output=True, check=True, timeout=10, + ) + except (OSError, subprocess.SubprocessError) as error: + raise RuntimeError("could not locate the macOS SDK for the bounded compile load") from error + self.sdk = result.stdout.strip() + if not self.sdk: + raise RuntimeError("xcrun returned an empty macOS SDK path") + self.temporary = tempfile.TemporaryDirectory(prefix="keypath-swift-load-") + source = pathlib.Path(self.temporary.name) / "GeneratedLoad.swift" + lines = ["import Foundation", ""] + for index in range(700): + lines.extend([ + f"@inline(never) func workload{index}(_ values: S) -> Int ", + "where S.Element == Int {", + f" values.enumerated().reduce({index}) {{ partial, pair in", + f" partial &+ ((pair.offset &+ pair.element &+ {index}) % 97)", + " }", + "}", + "", + ]) + source.write_text("\n".join(lines)) + self.source_bytes = source.stat().st_size + self.started_at = time.time() + self.process = subprocess.Popen( + [ + "/bin/bash", "-c", + 'while :; do "$1" -sdk "$2" -typecheck "$3" >/dev/null 2>&1 || exit $?; done', + "keypath-swift-compile-load", self.compiler, self.sdk, str(source), + ], + start_new_session=True, + ) + time.sleep(0.2) + early_return_code = self.process.poll() + if early_return_code is not None: + self.process = None + self.temporary.cleanup() + self.temporary = None + raise RuntimeError( + f"bounded Swift compile load exited during startup ({early_return_code})" + ) + + def stop(self) -> dict[str, Any]: + return_code: int | None = None + process = self.process + self.process = None + try: + if process is not None: + return_code = process.poll() + if return_code is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + # The short-lived group leader may exit between poll() and + # killpg(). Never signal a recycled process-group ID. + if process.poll() is None: + try: + process.terminate() + except (ProcessLookupError, PermissionError): + pass + try: + return_code = process.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + if process.poll() is None: + try: + process.kill() + except (ProcessLookupError, PermissionError): + pass + return_code = process.wait(timeout=2) + finally: + if self.temporary is not None: + self.temporary.cleanup() + self.temporary = None + return { + "enabled": True, + "compiler": self.compiler, + "sdk": self.sdk, + "sourceBytes": self.source_bytes, + "startedAtEpoch": self.started_at, + "durationMs": round(max(0, time.time() - self.started_at) * 1000), + "returnCode": return_code, + } + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--run-id", required=True) + root.add_argument("--text", required=True, type=pathlib.Path) + root.add_argument("--fixture-host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument( + "--brand", choices=("keypath", "bear"), default="keypath", + help="select the centered app logo and presentation palette on the ESP32", + ) + root.add_argument("--interval-ms", type=int, default=50) + root.add_argument("--hold-ms", type=int, default=12) + root.add_argument("--shift-lead-ms", type=int, default=0) + root.add_argument("--shift-release-lag-ms", type=int, default=0) + root.add_argument("--repeat", type=int, default=1) + root.add_argument("--cycle-gap-ms", type=int, default=500) + root.add_argument("--delay-ms", type=int, default=1200) + root.add_argument("--settle-ms", type=int, default=300) + root.add_argument("--timeout-ms", type=int) + root.add_argument( + "--demo-mode", action="store_true", + help="bypass host-resource admission; results are demonstration evidence only", + ) + root.add_argument("--max-late-reports", type=int, default=0) + root.add_argument("--max-lateness-us", type=int, default=0) + root.add_argument( + "--cpu-load-workers", type=int, + help="start this many bounded CPU workers after capture preflight and record CPU samples", + ) + root.add_argument("--cpu-sample-ms", type=int, default=250) + root.add_argument( + "--swift-compile-load", action="store_true", + help="run a bounded generated Swift type-check loop after strict admission", + ) + interruption = root.add_mutually_exclusive_group() + interruption.add_argument( + "--abort-after-ms", type=int, + help="abort after start returns and verify a safe, released prefix instead of full output", + ) + interruption.add_argument( + "--expect-external-abort-ms", type=int, + help="wait for a physical button or touch abort for this many milliseconds", + ) + interruption.add_argument( + "--expect-usb-cycle-ms", type=int, + help="wait for physical USB removal and reconnection for this many milliseconds", + ) + root.add_argument("--output", type=pathlib.Path) + return root + + +def main() -> int: + arguments = parser().parse_args() + output = arguments.output or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/combined" / + f"{safe_run_id(arguments.run_id)}.json" + ) + if not arguments.text.is_file(): + print(f"physical-hid-capture-run: text file does not exist: {arguments.text}", file=sys.stderr) + return 2 + environment = os.environ.copy() + fixture_host = "" + fixture_mutated = False + capture_armed = False + cpu_load: ControlledCPULoad | None = None + cpu_load_result: dict[str, Any] | None = None + swift_compile_load: ControlledSwiftCompileLoad | None = None + swift_compile_load_result: dict[str, Any] | None = None + stage = "argument-validation" + started_at_epoch = time.time() + timeout_ms = 0 + control_errors: list[dict[str, Any]] = [] + expected = "" + expected_reports = 0 + interruption_mode = "unknown" + initial_status: dict[str, Any] | None = None + stale_abort_response: dict[str, Any] | None = None + capture_preflight: dict[str, Any] | None = None + load: dict[str, Any] | None = None + fixture_arm: dict[str, Any] | None = None + presentation_testing: dict[str, Any] | None = None + capture_focus: dict[str, Any] | None = None + capture_arm: dict[str, Any] | None = None + start: dict[str, Any] | None = None + abort_response: dict[str, Any] | None = None + capture_initial_terminal: dict[str, Any] | None = None + capture_response: dict[str, Any] | None = None + fixture_status: dict[str, Any] = {} + fixture_trace: list[dict[str, Any]] = [] + try: + if arguments.abort_after_ms is not None and arguments.abort_after_ms < 1: + raise RuntimeError("--abort-after-ms must be positive") + if arguments.expect_external_abort_ms is not None and arguments.expect_external_abort_ms < 1: + raise RuntimeError("--expect-external-abort-ms must be positive") + if arguments.expect_usb_cycle_ms is not None and arguments.expect_usb_cycle_ms < 1: + raise RuntimeError("--expect-usb-cycle-ms must be positive") + if arguments.max_late_reports < 0 or arguments.max_lateness_us < 0: + raise RuntimeError("lateness budgets cannot be negative") + if arguments.max_late_reports > 0 and arguments.max_lateness_us == 0: + raise RuntimeError("--max-lateness-us is required when late reports are allowed") + logical_cpus = max(1, os.cpu_count() or 1) + if arguments.cpu_load_workers is not None and not 0 <= arguments.cpu_load_workers <= logical_cpus: + raise RuntimeError(f"--cpu-load-workers must be between 0 and {logical_cpus}") + if not 100 <= arguments.cpu_sample_ms <= 5_000: + raise RuntimeError("--cpu-sample-ms must be between 100 and 5000") + stage = "fixture-discovery" + environment["KEYPATH_FIXTURE_TOKEN"] = load_fixture_token() + host, initial_status = resolve_fixture_host( + arguments.fixture_host, environment, control_errors + ) + fixture_host = host + if initial_status.get("state") in {"loaded", "armed", "running"}: + stage = "fixture-stale-run-abort" + stale_abort_response = run_fixture_state_change_with_confirmation( + host, "abort", arguments.run_id, + include_run_id_argument=False, expected_states={"aborted"}, + environment=environment, retry_seconds=60, errors=control_errors, + require_run_id_match=False, + ) + stage = "capture-preflight" + capture_preflight = run_json(capture_command("status"), environment) + readiness = capture_preflight.get("systemReadiness") + if not isinstance(readiness, dict): + raise RuntimeError( + "capture Jig does not report system readiness; rebuild and reopen it before testing" + ) + stage = "host-resource-admission" + if not arguments.demo_mode and not readiness.get("canProceed"): + detail = str(readiness.get("detail", "the Mac is not inside the capture resource envelope")) + suggestions = readiness.get("suggestions", []) + help_text = " ".join(str(value) for value in suggestions if value) + raise RuntimeError(f"capture Jig paused the run: {detail}. {help_text}".strip()) + text = arguments.text.read_text() + expected = text * arguments.repeat + load_requested = ( + arguments.cpu_load_workers is not None or arguments.swift_compile_load + ) + effective_delay_ms = max(arguments.delay_ms, 10_000) if load_requested else arguments.delay_ms + estimated_ms = effective_delay_ms + arguments.repeat * ( + len(text) * arguments.interval_ms + arguments.cycle_gap_ms + ) + # WindowServer can queue valid HID events while the capture app is starved by a + # concurrent compile. Preserve enough wall-clock margin to drain that queue before + # the fail-closed capture timeout freezes the evidence snapshot. + timeout_margin_ms = max(10_000, estimated_ms // 2) + # Capture is armed before the authenticated Start request. The ESP32 can + # accept Start while its Wi-Fi reply is delayed, so the oracle window must + # cover the same bounded 60-second control-plane recovery period or valid + # HID can arrive after a false 15-second timeout. + timeout_ms = arguments.timeout_ms or max(90_000, estimated_ms + timeout_margin_ms) + interruption_mode = ( + "remote-abort" if arguments.abort_after_ms is not None else + "external-abort" if arguments.expect_external_abort_ms is not None else + "usb-cycle" if arguments.expect_usb_cycle_ms is not None else + "completion" + ) + operator_instruction = { + "external-abort": "PRESS BOOT OR TAP THE SCREEN NOW TO ABORT THE ACTIVE TEST", + "usb-cycle": "UNPLUG USB-C NOW · WAIT 2 SECONDS · RECONNECT THE SAME CABLE", + }.get(interruption_mode) + fixture_detail, fixture_next = { + "external-abort": ("PRESS BOOT OR TOUCH NOW", "VERIFY SAFE ABORT"), + "usb-cycle": ("UNPLUG USB-C NOW", "WAIT 2S + RECONNECT"), + }.get(interruption_mode, ("CAPTURE JIG ARMED", "VERIFY OUTPUT")) + + with tempfile.TemporaryDirectory(prefix="keypath-hid-capture-") as temporary: + stage = "script-compilation" + script = pathlib.Path(temporary) / "fixture-script.txt" + expected_path = pathlib.Path(temporary) / "expected.txt" + expected_path.write_text(expected) + run_checked([ + str(FIXTURE_CLIENT), "compile-text", "--run-id", arguments.run_id, + "--text", str(arguments.text), "--interval-ms", str(arguments.interval_ms), + "--hold-ms", str(arguments.hold_ms), "--repeat", str(arguments.repeat), + "--cycle-gap-ms", str(arguments.cycle_gap_ms), + "--shift-lead-ms", str(arguments.shift_lead_ms), + "--shift-release-lag-ms", str(arguments.shift_release_lag_ms), + "--output", str(script), + ], environment) + header = script.read_text().splitlines()[0].split() + expected_reports = int(header[2]) * int(header[3]) + + stage = "fixture-load" + load = run_fixture_state_change_with_confirmation( + host, "load-script", arguments.run_id, str(script), + include_run_id_argument=False, + expected_states={"loaded", "armed", "running", "complete"}, + environment=environment, retry_seconds=60, errors=control_errors, + ) + fixture_mutated = True + stage = "fixture-arm" + fixture_arm = run_fixture_state_change_with_confirmation( + host, "arm", arguments.run_id, expected_states={"armed", "running", "complete"}, + environment=environment, retry_seconds=60, errors=control_errors, + ) + stage = "fixture-presentation" + try: + presentation_testing = run_fixture_json_with_retry( + host, "present", "--phase", "testing", "--result", "none", + "--brand", arguments.brand, + "--progress", "0", "--title", arguments.run_id[:32], + "--detail", fixture_detail, "--next", fixture_next, + "--reports-expected", str(expected_reports), "--reports-observed", "0", + environment=environment, retry_seconds=20, errors=control_errors, + request_timeout=10, + ) + except RuntimeError as presentation_error: + # Presentation is operator feedback, not part of HID correctness. + # Preserve the degraded-control-plane evidence and continue only + # because arm, focus, capture and release all have independent gates. + presentation_testing = {"ok": False, "error": str(presentation_error)} + # Acquire focus at the last possible moment. Keeping the Jig focused while + # readiness checks or compilation run would steal the user's own typing. + stage = "capture-focus" + capture_focus = run_json(capture_command("focus"), environment) + stage = "capture-arm" + capture_arm = run_json(capture_command( + "arm", "--run-id", arguments.run_id, "--expected", str(expected_path), + "--timeout-ms", str(timeout_ms), "--settle-ms", str(arguments.settle_ms), + *(["--demo-mode"] if arguments.demo_mode else []), + *(["--instruction", operator_instruction] if operator_instruction else []), + ), environment) + capture_armed = True + stage = "fixture-start" + start = run_fixture_state_change_with_confirmation( + host, "start", arguments.run_id, "--delay-ms", str(effective_delay_ms), + expected_states={"running", "complete"}, + environment=environment, retry_seconds=60, errors=control_errors, + ) + # Schedule and confirm Start before applying host pressure. This keeps + # Swift/CPU load from starving the Wi-Fi request that begins the run, + # while the 10-second lead ensures the load is active before USB HID. + stage = "load-start" + if arguments.cpu_load_workers is not None: + cpu_load = ControlledCPULoad( + workers=arguments.cpu_load_workers, sample_ms=arguments.cpu_sample_ms + ) + cpu_load.start() + if arguments.swift_compile_load: + swift_compile_load = ControlledSwiftCompileLoad() + swift_compile_load.start() + stage = "active-capture" + saw_usb_unmounted = False + if interruption_mode == "completion": + capture_initial_terminal = run_json(capture_command( + "wait", "--timeout-seconds", f"{timeout_ms / 1000 + 2:g}", "--poll-ms", "100" + ), environment, (0, 1)) + fixture_status = wait_for_fixture_terminal( + host, environment, timeout_seconds=timeout_ms / 1000 + 2, + errors=control_errors, + ) + capture_response = wait_for_capture_settle( + environment, settle_ms=arguments.settle_ms, + timeout_seconds=timeout_margin_ms / 1000, + ) + elif interruption_mode == "remote-abort": + capture_initial_terminal = None + time.sleep(arguments.abort_after_ms / 1000) + abort_response = run_fixture_json_with_retry( + host, "abort", environment=environment, + retry_seconds=5, errors=control_errors, + ) + release_deadline = time.monotonic() + 2 + while True: + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=2, errors=control_errors, + ) + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + if fixture_status.get("state") == "aborted" and completed >= submitted + 1: + break + if time.monotonic() >= release_deadline: + break + time.sleep(0.025) + capture_response = run_json(capture_command("finalize"), environment, (0, 1)) + elif interruption_mode == "external-abort": + capture_initial_terminal = None + external_deadline = time.monotonic() + arguments.expect_external_abort_ms / 1000 + while True: + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=min(2, max(0, external_deadline - time.monotonic())), + errors=control_errors, + ) + if fixture_status.get("state") == "aborted": + break + if time.monotonic() >= external_deadline: + break + time.sleep(0.05) + abort_response = fixture_status + release_deadline = time.monotonic() + 2 + while fixture_status.get("state") == "aborted": + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + if completed >= submitted + 1: + break + if time.monotonic() >= release_deadline: + break + time.sleep(0.025) + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=2, errors=control_errors, + ) + capture_response = run_json(capture_command("finalize"), environment, (0, 1)) + else: + capture_initial_terminal = None + usb_deadline = time.monotonic() + arguments.expect_usb_cycle_ms / 1000 + while True: + remaining = max(0, usb_deadline - time.monotonic()) + try: + fixture_status = run_fixture_json_with_retry( + host, "status", environment=environment, + retry_seconds=min(2, remaining), errors=control_errors, + ) + except RuntimeError: + if time.monotonic() >= usb_deadline: + break + continue + if fixture_status.get("state") == "error" and not fixture_status.get("usbMounted", True): + saw_usb_unmounted = True + if saw_usb_unmounted and fixture_status.get("usbMounted"): + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + if completed >= submitted + 1: + break + if time.monotonic() >= usb_deadline: + break + time.sleep(0.05) + capture_response = run_json(capture_command("finalize"), environment, (0, 1)) + + if cpu_load is not None: + cpu_load_result = cpu_load.stop() + cpu_load = None + if swift_compile_load is not None: + swift_compile_load_result = swift_compile_load.stop() + swift_compile_load = None + stage = "evidence-collection" + fixture_trace = fetch_fixture_trace(host, environment, control_errors) + capture = capture_response["snapshot"] + late_reports = int(fixture_status.get("lateReports", 0)) + maximum_lateness_us = int(fixture_status.get("maximumLatenessUs", 0)) + lateness_within_budget = ( + late_reports <= arguments.max_late_reports and + (late_reports == 0 or maximum_lateness_us <= arguments.max_lateness_us) + ) + if interruption_mode == "completion": + checks = { + "capturePassed": capture.get("state") == "passed", + "exactOutput": capture.get("received") == expected, + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "noCaptureIssues": not capture.get("issues"), + "fixtureCompleted": fixture_status.get("state") == "complete", + "allReportsSubmitted": fixture_status.get("reportsSubmitted") == expected_reports, + "latenessWithinBudget": lateness_within_budget, + } + elif interruption_mode in {"remote-abort", "external-abort"}: + received = str(capture.get("received", "")) + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + checks = { + "fixtureAborted": fixture_status.get("state") == "aborted", + "reportsStoppedEarly": 0 < submitted < expected_reports, + "receivedSafePrefix": bool(received) and expected.startswith(received), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "releaseTransferCompleted": completed >= submitted + 1, + "noDuplicateDownEvents": capture.get("duplicateDownEvents", 0) == 0, + "noRepeatEvents": capture.get("repeatEvents", 0) == 0, + "noUnmatchedUpEvents": capture.get("unmatchedUpEvents", 0) == 0, + } + else: + received = str(capture.get("received", "")) + submitted = int(fixture_status.get("reportsSubmitted", 0)) + completed = int(fixture_status.get("transfersCompleted", 0)) + checks = { + "fixtureErroredOnUnmount": fixture_status.get("state") == "error", + "usbUnmountObserved": saw_usb_unmounted, + "usbRemounted": fixture_status.get("usbMounted") is True, + "reportsStoppedEarly": 0 < submitted < expected_reports, + "receivedSafePrefix": bool(received) and expected.startswith(received), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "releaseTransferCompleted": completed >= submitted + 1, + "noDuplicateDownEvents": capture.get("duplicateDownEvents", 0) == 0, + "noRepeatEvents": capture.get("repeatEvents", 0) == 0, + "noUnmatchedUpEvents": capture.get("unmatchedUpEvents", 0) == 0, + } + passed = all(checks.values()) + try: + presentation_result = run_fixture_json_with_retry( + host, "present", "--phase", "result", "--result", "pass" if passed else "fail", + "--brand", arguments.brand, + "--progress", "1000", "--title", arguments.run_id[:32], + "--detail", f"{len(capture.get('received', ''))} / {len(expected)} CHARS", + "--next", "NEXT MATRIX CASE", "--reports-expected", str(expected_reports), + "--reports-observed", str(fixture_status.get("reportsSubmitted", 0)), + "--latency-p95-us", str(fixture_status.get("maximumLatenessUs", 0)), + "--safe-release", environment=environment, + retry_seconds=5, errors=control_errors, + ) + except RuntimeError as presentation_error: + presentation_result = {"ok": False, "error": str(presentation_error)} + artifact = { + "schemaVersion": 2, + "runID": arguments.run_id, + "status": "passed" if passed else "failed", + "admissionMode": "demo-bypass" if arguments.demo_mode else "strict", + "fixtureHost": host, + "presentationBrand": arguments.brand, + "expectedReports": expected_reports, + "interruptionMode": interruption_mode, + "timingBudget": { + "maxLateReports": arguments.max_late_reports, + "maxLatenessUs": arguments.max_lateness_us, + }, + "modifierTiming": { + "shiftLeadMs": arguments.shift_lead_ms, + "keyHoldMs": arguments.hold_ms, + "shiftReleaseLagMs": arguments.shift_release_lag_ms, + }, + "scheduling": { + "requestedDelayMs": arguments.delay_ms, + "effectiveDelayMs": effective_delay_ms, + "loadStartedAfterStartConfirmation": load_requested, + }, + "checks": checks, + "control": { + "initialFixtureStatus": initial_status, + "staleRunAbort": stale_abort_response, + "capturePreflight": capture_preflight, + "load": load, + "fixtureArm": fixture_arm, + "captureFocus": capture_focus, + "captureArm": capture_arm, + "captureInitialTerminal": capture_initial_terminal, + "presentationTesting": presentation_testing, + "presentationResult": presentation_result, + "start": start, + "abort": abort_response, + }, + "fixture": fixture_status, + "fixtureTrace": fixture_trace, + "capture": capture, + "cpuLoad": cpu_load_result, + "swiftCompileLoad": swift_compile_load_result, + "controlPlane": { + "degraded": bool(control_errors), + "errors": control_errors, + }, + "startedAtEpoch": started_at_epoch, + "finishedAtEpoch": time.time(), + } + write_artifact(output, artifact) + summary = { + "runID": arguments.run_id, + "status": artifact["status"], + "admissionMode": artifact["admissionMode"], + "artifact": str(output), + "fixtureHost": host, + "expectedReports": expected_reports, + "submittedReports": fixture_status.get("reportsSubmitted"), + "capturedEvents": len(capture.get("events", [])), + "fixtureTraceReports": len(fixture_trace), + "receivedCharacters": len(capture.get("received", "")), + "checks": checks, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 if passed else 1 + except (OSError, ValueError, RuntimeError, KeyError) as error: + cleanup: list[str] = [] + abort_confirmed = False + if cpu_load is not None: + cpu_load_result = cpu_load.stop() + cpu_load = None + cleanup.append("bounded CPU load stopped") + if swift_compile_load is not None: + swift_compile_load_result = swift_compile_load.stop() + swift_compile_load = None + cleanup.append("bounded Swift compile load stopped") + if fixture_mutated and fixture_host: + try: + abort_response = run_fixture_state_change_with_confirmation( + fixture_host, "abort", arguments.run_id, + include_run_id_argument=False, expected_states={"aborted"}, + environment=environment, retry_seconds=60, errors=control_errors, + require_run_id_match=False, + ) + abort_confirmed = True + cleanup.append("fixture aborted with all-keys-released queued") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"fixture abort failed: {cleanup_error}") + if capture_armed: + if not abort_confirmed: + cleanup.append( + "fixture abort unconfirmed; waiting for the bounded capture window" + ) + try: + capture_response = run_json(capture_command( + "wait", "--timeout-seconds", f"{max(2, timeout_ms / 1000 + 2):g}", + "--poll-ms", "100", + ), environment, (0, 1)) + cleanup.append("capture reached a terminal state before cleanup") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"capture terminal wait failed: {cleanup_error}") + release_snapshot, release_confirmed = wait_for_capture_release( + environment, timeout_seconds=5 + ) + cleanup.append( + "capture observed all keys and modifiers released" + if release_confirmed else + "capture release remained unconfirmed after 5s" + ) + capture_state = (capture_response or {}).get("snapshot", {}).get("state") + if capture_state not in {"passed", "failed"}: + try: + capture_response = run_json( + capture_command("finalize"), environment, (0, 1) + ) + cleanup.append("capture finalized") + except (OSError, ValueError, RuntimeError, KeyError) as cleanup_error: + cleanup.append(f"capture finalize failed: {cleanup_error}") + capture_response = release_snapshot + if fixture_host and fixture_mutated: + try: + fixture_status = run_fixture_json_with_retry( + fixture_host, "status", environment=environment, + retry_seconds=3, errors=control_errors, + ) + except (OSError, ValueError, RuntimeError, KeyError): + pass + try: + fixture_trace = fetch_fixture_trace( + fixture_host, environment, control_errors + ) + except (OSError, ValueError, RuntimeError, KeyError): + pass + capture = (capture_response or {}).get("snapshot", {}) + classification = ( + "fixture-control-plane" if control_errors or stage.startswith("fixture") else + "host-resource-admission" if stage == "host-resource-admission" else + "capture-oracle" if stage.startswith("capture") else + "harness-infrastructure" + ) + artifact = { + "schemaVersion": 2, + "runID": arguments.run_id, + "status": "inconclusive", + "admissionMode": "demo-bypass" if arguments.demo_mode else "strict", + "fixtureHost": fixture_host or arguments.fixture_host, + "presentationBrand": arguments.brand, + "expectedReports": expected_reports, + "interruptionMode": interruption_mode, + "failure": { + "classification": classification, + "stage": stage, + "error": str(error)[:1200], + }, + "cleanup": cleanup, + "controlPlane": { + "degraded": bool(control_errors), + "errors": control_errors, + }, + "control": { + "initialFixtureStatus": initial_status, + "capturePreflight": capture_preflight, + "load": load, + "fixtureArm": fixture_arm, + "captureFocus": capture_focus, + "captureArm": capture_arm, + "captureInitialTerminal": capture_initial_terminal, + "presentationTesting": presentation_testing, + "start": start, + "abort": abort_response, + }, + "fixture": fixture_status, + "fixtureTrace": fixture_trace, + "capture": capture, + "cpuLoad": cpu_load_result, + "swiftCompileLoad": swift_compile_load_result, + "startedAtEpoch": started_at_epoch, + "finishedAtEpoch": time.time(), + } + write_artifact(output, artifact) + summary = { + "runID": arguments.run_id, + "status": "inconclusive", + "admissionMode": artifact["admissionMode"], + "classification": classification, + "stage": stage, + "artifact": str(output), + "receivedCharacters": len(capture.get("received", "")), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + print( + f"physical-hid-capture-run: {classification} at {stage}: {error}", + file=sys.stderr, + ) + return 2 + finally: + if cpu_load is not None: + cpu_load.stop() + if swift_compile_load is not None: + swift_compile_load.stop() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/physical-hid-repeat-matrix b/Scripts/lab/physical-hid-repeat-matrix new file mode 100755 index 000000000..bbaac1c2b --- /dev/null +++ b/Scripts/lab/physical-hid-repeat-matrix @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Run strict physical-HID repeat and duplicate-key cases across pace and CPU load.""" + +from __future__ import annotations + +import argparse +import datetime +import difflib +import hashlib +import json +import os +import pathlib +import subprocess +import sys +import time +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RUNNER = pathlib.Path(os.environ.get( + "KEYPATH_PHYSICAL_HID_RUNNER", ROOT / "Scripts/lab/physical-hid-capture-run" +)) +JIG_TOOL = pathlib.Path(os.environ.get( + "KEYPATH_CAPTURE_JIG_TOOL", ROOT / "Scripts/lab/hid-capture-jig-tool" +)) +FIXTURES = ROOT / "Scripts/lab/pico-hid-fixture/tests/fixtures" +DEFAULT_CORPORA = [ + ("single", FIXTURES / "repeat-single.txt"), + ("alternating", FIXTURES / "repeat-alternating.txt"), + ("roll", FIXTURES / "repeat-roll.txt"), + ("shifted", FIXTURES / "burst.txt"), +] + + +def integer_list(value: str) -> list[int]: + try: + values = [int(part) for part in value.split(",")] + except ValueError as error: + raise argparse.ArgumentTypeError("values must be comma-separated integers") from error + if not values or any(item < 0 for item in values) or len(set(values)) != len(values): + raise argparse.ArgumentTypeError("values must be unique non-negative integers") + return values + + +def safe_name(value: str) -> str: + return "".join(character if character.isalnum() or character in "-_." else "_" for character in value) + + +def case_run_id(prefix: str, corpus: str, interval_ms: int, workers: int, swift_compile: bool) -> str: + suffix = f"-{safe_name(corpus)[:4]}-i{interval_ms}-w{workers}-s{int(swift_compile)}" + prefix_budget = 48 - len(suffix) + return f"{safe_name(prefix)[:prefix_budget]}{suffix}" + + +def retry_run_id(base_run_id: str, retry: int) -> str: + """Keep retries unique even when long base IDs share a truncated prefix.""" + suffix = f"-{hashlib.sha256(base_run_id.encode()).hexdigest()[:8]}-retry{retry}" + return f"{base_run_id[:48 - len(suffix)]}{suffix}" + + +def valid_resume_artifact( + output_directory: pathlib.Path, base_run_id: str +) -> tuple[pathlib.Path | None, int]: + """Return the newest valid base/retry artifact and the next retry number.""" + base_path = output_directory / f"{base_run_id}.json" + if not base_path.is_file(): + return None, 2 + candidates = [base_path] + retry = 2 + while True: + candidate = output_directory / f"{retry_run_id(base_run_id, retry)}.json" + if not candidate.is_file(): + break + candidates.append(candidate) + retry += 1 + for candidate in reversed(candidates): + try: + case = analyze_artifact(json.loads(candidate.read_text())) + except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError): + continue + if case["validEvidence"]: + return candidate, retry + return None, retry + + +def edit_counts(expected: str, received: str) -> dict[str, int]: + counts = {"addedCharacters": 0, "deletedCharacters": 0, "substitutedCharacters": 0} + matcher = difflib.SequenceMatcher(a=expected, b=received, autojunk=False) + for tag, first_start, first_end, second_start, second_end in matcher.get_opcodes(): + first_length = first_end - first_start + second_length = second_end - second_start + if tag == "insert": + counts["addedCharacters"] += second_length + elif tag == "delete": + counts["deletedCharacters"] += first_length + elif tag == "replace": + common = min(first_length, second_length) + counts["substitutedCharacters"] += common + counts["deletedCharacters"] += first_length - common + counts["addedCharacters"] += second_length - common + return counts + + +def analyze_artifact(artifact: dict[str, Any]) -> dict[str, Any]: + capture = artifact.get("capture", {}) + fixture = artifact.get("fixture", {}) + expected = str(capture.get("expected", "")) + received = str(capture.get("received", "")) + edits = edit_counts(expected, received) + wrong = sum(edits.values()) + focus_valid = bool(capture.get("focused")) + all_keys_released = not capture.get("pressedKeyCodes") + all_modifiers_released = capture.get("activeModifiers", 0) == 0 + fixture_complete = fixture.get("state") == "complete" + return { + "runID": artifact.get("runID"), + "runnerStatus": artifact.get("status"), + "admissionMode": artifact.get("admissionMode"), + "expectedCharacters": len(expected), + "receivedCharacters": len(received), + "wrongCharacters": wrong, + **edits, + "duplicateDownEvents": int(capture.get("duplicateDownEvents", 0)), + "repeatEvents": int(capture.get("repeatEvents", 0)), + "unmatchedUpEvents": int(capture.get("unmatchedUpEvents", 0)), + "focusValid": focus_valid, + "allKeysReleased": all_keys_released, + "allModifiersReleased": all_modifiers_released, + "fixtureComplete": fixture_complete, + "lateReports": fixture.get("lateReports"), + "maximumLatenessUs": fixture.get("maximumLatenessUs"), + "validEvidence": ( + artifact.get("status") in {"passed", "failed"} and + artifact.get("admissionMode") == "strict" and focus_valid and + all_keys_released and all_modifiers_released and fixture_complete + ), + } + + +def classify(cases: list[dict[str, Any]]) -> str: + if not cases or any(not case["validEvidence"] for case in cases): + return "harness-invalid" + if any( + case["addedCharacters"] or case["duplicateDownEvents"] or case["repeatEvents"] + for case in cases + ): + return "repeated-input-observed" + if any(case["wrongCharacters"] for case in cases): + return "output-corruption-observed" + return "exact-output-observed" + + +def ensure_jig_running() -> tuple[bool, str]: + status = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if status.returncode == 0: + return True, status.stderr.strip() or status.stdout.strip() or "capture Jig is responsive" + opened = subprocess.run([str(JIG_TOOL), "open"], text=True, capture_output=True) + detail = opened.stderr.strip() or opened.stdout.strip() or f"exit {opened.returncode}" + return opened.returncode == 0, detail + + +def wait_for_jig_readiness(timeout_seconds: float) -> tuple[bool, str]: + deadline = time.monotonic() + timeout_seconds + detail = "capture Jig readiness is unavailable" + while True: + result = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if result.returncode == 0: + try: + readiness = json.loads(result.stdout).get("systemReadiness", {}) + detail = str(readiness.get("detail", detail)) + if readiness.get("canProceed"): + return True, detail + except (json.JSONDecodeError, AttributeError): + detail = "capture Jig returned invalid readiness JSON" + else: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + if time.monotonic() >= deadline: + return False, detail + time.sleep(1) + + +def write_json(path: pathlib.Path, value: dict[str, Any]) -> None: + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + result.add_argument("--run-id-prefix", default=f"repeat-matrix-{stamp}") + result.add_argument("--interval-values", type=integer_list, default=integer_list("50,10,5")) + result.add_argument("--cpu-worker-values", type=integer_list, default=integer_list("0,2,6")) + result.add_argument( + "--swift-compile-worker-values", type=integer_list, default=integer_list("0,2") + ) + result.add_argument("--no-swift-compile", action="store_true") + result.add_argument("--hold-ms", type=int, default=2) + result.add_argument("--repeat", type=int, default=4) + result.add_argument("--cycle-gap-ms", type=int, default=100) + result.add_argument("--delay-ms", type=int, default=800) + result.add_argument("--readiness-timeout-seconds", type=float, default=60) + result.add_argument("--stress-max-late-reports", type=int, default=5) + result.add_argument("--stress-max-lateness-us", type=int, default=5000) + result.add_argument("--fixture-host", default=os.environ.get( + "KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local" + )) + result.add_argument("--output-directory", type=pathlib.Path) + result.add_argument("--resume", action="store_true") + result.add_argument("--exclusive-desktop-confirmed", action="store_true") + result.add_argument("--continue-after-infrastructure-error", action="store_true") + return result + + +def main() -> int: + arguments = parser().parse_args() + if not arguments.exclusive_desktop_confirmed: + print( + "physical-hid-repeat-matrix: --exclusive-desktop-confirmed is required; " + "real USB keyboard input owns the active desktop", + file=sys.stderr, + ) + return 2 + if arguments.hold_ms < 1 or arguments.repeat < 1 or arguments.cycle_gap_ms < 0: + print("physical-hid-repeat-matrix: hold/repeat must be positive and gap non-negative", file=sys.stderr) + return 2 + if any(interval < 4 or arguments.hold_ms >= interval for interval in arguments.interval_values): + print("physical-hid-repeat-matrix: intervals must be >=4 ms and exceed hold-ms", file=sys.stderr) + return 2 + if arguments.readiness_timeout_seconds < 0: + print("physical-hid-repeat-matrix: readiness timeout cannot be negative", file=sys.stderr) + return 2 + logical_cpus = max(1, os.cpu_count() or 1) + all_worker_values = [ + *arguments.cpu_worker_values, + *([] if arguments.no_swift_compile else arguments.swift_compile_worker_values), + ] + if any(workers > logical_cpus for workers in all_worker_values): + print(f"physical-hid-repeat-matrix: CPU workers cannot exceed {logical_cpus}", file=sys.stderr) + return 2 + if arguments.stress_max_late_reports < 0 or arguments.stress_max_lateness_us < 0: + print("physical-hid-repeat-matrix: timing budgets cannot be negative", file=sys.stderr) + return 2 + missing = [str(path) for _, path in DEFAULT_CORPORA if not path.is_file()] + if missing: + print(f"physical-hid-repeat-matrix: missing corpus: {', '.join(missing)}", file=sys.stderr) + return 2 + + output_directory = arguments.output_directory or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/repeat-matrix" / + safe_name(arguments.run_id_prefix) + ) + output_directory.mkdir(parents=True, exist_ok=True) + output_directory.chmod(0o700) + load_profiles = [ + {"name": f"cpu{workers}", "cpuWorkers": workers, "swiftCompile": False} + for workers in arguments.cpu_worker_values + ] + if not arguments.no_swift_compile: + load_profiles.extend([ + {"name": f"swift-cpu{workers}", "cpuWorkers": workers, "swiftCompile": True} + for workers in arguments.swift_compile_worker_values + ]) + plan = { + "schemaVersion": 1, + "runIDPrefix": arguments.run_id_prefix, + "corpora": [{"name": name, "path": str(path)} for name, path in DEFAULT_CORPORA], + "intervalValuesMs": arguments.interval_values, + "loadProfiles": load_profiles, + "holdMs": arguments.hold_ms, + "repeat": arguments.repeat, + "cycleGapMs": arguments.cycle_gap_ms, + "caseCount": len(DEFAULT_CORPORA) * len(arguments.interval_values) * len(load_profiles), + "admissionMode": "strict", + "exclusiveDesktopConfirmed": True, + } + plan_path = output_directory / "plan.json" + if plan_path.is_file(): + if not arguments.resume: + print( + "physical-hid-repeat-matrix: output already contains a plan; use --resume or a new prefix", + file=sys.stderr, + ) + return 2 + try: + existing_plan = json.loads(plan_path.read_text()) + except json.JSONDecodeError: + print("physical-hid-repeat-matrix: existing plan is invalid JSON", file=sys.stderr) + return 2 + if existing_plan != plan: + print("physical-hid-repeat-matrix: existing plan differs; use a new prefix", file=sys.stderr) + return 2 + else: + write_json(plan_path, plan) + + jig_ready, detail = ensure_jig_running() + if not jig_ready: + print(f"physical-hid-repeat-matrix: could not open capture Jig: {detail}", file=sys.stderr) + return 2 + + cases: list[dict[str, Any]] = [] + infrastructure_errors: list[dict[str, Any]] = [] + stop = False + for corpus_name, corpus_path in DEFAULT_CORPORA: + for interval_ms in arguments.interval_values: + for profile in load_profiles: + workers = int(profile["cpuWorkers"]) + swift_compile = bool(profile["swiftCompile"]) + base_run_id = case_run_id( + arguments.run_id_prefix, corpus_name, interval_ms, workers, swift_compile + ) + run_id = base_run_id + artifact_path = output_directory / f"{run_id}.json" + if arguments.resume and artifact_path.is_file(): + valid_artifact, retry = valid_resume_artifact(output_directory, base_run_id) + if valid_artifact is not None: + artifact_path = valid_artifact + case = analyze_artifact(json.loads(artifact_path.read_text())) + case.update({ + "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, + "artifact": str(artifact_path), "resumed": True, + }) + cases.append(case) + continue + run_id = retry_run_id(base_run_id, retry) + artifact_path = output_directory / f"{run_id}.json" + jig_ready, detail = ensure_jig_running() + if not jig_ready: + infrastructure_errors.append({ + "runID": run_id, "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, "returnCode": 2, + "detail": f"capture Jig unavailable: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + stop = True + break + continue + resource_ready, detail = wait_for_jig_readiness(arguments.readiness_timeout_seconds) + if not resource_ready: + infrastructure_errors.append({ + "runID": run_id, "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, "returnCode": 2, + "detail": f"capture Jig resource gate timed out: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + stop = True + break + continue + command = [ + str(RUNNER), "--run-id", run_id, "--text", str(corpus_path), + "--fixture-host", arguments.fixture_host, + "--interval-ms", str(interval_ms), "--hold-ms", str(arguments.hold_ms), + "--repeat", str(arguments.repeat), "--cycle-gap-ms", str(arguments.cycle_gap_ms), + "--delay-ms", str(arguments.delay_ms), "--cpu-load-workers", str(workers), + "--output", str(artifact_path), + ] + if swift_compile: + command.append("--swift-compile-load") + if workers > 0 or swift_compile: + command.extend([ + "--max-late-reports", str(arguments.stress_max_late_reports), + "--max-lateness-us", str(arguments.stress_max_lateness_us), + ]) + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode == 2 or not artifact_path.is_file(): + infrastructure_errors.append({ + "runID": run_id, "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, "returnCode": result.returncode, + "detail": result.stderr.strip() or result.stdout.strip() or "artifact missing", + }) + if not arguments.continue_after_infrastructure_error: + stop = True + break + continue + case = analyze_artifact(json.loads(artifact_path.read_text())) + case.update({ + "corpus": corpus_name, "intervalMs": interval_ms, + "loadProfile": profile["name"], "cpuWorkers": workers, + "swiftCompile": swift_compile, + "artifact": str(artifact_path), "resumed": False, + }) + cases.append(case) + print(json.dumps({key: case[key] for key in ( + "runID", "corpus", "intervalMs", "loadProfile", "cpuWorkers", + "swiftCompile", "wrongCharacters", + "addedCharacters", "repeatEvents", "focusValid", "lateReports", + )}, sort_keys=True), flush=True) + if stop: + break + if stop: + break + + classification = classify(cases) + status = "blocked" if infrastructure_errors or classification == "harness-invalid" else "completed" + summary = { + "schemaVersion": 1, + "status": status, + "classification": classification, + "plan": plan, + "completedCases": len(cases), + "infrastructureErrors": infrastructure_errors, + "cases": cases, + } + summary_path = output_directory / "summary.json" + write_json(summary_path, summary) + print(json.dumps({ + "status": status, + "classification": classification, + "completedCases": len(cases), + "plannedCases": plan["caseCount"], + "summary": str(summary_path), + }, indent=2, sort_keys=True)) + if status == "blocked": + return 2 + return 0 if classification == "exact-output-observed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/physical-hid-shift-matrix b/Scripts/lab/physical-hid-shift-matrix new file mode 100755 index 000000000..ff86c4602 --- /dev/null +++ b/Scripts/lab/physical-hid-shift-matrix @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Run a focused physical-HID Shift lead/hold/release timing matrix.""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import pathlib +import subprocess +import sys +import time +from typing import Any + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +RUNNER = pathlib.Path(os.environ.get( + "KEYPATH_PHYSICAL_HID_RUNNER", ROOT / "Scripts/lab/physical-hid-capture-run" +)) +JIG_TOOL = pathlib.Path(os.environ.get( + "KEYPATH_CAPTURE_JIG_TOOL", ROOT / "Scripts/lab/hid-capture-jig-tool" +)) +DEFAULT_TEXT = ROOT / "Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt" +SHIFT_DEMOTIONS = { + **{shifted: plain for shifted, plain in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")}, + **dict(zip("!@#$%^&*()_+{}|:\"~<>?", "1234567890-=[]\\;'`,./")), +} + + +def integer_list(value: str) -> list[int]: + try: + values = [int(part) for part in value.split(",")] + except ValueError as error: + raise argparse.ArgumentTypeError("timing values must be comma-separated integers") from error + if not values or any(item < 0 for item in values) or len(set(values)) != len(values): + raise argparse.ArgumentTypeError("timing values must be unique non-negative integers") + return values + + +def safe_name(value: str) -> str: + return "".join(character if character.isalnum() or character in "-_." else "_" for character in value) + + +def analyze_artifact(artifact: dict[str, Any]) -> dict[str, Any]: + capture = artifact.get("capture", {}) + expected = str(capture.get("expected", "")) + received = str(capture.get("received", "")) + paired_mismatches = [ + {"offset": index, "expected": wanted, "received": actual} + for index, (wanted, actual) in enumerate(zip(expected, received)) + if wanted != actual + ] + shift_demotions = sum( + 1 for mismatch in paired_mismatches + if SHIFT_DEMOTIONS.get(mismatch["expected"]) == mismatch["received"] + ) + wrong_characters = len(paired_mismatches) + abs(len(expected) - len(received)) + fixture = artifact.get("fixture", {}) + return { + "runID": artifact.get("runID"), + "runnerStatus": artifact.get("status"), + "shiftLeadMs": artifact.get("modifierTiming", {}).get("shiftLeadMs"), + "keyHoldMs": artifact.get("modifierTiming", {}).get("keyHoldMs"), + "shiftReleaseLagMs": artifact.get("modifierTiming", {}).get("shiftReleaseLagMs"), + "expectedCharacters": len(expected), + "receivedCharacters": len(received), + "wrongCharacters": wrong_characters, + "shiftDemotions": shift_demotions, + "focusValid": bool(capture.get("focused")), + "allKeysReleased": not capture.get("pressedKeyCodes"), + "allModifiersReleased": capture.get("activeModifiers", 0) == 0, + "lateReports": fixture.get("lateReports"), + "maximumLatenessUs": fixture.get("maximumLatenessUs"), + "fixtureTraceReports": len(artifact.get("fixtureTrace", [])), + "firstMismatches": paired_mismatches[:12], + } + + +def classify(cases: list[dict[str, Any]]) -> str: + valid = [case for case in cases if case["focusValid"] and case["allKeysReleased"] and + case["allModifiersReleased"]] + if not valid: + return "harness-invalid" + baseline = next((case for case in valid if case["shiftLeadMs"] == 0 and + case["shiftReleaseLagMs"] == 0), None) + exact = [case for case in valid if case["wrongCharacters"] == 0] + if exact and baseline and baseline["wrongCharacters"] > 0: + return "modifier-timing-sensitive" + if exact: + return "exact-output-observed" + if any(case["shiftDemotions"] for case in valid): + return "shift-demotion-persists" + return "output-corruption-persists" + + +def ensure_jig_running() -> tuple[bool, str]: + """Preserve a responsive Jig process without stealing keyboard focus.""" + status = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if status.returncode == 0: + detail = status.stderr.strip() or status.stdout.strip() or "capture Jig is responsive" + return True, detail + result = subprocess.run([str(JIG_TOOL), "open"], text=True, capture_output=True) + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + return result.returncode == 0, detail + + +def wait_for_jig_readiness(timeout_seconds: float) -> tuple[bool, str]: + """Wait for a stable resource window without activating the Jig.""" + deadline = time.monotonic() + timeout_seconds + detail = "capture Jig readiness is unavailable" + while True: + result = subprocess.run([str(JIG_TOOL), "status"], text=True, capture_output=True) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + else: + try: + response = json.loads(result.stdout) + readiness = response.get("systemReadiness", {}) + detail = str(readiness.get("detail", "resource readiness is unavailable")) + if readiness.get("canProceed"): + return True, detail + except (json.JSONDecodeError, AttributeError): + detail = "capture Jig returned invalid readiness JSON" + if time.monotonic() >= deadline: + return False, detail + time.sleep(1) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + result.add_argument("--run-id-prefix", default=f"shift-timing-{stamp}") + result.add_argument("--text", type=pathlib.Path, default=DEFAULT_TEXT) + result.add_argument("--lead-values", type=integer_list, default=integer_list("0,2,4,8,12")) + result.add_argument("--release-values", type=integer_list, default=integer_list("0,2,4,8,12")) + result.add_argument("--interval-ms", type=int, default=50) + result.add_argument("--hold-ms", type=int, default=8) + result.add_argument("--repeat", type=int, default=5) + result.add_argument("--cycle-gap-ms", type=int, default=250) + result.add_argument("--delay-ms", type=int, default=1000) + result.add_argument("--readiness-timeout-seconds", type=float, default=60) + result.add_argument("--fixture-host", default=os.environ.get( + "KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + result.add_argument("--output-directory", type=pathlib.Path) + result.add_argument("--continue-after-infrastructure-error", action="store_true") + return result + + +def main() -> int: + arguments = parser().parse_args() + if not arguments.text.is_file(): + print(f"physical-hid-shift-matrix: text file does not exist: {arguments.text}", file=sys.stderr) + return 2 + maximum_timing = max(arguments.lead_values) + arguments.hold_ms + max(arguments.release_values) + if maximum_timing >= arguments.interval_ms: + print("physical-hid-shift-matrix: lead + hold + release must fit inside the interval", file=sys.stderr) + return 2 + if arguments.readiness_timeout_seconds < 0: + print("physical-hid-shift-matrix: readiness timeout cannot be negative", file=sys.stderr) + return 2 + + output_directory = arguments.output_directory or ( + pathlib.Path.home() / ".local/state/keypath-hid-capture-jig/modifier-matrix" / + safe_name(arguments.run_id_prefix) + ) + output_directory.mkdir(parents=True, exist_ok=True) + output_directory.chmod(0o700) + plan = { + "schemaVersion": 1, + "runIDPrefix": arguments.run_id_prefix, + "text": str(arguments.text), + "intervalMs": arguments.interval_ms, + "holdMs": arguments.hold_ms, + "repeat": arguments.repeat, + "leadValuesMs": arguments.lead_values, + "releaseValuesMs": arguments.release_values, + "caseCount": len(arguments.lead_values) * len(arguments.release_values), + } + (output_directory / "plan.json").write_text(json.dumps(plan, indent=2, sort_keys=True) + "\n") + (output_directory / "plan.json").chmod(0o600) + + jig_ready, detail = ensure_jig_running() + if not jig_ready: + print(f"physical-hid-shift-matrix: could not open capture Jig: {detail}", file=sys.stderr) + return 2 + + cases: list[dict[str, Any]] = [] + infrastructure_errors: list[dict[str, Any]] = [] + for lead_ms in arguments.lead_values: + for release_ms in arguments.release_values: + run_id = safe_name(f"{arguments.run_id_prefix}-l{lead_ms}-r{release_ms}")[:48] + artifact_path = output_directory / f"{run_id}.json" + jig_ready, detail = ensure_jig_running() + if not jig_ready: + infrastructure_errors.append({ + "runID": run_id, "shiftLeadMs": lead_ms, + "shiftReleaseLagMs": release_ms, "returnCode": 2, + "detail": f"capture Jig focus failed: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + break + continue + resource_ready, detail = wait_for_jig_readiness(arguments.readiness_timeout_seconds) + if not resource_ready: + infrastructure_errors.append({ + "runID": run_id, "shiftLeadMs": lead_ms, + "shiftReleaseLagMs": release_ms, "returnCode": 2, + "detail": f"capture Jig resource gate timed out: {detail}", + }) + if not arguments.continue_after_infrastructure_error: + break + continue + command = [ + str(RUNNER), "--run-id", run_id, "--text", str(arguments.text), + "--fixture-host", arguments.fixture_host, + "--interval-ms", str(arguments.interval_ms), "--hold-ms", str(arguments.hold_ms), + "--shift-lead-ms", str(lead_ms), "--shift-release-lag-ms", str(release_ms), + "--repeat", str(arguments.repeat), "--cycle-gap-ms", str(arguments.cycle_gap_ms), + "--delay-ms", str(arguments.delay_ms), "--output", str(artifact_path), + ] + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode == 2 or not artifact_path.is_file(): + error = { + "runID": run_id, "shiftLeadMs": lead_ms, "shiftReleaseLagMs": release_ms, + "returnCode": result.returncode, + "detail": result.stderr.strip() or result.stdout.strip() or "artifact missing", + } + infrastructure_errors.append(error) + if not arguments.continue_after_infrastructure_error: + break + continue + artifact = json.loads(artifact_path.read_text()) + case = analyze_artifact(artifact) + case["artifact"] = str(artifact_path) + cases.append(case) + print(json.dumps({key: case[key] for key in ( + "runID", "shiftLeadMs", "shiftReleaseLagMs", "wrongCharacters", + "shiftDemotions", "focusValid", "lateReports" + )}, sort_keys=True), flush=True) + if infrastructure_errors and not arguments.continue_after_infrastructure_error: + break + + ranked = sorted( + (case for case in cases if case["focusValid"] and case["allKeysReleased"] and + case["allModifiersReleased"]), + key=lambda case: (case["wrongCharacters"], case["shiftDemotions"], + case["shiftLeadMs"] + case["shiftReleaseLagMs"]), + ) + summary = { + "schemaVersion": 1, + "status": "blocked" if infrastructure_errors else "completed", + "classification": classify(cases), + "plan": plan, + "completedCases": len(cases), + "infrastructureErrors": infrastructure_errors, + "bestCases": ranked[:5], + "cases": cases, + } + summary_path = output_directory / "summary.json" + summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + summary_path.chmod(0o600) + print(json.dumps({ + "status": summary["status"], "classification": summary["classification"], + "completedCases": len(cases), "plannedCases": plan["caseCount"], + "summary": str(summary_path), "bestCases": ranked[:3], + }, indent=2, sort_keys=True)) + return 2 if infrastructure_errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/physical-remap-session b/Scripts/lab/physical-remap-session new file mode 100755 index 000000000..7d96bfdce --- /dev/null +++ b/Scripts/lab/physical-remap-session @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Prepare and observe the single physical-HID proof shared by P02-P04.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time + + +ROOT = pathlib.Path(__file__).resolve().parent +RESULT = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_RESULT", ROOT / "scenario-result")) +ASSERT_READY = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_ASSERT_READY", ROOT / "assert-runtime-state")) +PEEKABOO_UI = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_PEEKABOO_UI", ROOT / "peekaboo-ui")) +PEEKABOO = pathlib.Path(os.environ.get("KEYPATH_PHYSICAL_PEEKABOO", pathlib.Path.home() / ".local/bin/peekaboo")) +INSTALLED_CLI = pathlib.Path(os.environ.get( + "KEYPATH_PHYSICAL_INSTALLED_CLI", "/Applications/KeyPath.app/Contents/MacOS/keypath-cli" +)) +SW_VERS = os.environ.get("KEYPATH_PHYSICAL_SW_VERS", "/usr/bin/sw_vers") +IOREG = os.environ.get("KEYPATH_PHYSICAL_IOREG", "/usr/sbin/ioreg") +OPEN = os.environ.get("KEYPATH_PHYSICAL_OPEN", "/usr/bin/open") +OSASCRIPT = os.environ.get("KEYPATH_PHYSICAL_OSASCRIPT", "/usr/bin/osascript") +SLEEP_SCALE = max(0.0, float(os.environ.get("KEYPATH_PHYSICAL_SLEEP_SCALE", "1"))) + + +def run(command: list[str], output: pathlib.Path | None = None) -> subprocess.CompletedProcess[str]: + if output is None: + return subprocess.run(command, check=False, capture_output=True, text=True) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("w") as handle: + return subprocess.run(command, check=False, stdout=handle, stderr=subprocess.STDOUT, text=True) + + +def pause(seconds: float) -> None: + time.sleep(seconds * SLEEP_SCALE) + + +def record(directory: pathlib.Path, scenario: str, status: str, summary: str, + classification: str | None = None, step: str | None = None, + message: str | None = None, evidence: tuple[str, ...] = ()) -> None: + command = [str(RESULT), "record", "--output", str(directory / "result.json"), + "--scenario", scenario, "--status", status, "--summary", summary] + if classification: + command += ["--classification", classification] + if step: + command += ["--step", step] + if message: + command += ["--message", message] + for item in evidence: + command += ["--evidence", item] + result = run(command) + if result.returncode: + raise RuntimeError(result.stderr or "could not record scenario result") + + +def snapshot(app: str, output: pathlib.Path) -> subprocess.CompletedProcess[str]: + return run([str(PEEKABOO_UI), "snapshot", "--app", app, "--output", str(output)]) + + +def textedit_value() -> str: + result = run([OSASCRIPT, "-e", 'tell application "TextEdit" to get text of document 1']) + return result.stdout.rstrip("\n") if result.returncode == 0 else "" + + +def overlay_key_state(path: pathlib.Path) -> tuple[bool, bool]: + try: + root = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return False, False + found = False + pressed = False + + def walk(value: object) -> None: + nonlocal found, pressed + if isinstance(value, dict): + if value.get("identifier") == "keycap-code-12": + found = True + state = str(value.get("value", value.get("accessibilityValue", ""))).casefold() + pressed = state in ("pressed", "held") + for child in value.values(): + walk(child) + elif isinstance(value, list): + for child in value: + walk(child) + + walk(root) + return found, pressed + + +def prepare(args: argparse.Namespace) -> int: + output = args.output + prep = output / "prepare" + prep.mkdir(parents=True, exist_ok=True) + version = run([SW_VERS, "-productVersion"]) + if version.returncode or version.stdout.strip().split(".")[0] != "15": + record(prep, "physical-remap-prepare", "blocked", "The physical remap session requires macOS 15.", + "environment-precondition-failure", "os-admission") + return 4 + run([SW_VERS], prep / "sw-vers.txt") + hid = run([IOREG, "-r", "-c", "IOHIDDevice", "-l"]) + inventory = hid.stdout + retained_keys = ("Product", "Manufacturer", "Transport", "VendorID", "ProductID", "PrimaryUsage") + safe_inventory = "\n".join( + line for line in inventory.splitlines() if any(key in line for key in retained_keys) + ) + "\n" + (prep / "hid-inventory.txt").write_text(safe_inventory) + if hid.returncode or args.device_match.casefold() not in inventory.casefold(): + record(prep, "physical-remap-prepare", "blocked", + "The named physical keyboard is not visible inside the guest.", + "environment-precondition-failure", "physical-hid-admission", + f"Missing guest HID match: {args.device_match}", ("hid-inventory.txt",)) + return 4 + manifest = pathlib.Path("/Library/KeyPathLab/managed-policy/manifest.json") + ready_command = [str(ASSERT_READY), "ready"] + if manifest.exists(): + ready_command += ["--managed-policy-manifest", str(manifest)] + ready = run(ready_command, prep / "runtime-ready.tsv") + if ready.returncode: + record(prep, "physical-remap-prepare", "blocked", + "The physical proof requires an independently ready runtime.", + "environment-precondition-failure", "runtime-ready", evidence=("runtime-ready.tsv",)) + return 4 + if not INSTALLED_CLI.is_file() or not os.access(INSTALLED_CLI, os.X_OK): + record(prep, "physical-remap-prepare", "blocked", "The installed KeyPath CLI is unavailable.", + "environment-precondition-failure", "installed-cli") + return 4 + rule = run([str(INSTALLED_CLI), "rule", "ensure", "q", "w", "--apply"], prep / "rule-q-to-w.json") + simulate = run([str(INSTALLED_CLI), "simulate", "q"], prep / "simulation.txt") + if rule.returncode or simulate.returncode: + record(prep, "physical-remap-prepare", "failed", "Could not establish the deterministic q-to-w rule.", + "keypath-product-failure", "q-to-w-rule", evidence=("rule-q-to-w.json", "simulation.txt")) + return 1 + run([OPEN, "-a", "KeyPath"]) + pause(2) + overlay = prep / "overlay-ready.json" + if snapshot("KeyPath", overlay).returncode or "keyboard-overlay" not in overlay.read_text(errors="replace"): + run([str(PEEKABOO), "hotkey", "cmd,alt,l", "--app", "KeyPath"]) + pause(1) + if snapshot("KeyPath", overlay).returncode or "keyboard-overlay" not in overlay.read_text(errors="replace"): + record(prep, "physical-remap-prepare", "blocked", "The KeyPath overlay is not AX-visible.", + "environment-precondition-failure", "overlay-ready", evidence=("overlay-ready.json",)) + return 4 + textedit_script = ( + 'tell application "TextEdit"\nactivate\n' + 'if (count documents) = 0 then make new document\n' + 'set text of document 1 to ""\nend tell' + ) + if run([OSASCRIPT, "-e", textedit_script]).returncode: + record(prep, "physical-remap-prepare", "blocked", "Could not prepare the independent TextEdit oracle.", + "environment-precondition-failure", "textedit-ready") + return 4 + pause(1) + if snapshot("TextEdit", prep / "textedit-ready.json").returncode: + record(prep, "physical-remap-prepare", "blocked", "TextEdit is not AX-visible.", + "environment-precondition-failure", "textedit-ready") + return 4 + (prep / "session.json").write_text(json.dumps({ + "schemaVersion": 1, + "deviceMatch": args.device_match, + "sourceKey": "q", + "expectedOutput": "w", + }, indent=2) + "\n") + record(prep, "physical-remap-prepare", "passed", + "Ready runtime, physical guest HID, q-to-w rule, overlay, and TextEdit oracle are armed.", + evidence=("sw-vers.txt", "hid-inventory.txt", "runtime-ready.tsv", "rule-q-to-w.json", + "simulation.txt", "overlay-ready.json", "textedit-ready.json", "session.json")) + print("physical_remap_session\tarmed") + print("next\trun observe, then hold the named keyboard's physical q key until it completes") + return 0 + + +def observe(args: argparse.Namespace) -> int: + output = args.output + prep_result = output / "prepare" / "result.json" + try: + prepared = json.loads(prep_result.read_text()).get("status") == "passed" + except (OSError, json.JSONDecodeError): + prepared = False + if not prepared: + target = output / "P02" + target.mkdir(parents=True, exist_ok=True) + record(target, "P02-physical-remap", "blocked", "Run the passed prepare stage first.", + "environment-precondition-failure", "prepare-checkpoint") + return 4 + run([OSASCRIPT, "-e", 'tell application "KeyPath" to quit']) + pause(0.5) + started = time.monotonic_ns() + if run([OPEN, "-a", "KeyPath"]).returncode: + return 4 + overlay_ready = output / "P04" / "overlay-after-launch.json" + overlay_ready.parent.mkdir(parents=True, exist_ok=True) + for _ in range(30): + if snapshot("KeyPath", overlay_ready).returncode == 0 \ + and "keyboard-overlay" in overlay_ready.read_text(errors="replace"): + break + pause(0.1) + else: + return 4 + clear = run([OSASCRIPT, "-e", 'tell application "TextEdit" to set text of document 1 to ""', + "-e", 'tell application "TextEdit" to activate']) + if clear.returncode: + return 4 + print("physical_remap_session\twaiting") + print("operator_action\thold the physical q key until this command completes") + sys.stdout.flush() + deadline = time.monotonic() + args.timeout_seconds + value = "" + while time.monotonic() < deadline: + value = textedit_value() + if value: + break + pause(0.05) + detected = time.monotonic_ns() + elapsed_ms = round((detected - started) / 1_000_000, 3) + + p02 = output / "P02" + p03 = output / "P03" + p04 = output / "P04" + for directory in (p02, p03, p04): + directory.mkdir(parents=True, exist_ok=True) + (p02 / "textedit-output.txt").write_text(value + "\n") + shutil.copy2(output / "prepare" / "hid-inventory.txt", p02 / "hid-inventory.txt") + shutil.copy2(output / "prepare" / "runtime-ready.tsv", p02 / "runtime-ready.tsv") + timing = {"schemaVersion": 1, "launchToOutputMilliseconds": elapsed_ms, "output": value} + (p04 / "timing.json").write_text(json.dumps(timing, indent=2) + "\n") + overlay_ax = p03 / "overlay-during-physical-q.json" + overlay_capture = snapshot("KeyPath", overlay_ax) + run([str(PEEKABOO_UI), "screenshot", "--app", "KeyPath", "--output", str(p03 / "overlay-during-physical-q.png")]) + snapshot("TextEdit", p02 / "textedit-after.json") + found_key, overlay_pressed = overlay_key_state(overlay_ax) + + p02_passed = value == "w" + if p02_passed: + record(p02, "P02-physical-remap", "passed", + "A guest-visible physical q produced independently observed virtual output w in TextEdit.", + evidence=("hid-inventory.txt", "runtime-ready.tsv", "textedit-output.txt", "textedit-after.json")) + elif not value: + record(p02, "P02-physical-remap", "blocked", "No physical input reached TextEdit before timeout.", + "environment-precondition-failure", "physical-keypress", evidence=("hid-inventory.txt", "runtime-ready.tsv")) + else: + record(p02, "P02-physical-remap", "failed", f"Physical q produced {value!r}, not remapped output 'w'.", + "keypath-product-failure", "virtual-output", evidence=("hid-inventory.txt", "runtime-ready.tsv", "textedit-output.txt", "textedit-after.json")) + + if overlay_capture.returncode == 0 and found_key and overlay_pressed: + record(p03, "P03-overlay-observation", "passed", + "The KeyPath overlay exposed physical Q as pressed during the proven q-to-w event.", + evidence=("overlay-during-physical-q.json", "overlay-during-physical-q.png")) + elif not found_key: + record(p03, "P03-overlay-observation", "failed", "The fresh overlay AX tree lacked physical Q keycap state.", + "harness-selector-failure", "overlay-keycap-selector", evidence=("overlay-during-physical-q.json",)) + else: + record(p03, "P03-overlay-observation", "failed", "The overlay did not report physical Q as pressed during observation.", + "keypath-product-failure", "overlay-input-state", evidence=("overlay-during-physical-q.json", "overlay-during-physical-q.png")) + + if p02_passed: + record(p04, "P04-first-remap-timing", "passed", + f"KeyPath relaunch to the first confirmed physical q-to-w output took {elapsed_ms} ms.", + evidence=("overlay-after-launch.json", "timing.json")) + else: + record(p04, "P04-first-remap-timing", "blocked", "No successful physical remap was available to time.", + "environment-precondition-failure", "p02-output-proof", evidence=("timing.json",)) + print(f"physical_remap_output\t{value or 'timeout'}") + print(f"launch_to_output_ms\t{elapsed_ms}") + print(f"overlay_q_pressed\t{str(overlay_pressed).lower()}") + return 0 if p02_passed and overlay_pressed else 1 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + prepare_parser = subparsers.add_parser("prepare") + prepare_parser.add_argument("--output", required=True, type=pathlib.Path) + prepare_parser.add_argument("--device-match", required=True) + observe_parser = subparsers.add_parser("observe") + observe_parser.add_argument("--output", required=True, type=pathlib.Path) + observe_parser.add_argument("--timeout-seconds", type=int, default=120) + args = parser.parse_args() + if args.command == "prepare": + raise SystemExit(prepare(args)) + if not 1 <= args.timeout_seconds <= 300: + parser.error("--timeout-seconds must be between 1 and 300") + raise SystemExit(observe(args)) + + +if __name__ == "__main__": + main() diff --git a/Scripts/lab/pico-hid-fixture-client b/Scripts/lab/pico-hid-fixture-client new file mode 100755 index 000000000..daed23c79 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture-client @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Compile deterministic text into Pico HID reports and control the KeyPath fixture.""" + +from __future__ import annotations + +import argparse +import hashlib +import hmac +import http.client +import json +import os +import pathlib +import sys +import time +import urllib.error +import urllib.request +import zlib + + +SHIFT = 0x02 +USAGE: dict[str, tuple[int, int]] = {} +for offset, character in enumerate("abcdefghijklmnopqrstuvwxyz"): + USAGE[character] = (0x04 + offset, 0) + USAGE[character.upper()] = (0x04 + offset, SHIFT) +for character, usage in zip("1234567890", range(0x1E, 0x28)): + USAGE[character] = (usage, 0) +for character, shifted, usage in zip("1234567890", "!@#$%^&*()", range(0x1E, 0x28)): + USAGE[shifted] = (usage, SHIFT) +USAGE.update({ + "\n": (0x28, 0), "\t": (0x2B, 0), " ": (0x2C, 0), + "-": (0x2D, 0), "_": (0x2D, SHIFT), "=": (0x2E, 0), "+": (0x2E, SHIFT), + "[": (0x2F, 0), "{": (0x2F, SHIFT), "]": (0x30, 0), "}": (0x30, SHIFT), + "\\": (0x31, 0), "|": (0x31, SHIFT), ";": (0x33, 0), ":": (0x33, SHIFT), + "'": (0x34, 0), '"': (0x34, SHIFT), "`": (0x35, 0), "~": (0x35, SHIFT), + ",": (0x36, 0), "<": (0x36, SHIFT), ".": (0x37, 0), ">": (0x37, SHIFT), + "/": (0x38, 0), "?": (0x38, SHIFT), +}) + + +def compile_text(run_id: str, text: str, interval_ms: int, hold_ms: int, + repeat_count: int, cycle_gap_ms: int, shift_lead_ms: int = 0, + shift_release_lag_ms: int = 0) -> str: + if not run_id or len(run_id) > 48 or any(not (ch.isalnum() or ch in "-_.") for ch in run_id): + raise ValueError("run id must contain 1-48 letters, digits, dots, underscores, or hyphens") + if not text: + raise ValueError("input text is empty") + if not 1 <= repeat_count <= 10_000: + raise ValueError("repeat count must be between 1 and 10000") + if interval_ms < 4: + raise ValueError("report interval must be at least 4 ms for deterministic full-speed USB delivery") + if not 2 <= hold_ms < interval_ms: + raise ValueError("hold duration must be at least 2 ms and shorter than the interval") + if shift_lead_ms < 0 or shift_release_lag_ms < 0: + raise ValueError("Shift lead and release lag cannot be negative") + if shift_lead_ms + hold_ms + shift_release_lag_ms >= interval_ms: + raise ValueError("Shift lead, hold, and release lag must fit inside the character interval") + events: list[str] = [] + at_us = 0 + for index, character in enumerate(text): + try: + usage, modifiers = USAGE[character] + except KeyError as error: + raise ValueError(f"unsupported US keyboard character at offset {index}: {character!r}") from error + key_down_us = at_us + if modifiers and shift_lead_ms: + events.append(f"{at_us} {modifiers} 0 0 0 0 0 0") + key_down_us += shift_lead_ms * 1000 + events.append(f"{key_down_us} {modifiers} {usage} 0 0 0 0 0") + key_up_us = key_down_us + hold_ms * 1000 + if modifiers and shift_release_lag_ms: + events.append(f"{key_up_us} {modifiers} 0 0 0 0 0 0") + events.append(f"{key_up_us + shift_release_lag_ms * 1000} 0 0 0 0 0 0 0") + else: + events.append(f"{key_up_us} 0 0 0 0 0 0 0") + at_us += interval_ms * 1000 + cycle_us = at_us + cycle_gap_ms * 1000 + event_payload = "\n".join(events) + "\n" + crc = zlib.crc32(event_payload.encode("ascii")) & 0xFFFFFFFF + return f"KPHID1 {run_id} {len(events)} {repeat_count} {cycle_us} {crc:08x}\n{event_payload}" + + +class FixtureClient: + def __init__(self, host: str, token: str, port: int = 8080, timeout: float = 10.0): + if not token: + raise ValueError("fixture bearer token is empty") + if timeout <= 0: + raise ValueError("fixture timeout must be positive") + self.base_url = f"http://{host}:{port}" + self.host = host + self.port = port + self.token = token + self.timeout = timeout + + def request(self, method: str, path: str, body: bytes | None = None, + content_type: str = "text/plain; charset=us-ascii", + headers: dict[str, str] | None = None) -> str: + request_headers = {"Authorization": f"Bearer {self.token}"} + if headers: + request_headers.update(headers) + request = urllib.request.Request( + self.base_url + path, + data=body, + method=method, + headers=request_headers, + ) + if body is not None: + request.add_header("Content-Type", content_type) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + return response.read().decode("utf-8") + except urllib.error.HTTPError as error: + detail = error.read().decode("utf-8", errors="replace").strip() + raise RuntimeError(f"fixture returned HTTP {error.code}: {detail}") from error + + def status(self) -> dict[str, object]: + return json.loads(self.request("GET", "/v1/status")) + + def load_script(self, script: str) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/script", script.encode("ascii"))) + + def arm(self, run_id: str) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/arm", run_id.encode("ascii"))) + + def start(self, run_id: str, delay_ms: int) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/start", f"{run_id} {delay_ms}".encode("ascii"))) + + def abort(self) -> dict[str, object]: + return json.loads(self.request("POST", "/v1/abort", b"")) + + def present(self, presentation: dict[str, object]) -> dict[str, object]: + body = json.dumps(presentation, separators=(",", ":")).encode("ascii") + return json.loads(self.request("POST", "/v1/presentation", body, "application/json")) + + def trace(self, start: int = 0, limit: int = 8) -> list[dict[str, object]]: + lines = self.request("GET", f"/v1/trace?from={start}&limit={limit}").splitlines() + return [json.loads(line) for line in lines] + + def trace_all(self, limit: int = 8, retry_seconds: float = 60.0) -> list[dict[str, object]]: + """Fetch the bounded trace over one persistent authenticated connection.""" + if not 1 <= limit <= 8: + raise ValueError("trace page limit must be between 1 and 8") + if retry_seconds <= 0: + raise ValueError("trace retry window must be positive") + connection: http.client.HTTPConnection | None = None + traces: list[dict[str, object]] = [] + start = 0 + available: int | None = None + run_id: str | None = None + try: + while available is None or start < available: + deadline = time.monotonic() + retry_seconds + while True: + try: + if connection is None: + connection = http.client.HTTPConnection( + self.host, self.port, timeout=self.timeout + ) + connection.request( + "GET", f"/v1/trace?from={start}&limit={limit}", + headers={"Authorization": f"Bearer {self.token}", + "Connection": "keep-alive"}, + ) + response = connection.getresponse() + body = response.read().decode("utf-8") + if response.status != 200: + raise RuntimeError( + f"fixture returned HTTP {response.status}: {body.strip()}" + ) + lines = [json.loads(line) for line in body.splitlines()] + break + except (OSError, TimeoutError, http.client.HTTPException, + json.JSONDecodeError) as error: + if connection is not None: + connection.close() + connection = None + if time.monotonic() >= deadline: + raise RuntimeError( + f"trace page {start} unavailable after {retry_seconds:g}s: {error}" + ) from error + time.sleep(0.25) + if not lines or not isinstance(lines[0], dict): + raise RuntimeError("fixture trace returned an invalid page") + header = lines[0] + page_run_id = str(header.get("runId", "")) + if run_id is None: + run_id = page_run_id + elif page_run_id != run_id: + raise RuntimeError("fixture run id changed while fetching trace") + available = int(header.get("available", 0)) + batch = [entry for entry in lines[1:] if isinstance(entry, dict)] + if not batch and start < available: + raise RuntimeError("fixture trace page did not advance") + traces.extend(batch) + start += len(batch) + return traces + finally: + if connection is not None: + connection.close() + + def update_firmware(self, firmware: bytes, expected_build: str, + wait_seconds: float = 90.0) -> dict[str, object]: + if not firmware: + raise ValueError("firmware image is empty") + if not expected_build or len(expected_build) > 32 or not expected_build.isalnum(): + raise ValueError("expected build must contain 1-32 letters or digits") + if wait_seconds <= 0: + raise ValueError("update wait must be positive") + before = self.status() + if before.get("updateReady") is not True: + raise RuntimeError("fixture is not safely idle for a firmware update") + current_slot = before.get("otaSlot") + if current_slot not in ("ota_0", "ota_1"): + raise RuntimeError("fixture needs the one-time USB dual-slot migration before Wi-Fi updates") + target_slot = "ota_1" if current_slot == "ota_0" else "ota_0" + digest = hashlib.sha256(firmware).hexdigest() + signature = hmac.new(self.token.encode("utf-8"), firmware, hashlib.sha256).hexdigest() + try: + response = json.loads(self.request( + "POST", "/v1/firmware", firmware, "application/octet-stream", + {"X-KeyPath-SHA256": digest, "X-KeyPath-HMAC-SHA256": signature, + "X-KeyPath-Expected-Build": expected_build}, + )) + except (urllib.error.URLError, ConnectionError, TimeoutError): + # The board may close the socket while restarting. The build check below is authoritative. + response = {"ok": True, "message": "board disconnected while restarting", "rebooting": True} + + deadline = time.monotonic() + wait_seconds + last_error = "fixture did not reconnect" + while time.monotonic() < deadline: + try: + status = self.status() + if (status.get("build") == expected_build and status.get("wifiConnected") is True and + status.get("displayHealthy") is True and status.get("splashComplete") is True and + status.get("otaSlot") == target_slot and status.get("otaState") == "valid"): + return {"upload": response, "status": status, "verifiedBuild": expected_build, + "previousSlot": current_slot, "verifiedSlot": target_slot} + last_error = (f"fixture reported build {status.get('build', 'unknown')!r}, " + f"slot {status.get('otaSlot', 'unknown')!r}, " + f"state {status.get('otaState', 'unknown')!r}") + except (urllib.error.URLError, ConnectionError, TimeoutError, RuntimeError, json.JSONDecodeError) as error: + last_error = str(error) + time.sleep(1.0) + raise RuntimeError(f"firmware update was not verified: {last_error}") + + +def token_from_environment() -> str: + token = os.environ.get("KEYPATH_FIXTURE_TOKEN", "") + if not token: + raise SystemExit("KEYPATH_FIXTURE_TOKEN is required; do not put the token on the command line") + return token + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument("--port", type=int, default=8080) + root.add_argument("--timeout", type=float, default=10.0) + commands = root.add_subparsers(dest="command", required=True) + commands.add_parser("status") + compile_command = commands.add_parser("compile-text") + compile_command.add_argument("--run-id", required=True) + compile_command.add_argument("--text", required=True, type=pathlib.Path) + compile_command.add_argument("--interval-ms", type=int, default=80) + compile_command.add_argument("--hold-ms", type=int, default=30) + compile_command.add_argument("--repeat", type=int, default=1) + compile_command.add_argument("--cycle-gap-ms", type=int, default=500) + compile_command.add_argument("--shift-lead-ms", type=int, default=0) + compile_command.add_argument("--shift-release-lag-ms", type=int, default=0) + compile_command.add_argument("--output", type=pathlib.Path) + load_command = commands.add_parser("load-script") + load_command.add_argument("script", type=pathlib.Path) + arm_command = commands.add_parser("arm") + arm_command.add_argument("run_id") + start_command = commands.add_parser("start") + start_command.add_argument("run_id") + start_command.add_argument("--delay-ms", type=int, default=2000) + commands.add_parser("abort") + update_command = commands.add_parser("update-firmware", help="install and verify an authenticated OTA image") + update_command.add_argument("firmware", type=pathlib.Path) + update_command.add_argument("--expected-build", required=True) + update_command.add_argument("--wait-seconds", type=float, default=90.0) + trace_command = commands.add_parser("trace") + trace_command.add_argument("--from", dest="start", type=int, default=0) + trace_command.add_argument("--limit", type=int, default=8) + trace_all_command = commands.add_parser("trace-all") + trace_all_command.add_argument("--limit", type=int, default=8) + trace_all_command.add_argument("--retry-seconds", type=float, default=60.0) + present_command = commands.add_parser("present", help="drive the fixture's animated test narrative") + present_command.add_argument("--phase", required=True, + choices=("auto", "preparing", "countdown", "testing", "observing", + "resolving", "result", "next")) + present_command.add_argument("--result", choices=("none", "pass", "fail", "inconclusive"), default="none") + present_command.add_argument("--brand", choices=("none", "keypath", "bear"), default="none") + present_command.add_argument("--progress", type=int, choices=range(0, 1001), default=0, metavar="0..1000") + present_command.add_argument("--title", default="") + present_command.add_argument("--detail", default="") + present_command.add_argument("--next", default="") + present_command.add_argument("--reports-expected", type=int, default=0) + present_command.add_argument("--reports-observed", type=int, default=0) + present_command.add_argument("--dropped", type=int, default=0) + present_command.add_argument("--duplicated", type=int, default=0) + present_command.add_argument("--repeated", type=int, default=0) + present_command.add_argument("--latency-p95-us", type=int, default=0) + present_command.add_argument("--safe-release", action="store_true") + run_command = commands.add_parser("run-text") + run_command.add_argument("--run-id", required=True) + run_command.add_argument("--text", required=True, type=pathlib.Path) + run_command.add_argument("--interval-ms", type=int, default=80) + run_command.add_argument("--hold-ms", type=int, default=30) + run_command.add_argument("--repeat", type=int, default=1) + run_command.add_argument("--cycle-gap-ms", type=int, default=500) + run_command.add_argument("--shift-lead-ms", type=int, default=0) + run_command.add_argument("--shift-release-lag-ms", type=int, default=0) + run_command.add_argument("--delay-ms", type=int, default=2000) + return root + + +def print_json(value: object) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +def main() -> int: + arguments = parser().parse_args() + if arguments.command == "compile-text": + script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, + arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms, + arguments.shift_lead_ms, arguments.shift_release_lag_ms) + if arguments.output: + arguments.output.write_text(script) + print(arguments.output) + else: + sys.stdout.write(script) + return 0 + + client = FixtureClient(arguments.host, token_from_environment(), arguments.port, arguments.timeout) + if arguments.command == "status": print_json(client.status()) + elif arguments.command == "load-script": print_json(client.load_script(arguments.script.read_text())) + elif arguments.command == "arm": print_json(client.arm(arguments.run_id)) + elif arguments.command == "start": print_json(client.start(arguments.run_id, arguments.delay_ms)) + elif arguments.command == "abort": print_json(client.abort()) + elif arguments.command == "update-firmware": + print_json(client.update_firmware(arguments.firmware.read_bytes(), arguments.expected_build, + arguments.wait_seconds)) + elif arguments.command == "trace": print_json(client.trace(arguments.start, arguments.limit)) + elif arguments.command == "trace-all": + print_json(client.trace_all(arguments.limit, arguments.retry_seconds)) + elif arguments.command == "present": + print_json(client.present({ + "phase": arguments.phase, "result": arguments.result, "brand": arguments.brand, + "progress": arguments.progress, + "title": arguments.title, "detail": arguments.detail, "next": arguments.next, + "reportsExpected": arguments.reports_expected, "reportsObserved": arguments.reports_observed, + "dropped": arguments.dropped, "duplicated": arguments.duplicated, + "repeated": arguments.repeated, "latencyP95Us": arguments.latency_p95_us, + "safeRelease": arguments.safe_release, + })) + elif arguments.command == "run-text": + script = compile_text(arguments.run_id, arguments.text.read_text(), arguments.interval_ms, + arguments.hold_ms, arguments.repeat, arguments.cycle_gap_ms, + arguments.shift_lead_ms, arguments.shift_release_lag_ms) + print_json({"load": client.load_script(script), "arm": client.arm(arguments.run_id), + "start": client.start(arguments.run_id, arguments.delay_ms)}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/pico-hid-fixture-control-soak b/Scripts/lab/pico-hid-fixture-control-soak new file mode 100755 index 000000000..3154fdcf3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture-control-soak @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Measure the ESP fixture control plane without sending any HID reports.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import importlib.machinery +import importlib.util +import json +import os +import pathlib +import socket +import statistics +import sys +import time +import urllib.error + + +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +CLIENT_PATH = SCRIPT_DIR / "pico-hid-fixture-client" + + +def load_client_module(): + loader = importlib.machinery.SourceFileLoader("keypath_fixture_client", str(CLIENT_PATH)) + spec = importlib.util.spec_from_loader(loader.name, loader) + if spec is None: + raise RuntimeError("could not load fixture client") + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def atomic_write(path: pathlib.Path, value: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + temporary.chmod(0o600) + os.replace(temporary, path) + + +def percentile(values: list[float], fraction: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + index = min(len(ordered) - 1, int((len(ordered) - 1) * fraction)) + return ordered[index] + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + root.add_argument("--host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + root.add_argument("--port", type=int, default=8080) + root.add_argument("--requests", type=int, default=120) + root.add_argument("--interval-ms", type=int, default=100) + root.add_argument("--timeout", type=float, default=1.5) + root.add_argument("--max-p95-ms", type=float, default=750.0) + root.add_argument("--max-consecutive-failures", type=int, default=3) + root.add_argument("--output", type=pathlib.Path) + return root + + +def main() -> int: + arguments = parser().parse_args() + if not 1 <= arguments.requests <= 10_000: + raise SystemExit("--requests must be between 1 and 10000") + if (arguments.interval_ms < 0 or arguments.timeout <= 0 or arguments.max_p95_ms <= 0 or + arguments.max_consecutive_failures < 1): + raise SystemExit("interval and timeout must not be negative") + token = os.environ.get("KEYPATH_FIXTURE_TOKEN", "") + if not token: + raise SystemExit("KEYPATH_FIXTURE_TOKEN is required") + + output = arguments.output + if output is None: + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + output = (pathlib.Path.home() / ".local/state/keypath-hid-fixture/control" / + f"control-soak-{stamp}.json") + client_module = load_client_module() + started_at = utc_now() + discovery_started = time.monotonic() + resolution_error = "" + try: + resolved_host = socket.getaddrinfo( + arguments.host, arguments.port, socket.AF_INET, socket.SOCK_STREAM + )[0][4][0] + except OSError as error: + resolved_host = "" + resolution_error = str(error)[:240] + discovery_ms = (time.monotonic() - discovery_started) * 1000.0 + client = (client_module.FixtureClient(resolved_host, token, arguments.port, arguments.timeout) + if resolved_host else None) + latencies_ms: list[float] = [] + failures: list[dict[str, object]] = [] + first_status: dict[str, object] | None = None + last_status: dict[str, object] | None = None + consecutive_failures = 0 + if resolution_error: + failures.append({"request": 0, "error": f"host resolution failed: {resolution_error}"}) + for index in range(arguments.requests if client else 0): + started = time.monotonic() + try: + status = client.status() + latency_ms = (time.monotonic() - started) * 1000.0 + latencies_ms.append(latency_ms) + if first_status is None: + first_status = status + last_status = status + consecutive_failures = 0 + except (OSError, RuntimeError, ValueError, json.JSONDecodeError, + urllib.error.URLError) as error: + failures.append({"request": index + 1, "error": str(error)[:240]}) + consecutive_failures += 1 + if consecutive_failures >= arguments.max_consecutive_failures: + break + if index + 1 < arguments.requests and arguments.interval_ms: + time.sleep(arguments.interval_ms / 1000.0) + + resets_observed = bool(first_status and last_status and + last_status.get("diagnostics", {}).get("uptimeMs", 0) < + first_status.get("diagnostics", {}).get("uptimeMs", 0)) + build_changed = bool(first_status and last_status and + first_status.get("build") != last_status.get("build")) + p95_ms = percentile(latencies_ms, 0.95) if latencies_ms else 0.0 + reasons: list[str] = [] + if failures: + reasons.append("request failures") + if len(latencies_ms) != arguments.requests: + reasons.append("request count incomplete") + if p95_ms > arguments.max_p95_ms: + reasons.append("p95 latency exceeded budget") + if resets_observed: + reasons.append("fixture reset observed") + if build_changed: + reasons.append("firmware build changed") + status = "pass" if not reasons else "fail" + artifact: dict[str, object] = { + "schemaVersion": 1, + "kind": "keypath-fixture-control-soak", + "status": status, + "startedAt": started_at, + "endedAt": utc_now(), + "target": { + "requestedHost": arguments.host, + "resolvedHost": resolved_host or None, + "port": arguments.port, + "discoveryMs": round(discovery_ms, 3), + }, + "requests": { + "attempted": arguments.requests, + "succeeded": len(latencies_ms), + "failed": len(failures), + "intervalMs": arguments.interval_ms, + "timeoutSeconds": arguments.timeout, + "maxConsecutiveFailures": arguments.max_consecutive_failures, + }, + "latencyMs": { + "minimum": round(min(latencies_ms), 3) if latencies_ms else None, + "median": round(statistics.median(latencies_ms), 3) if latencies_ms else None, + "p95": round(p95_ms, 3) if latencies_ms else None, + "maximum": round(max(latencies_ms), 3) if latencies_ms else None, + "p95Budget": arguments.max_p95_ms, + }, + "failureReasons": reasons, + "continuity": {"resetObserved": resets_observed, "buildChanged": build_changed}, + "firstDiagnostics": first_status.get("diagnostics") if first_status else None, + "lastDiagnostics": last_status.get("diagnostics") if last_status else None, + "failures": failures, + } + atomic_write(output, artifact) + print(json.dumps({ + "status": status, + "requests": arguments.requests, + "failures": len(failures), + "p95Ms": artifact["latencyMs"]["p95"], + "artifact": str(output), + }, indent=2, sort_keys=True)) + return 0 if status == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/lab/pico-hid-fixture-tool b/Scripts/lab/pico-hid-fixture-tool new file mode 100755 index 000000000..60d441202 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture-tool @@ -0,0 +1,307 @@ +#!/bin/bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +fixture_root="$script_dir/pico-hid-fixture" +target="$fixture_root/targets/waveshare-esp32-s3-touch-lcd-1.69" +idf_path=${KEYPATH_FIXTURE_IDF_PATH:-"$HOME/.cache/keypath-esp32/esp-idf"} +build_dir=${KEYPATH_FIXTURE_BUILD_DIR:-"$HOME/.cache/keypath-esp32/keypath-fixture-build"} +sdkconfig=${KEYPATH_FIXTURE_SDKCONFIG:-"$HOME/.cache/keypath-esp32/keypath-fixture-sdkconfig-ota-v2"} +secrets_file=${KEYPATH_FIXTURE_SECRETS_FILE:-"$HOME/dotfiles/secrets.env"} +device_dir=${KEYPATH_FIXTURE_DEVICE_DIR:-/dev} +add_secret_app=${KEYPATH_FIXTURE_ADD_SECRET_APP:-"/Applications/Add Secret.app"} +fixture_client=${KEYPATH_FIXTURE_CLIENT:-"$script_dir/pico-hid-fixture-client"} + +required_secrets="KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 KEYPATH_HACKER_DOJO_USERNAME KEYPATH_HACKER_DOJO_PASSWORD KEYPATH_FIXTURE_TOKEN" + +usage() { + printf '%s\n' \ + "Usage: Scripts/lab/pico-hid-fixture-tool COMMAND [OPTIONS]" \ + "" \ + "Commands:" \ + " configure Securely add any missing fixture credentials" \ + " doctor Check toolchain, credentials, and connected board" \ + " test Run host, campaign, and ESP32 QEMU tests" \ + " build Build production firmware with real credentials" \ + " flash [--port DEV] Build and flash the connected Waveshare board" \ + " install [--port DEV] Run checks, tests, build, flash, and health check" \ + " update Build, install, and verify firmware over authenticated Wi-Fi OTA" \ + " status Query the running fixture over Wi-Fi" \ + " soak [OPTIONS] Measure status reliability without sending HID reports" \ + "" \ + "The fixture token is read from the environment or sops; never pass it on the command line." +} + +fail() { + printf 'fixture-tool: %s\n' "$*" >&2 + exit 1 +} + +have_secret() { + secret_name=$1 + secret_value=$(printenv "$secret_name" 2>/dev/null || true) + [[ -n "$secret_value" ]] +} + +load_credentials() { + missing=0 + for secret_name in $required_secrets; do + if ! have_secret "$secret_name"; then + missing=1 + break + fi + done + if [[ "$missing" -eq 0 ]]; then + return + fi + command -v sops >/dev/null 2>&1 || fail "sops is required to read $secrets_file" + [[ -f "$secrets_file" ]] || fail "encrypted secrets file is missing: $secrets_file" + while IFS='=' read -r secret_name secret_value; do + case "$secret_name" in + KEYPATH_WIFI_SSID_1|KEYPATH_WIFI_PASSWORD_1|KEYPATH_WIFI_SSID_3|KEYPATH_WIFI_PASSWORD_3|KEYPATH_WIFI_SSID_4|KEYPATH_WIFI_PASSWORD_4|KEYPATH_HACKER_DOJO_USERNAME|KEYPATH_HACKER_DOJO_PASSWORD|KEYPATH_FIXTURE_TOKEN) + printf -v "$secret_name" '%s' "$secret_value" + export "$secret_name" + ;; + esac + done < <(sops -d "$secrets_file") +} + +validate_credentials() { + load_credentials + for secret_name in $required_secrets; do + have_secret "$secret_name" || fail "$secret_name is not configured; run '$0 configure'" + secret_value=$(printenv "$secret_name") + case "$secret_value" in + *placeholder*|*PLACEHOLDER*) fail "$secret_name still contains a placeholder" ;; + esac + done + [[ ${#KEYPATH_FIXTURE_TOKEN} -ge 16 ]] || fail "KEYPATH_FIXTURE_TOKEN must contain at least 16 characters" +} + +idf_activate() { + [[ -f "$idf_path/export.sh" ]] || fail "ESP-IDF is missing at $idf_path" + if [[ -z "${IDF_PYTHON_ENV_PATH:-}" ]]; then + for candidate in "$HOME"/.espressif/python_env/idf5.5_py*_env; do + if [[ -x "$candidate/bin/python" ]]; then + IDF_PYTHON_ENV_PATH=$candidate + export IDF_PYTHON_ENV_PATH + break + fi + done + fi + # shellcheck disable=SC1090 + source "$idf_path/export.sh" >/dev/null +} + +port_candidates() { + find "$device_dir" -maxdepth 1 -type c \( \ + -name 'cu.usbmodem*' -o -name 'cu.wchusbserial*' -o -name 'cu.SLAB_USBtoUART*' \ + \) -print 2>/dev/null | sort +} + +detect_port() { + requested=${1:-} + if [[ -n "$requested" ]]; then + [[ -c "$requested" ]] || fail "serial device does not exist: $requested" + printf '%s\n' "$requested" + return + fi + candidates=$(port_candidates) + count=$(printf '%s\n' "$candidates" | awk 'NF { count++ } END { print count + 0 }') + if [[ "$count" -eq 0 ]]; then + fail "no ESP32 serial port found; connect the board, hold BOOT, tap RESET, release BOOT, then retry" + fi + if [[ "$count" -gt 1 ]]; then + printf 'fixture-tool: multiple serial devices found:\n%s\n' "$candidates" >&2 + fail "retry with --port DEVICE" + fi + printf '%s\n' "$candidates" +} + +configure_secret() { + secret_name=$1 + [[ -d "$add_secret_app" ]] || fail "Add Secret.app is missing: $add_secret_app" + printf 'Add %s in the secure window, then press Enter.\n' "$secret_name" + open -W -n "$add_secret_app" --args --key "$secret_name" + result_file="$HOME/.config/add-secret/result.json" + [[ -f "$result_file" ]] || fail "Add Secret.app did not write a result" + python3 - "$result_file" "$secret_name" <<'PY' +import json +import pathlib +import sys + +result = json.loads(pathlib.Path(sys.argv[1]).read_text()) +if result.get("status") != "success" or result.get("key") != sys.argv[2]: + raise SystemExit(f"Add Secret.app did not save {sys.argv[2]}: {result.get('status', 'unknown')}") +PY +} + +command_configure() { + for secret_name in $required_secrets; do + if grep -q "^${secret_name}=" "$secrets_file" 2>/dev/null; then + printf 'configured %s\n' "$secret_name" + else + configure_secret "$secret_name" + fi + done + validate_credentials + printf 'credentials configured securely\n' +} + +command_doctor() { + host_os=${KEYPATH_FIXTURE_HOST_OS:-$(uname -s)} + [[ "$host_os" == Darwin ]] || fail "flashing is supported from macOS" + [[ -d "$target" ]] || fail "firmware target is missing: $target" + [[ -f "$idf_path/export.sh" ]] || fail "ESP-IDF 5.5 toolchain is missing: $idf_path" + command -v cmake >/dev/null 2>&1 || fail "cmake is missing" + command -v ninja >/dev/null 2>&1 || fail "ninja is missing" + command -v python3 >/dev/null 2>&1 || fail "python3 is missing" + validate_credentials + printf 'ok macOS host\n' + printf 'ok ESP-IDF toolchain\n' + printf 'ok production credentials (values hidden)\n' + candidates=$(port_candidates) + if [[ -n "$candidates" ]]; then + printf 'ok serial device\n%s\n' "$candidates" + else + printf 'wait board not connected (expected until hardware arrives)\n' + fi +} + +command_test() { + "$fixture_root/tests/run-tests.sh" + python3 "$script_dir/tests/pico-hid-fixture-client-tests.py" + python3 "$script_dir/tests/pico-hid-fixture-tool-tests.py" + python3 "$script_dir/tests/pico-hid-fixture-control-soak-tests.py" + python3 "$script_dir/tests/scenario-matrix-runner-tests.py" + python3 "$script_dir/tests/physical-hid-capture-run-tests.py" + python3 "$script_dir/tests/physical-hid-shift-matrix-tests.py" + python3 "$script_dir/tests/physical-hid-repeat-matrix-tests.py" + python3 "$script_dir/tests/hid-capture-jig-tool-tests.py" + "$fixture_root/tests/run-esp32-qemu-smoke.sh" +} + +command_build() { + validate_credentials + idf_activate + idf.py -C "$target" -B "$build_dir" -D "SDKCONFIG=$sdkconfig" build + [[ -f "$build_dir/keypath_esp32_s3_hid_fixture.bin" ]] || fail "build completed without a firmware binary" + printf 'firmware %s\n' "$build_dir/keypath_esp32_s3_hid_fixture.bin" +} + +parse_port() { + selected_port= + while [[ $# -gt 0 ]]; do + case "$1" in + --port) + [[ $# -ge 2 ]] || fail "--port requires a device" + selected_port=$2 + shift 2 + ;; + *) fail "unknown option: $1" ;; + esac + done +} + +command_flash() { + parse_port "$@" + command_build + port=$(detect_port "$selected_port") + idf.py -C "$target" -B "$build_dir" -p "$port" flash + printf 'flashed %s\n' "$port" + printf 'next watch the display for JOINING LAB, then READY\n' +} + +command_status() { + validate_credentials + "$fixture_client" status +} + +command_soak() { + validate_credentials + "$script_dir/pico-hid-fixture-control-soak" "$@" +} + +firmware_build_id() { + generated_config="$build_dir/esp-idf/main/generated/fixture_config.h" + [[ -f "$generated_config" ]] || fail "generated build identity is missing: $generated_config" + build_id=$(sed -n 's/^#define KEYPATH_FIXTURE_BUILD_ID "\([[:alnum:]]*\)"$/\1/p' "$generated_config") + [[ -n "$build_id" ]] || fail "could not read the expected build identity" + printf '%s\n' "$build_id" +} + +command_update() { + command_build + expected_build=$(firmware_build_id) + firmware="$build_dir/keypath_esp32_s3_hid_fixture.bin" + current_status=$(command_status) || fail "fixture is not reachable; use USB flash recovery" + python3 - "$current_status" <<'PY' || fail "fixture is not safely idle; abort or finish the current test before updating" +import json +import sys + +status = json.loads(sys.argv[1]) +if not status.get("updateReady"): + raise SystemExit(1) +if not str(status.get("otaSlot", "")).startswith("ota_"): + raise SystemExit(1) +PY + printf 'updating build %s over authenticated Wi-Fi\n' "$expected_build" + # A production image is about 1.3 MB. The ESP32 writes and hashes it while + # receiving, so the generic 10-second control request timeout can close the + # socket mid-image on a healthy but busy board. + "$fixture_client" --timeout 120 update-firmware "$firmware" \ + --expected-build "$expected_build" --wait-seconds 90 + printf 'verified OTA build %s is healthy over Wi-Fi\n' "$expected_build" +} + +command_install() { + parse_port "$@" + command_doctor + command_test + if [[ -n "$selected_port" ]]; then + command_flash --port "$selected_port" + else + command_flash + fi + expected_build=$(firmware_build_id) + printf 'waiting for Wi-Fi health check' + attempt=0 + while [[ "$attempt" -lt 30 ]]; do + if installed_status=$(command_status 2>/dev/null) && + python3 - "$installed_status" "$expected_build" <<'PY' +import json +import sys + +status = json.loads(sys.argv[1]) +raise SystemExit(0 if status.get("build") == sys.argv[2] and + str(status.get("otaSlot", "")).startswith("ota_") else 1) +PY + then + printf '\ninstallation verified over Wi-Fi\n' + printf '%s\n' "$installed_status" + return + fi + printf '.' + sleep 2 + attempt=$((attempt + 1)) + done + printf '\n' >&2 + fail "firmware flashed but Wi-Fi health check failed; read the on-screen state and run '$0 status'" +} + +command=${1:-} +[[ -n "$command" ]] || { usage; exit 2; } +shift +case "$command" in + configure) [[ $# -eq 0 ]] || fail "configure takes no options"; command_configure ;; + doctor) [[ $# -eq 0 ]] || fail "doctor takes no options"; command_doctor ;; + test) [[ $# -eq 0 ]] || fail "test takes no options"; command_test ;; + build) [[ $# -eq 0 ]] || fail "build takes no options"; command_build ;; + flash) command_flash "$@" ;; + install) command_install "$@" ;; + update) [[ $# -eq 0 ]] || fail "update takes no options"; command_update ;; + status) [[ $# -eq 0 ]] || fail "status takes no options"; command_status ;; + soak) command_soak "$@" ;; + help|-h|--help) usage ;; + *) usage >&2; fail "unknown command: $command" ;; +esac diff --git a/Scripts/lab/pico-hid-fixture/.gitignore b/Scripts/lab/pico-hid-fixture/.gitignore new file mode 100644 index 000000000..b4a02542d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/.gitignore @@ -0,0 +1,2 @@ +build/ +*.uf2 diff --git a/Scripts/lab/pico-hid-fixture/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/CMakeLists.txt new file mode 100644 index 000000000..600d5ee95 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/CMakeLists.txt @@ -0,0 +1,66 @@ +cmake_minimum_required(VERSION 3.13) + +if(NOT DEFINED PICO_SDK_PATH) + if(DEFINED ENV{PICO_SDK_PATH}) + set(PICO_SDK_PATH "$ENV{PICO_SDK_PATH}") + else() + message(FATAL_ERROR "Set PICO_SDK_PATH to a Raspberry Pi Pico SDK checkout") + endif() +endif() + +set(PICO_BOARD pico2_w CACHE STRING "Pico board") +include(${PICO_SDK_PATH}/external/pico_sdk_import.cmake) + +project(keypath_pico_hid_fixture C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) +pico_sdk_init() + +foreach(secret_name KEYPATH_WIFI_SSID KEYPATH_WIFI_PASSWORD KEYPATH_FIXTURE_TOKEN) + if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") + message(FATAL_ERROR "${secret_name} must be supplied in the build environment") + endif() + set(secret_value "$ENV{${secret_name}}") + string(REPLACE "\\" "\\\\" secret_value "${secret_value}") + string(REPLACE "\"" "\\\"" secret_value "${secret_value}") + set(${secret_name}_ESCAPED "${secret_value}") +endforeach() + +string(LENGTH "$ENV{KEYPATH_FIXTURE_TOKEN}" fixture_token_length) +if(fixture_token_length LESS 16) + message(FATAL_ERROR "KEYPATH_FIXTURE_TOKEN must contain at least 16 characters") +endif() + +configure_file(src/fixture_config.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/fixture_config.h @ONLY) + +add_executable(keypath_pico_hid_fixture + src/fixture_core.c + src/fixture_demo.c + src/http_server.c + src/main.c + src/usb_descriptors.c +) + +target_include_directories(keypath_pico_hid_fixture PRIVATE + src + ${CMAKE_CURRENT_BINARY_DIR}/generated +) + +target_compile_definitions(keypath_pico_hid_fixture PRIVATE + KEYPATH_FIXTURE_FIRMWARE_VERSION="0.1.0" +) + +target_compile_options(keypath_pico_hid_fixture PRIVATE -Wall -Wextra -Werror) +target_link_libraries(keypath_pico_hid_fixture PRIVATE + pico_stdlib + pico_unique_id + pico_cyw43_arch_lwip_poll + pico_lwip_mdns + tinyusb_device + tinyusb_board + hardware_watchdog +) + +pico_enable_stdio_usb(keypath_pico_hid_fixture 0) +pico_enable_stdio_uart(keypath_pico_hid_fixture 0) +pico_add_extra_outputs(keypath_pico_hid_fixture) diff --git a/Scripts/lab/pico-hid-fixture/README.md b/Scripts/lab/pico-hid-fixture/README.md new file mode 100644 index 000000000..6eb2c7a39 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/README.md @@ -0,0 +1,470 @@ +# KeyPath physical HID fixture + +This lab-only firmware turns a Wi-Fi microcontroller into a deterministic USB keyboard. The VM +owns the fixture's USB device; the Mac mini uses the independent Wi-Fi control plane to load, arm, +start, abort, and inspect locally timed HID scripts. + +The fixture is deliberately not part of any KeyPath product target. + +## First board: one-command install from a Mac + +The checked-in `pico-hid-fixture-tool` owns setup, tests, builds, flashing, and the first Wi-Fi +health check. Before the board arrives, configure the four Wi-Fi profiles and control +token without putting their values in a shell history or chat: + +```bash +Scripts/lab/pico-hid-fixture-tool configure +Scripts/lab/pico-hid-fixture-tool doctor +``` + +`configure` opens Add Secret.app once for each missing value and stores it through sops. A doctor +result of `wait board not connected` is healthy before the hardware arrives. When the board is +connected tomorrow, the complete path is: + +```bash +Scripts/lab/pico-hid-fixture-tool install +``` + +That command validates the Mac and credentials, runs the host and QEMU suites, builds production +firmware, detects an unambiguous serial port, flashes it, and waits for the authenticated Wi-Fi +status endpoint. If no port appears, hold **BOOT**, tap **RESET**, then release **BOOT** and rerun; +use `install --port /dev/cu...` only when more than one serial device is connected. The exact +hands-on checklist and failure routing are in +[`docs/testing/keypath-hid-fixture-first-board.md`](../../../docs/testing/keypath-hid-fixture-first-board.md). +The operator-facing states, dual-core allocation, UX acceptance criteria, and deferred refinements +are in +[`docs/testing/keypath-hid-fixture-readiness.md`](../../../docs/testing/keypath-hid-fixture-readiness.md). + +## Hardware targets + +- **Primary:** Waveshare ESP32-S3-Touch-LCD-1.69 (240×280 display, capacitive touch, buzzer, + function button, 8 MB PSRAM, and 16 MB flash) +- **Legacy:** Raspberry Pi Pico 2 W +- One data-capable USB cable to the Mac mini +- Lab Wi-Fi access + +The Waveshare board needs no shield, speaker, external debugger, power supply, or second +microcontroller. Its display presents the run state and timing pressure; touch or the physical +button aborts an ordinary armed/running script, and its buzzer provides sparse transition cues. +The top power button also arms a bounded offline demo while the fixture is terminal; tapping the +screen is the separate confirmation that starts it. Pico 2 W uses its onboard green LED instead. + +On a cold boot, the display briefly presents the official Hacker Dojo torii mark before dissolving +into the live KeyPath startup scene. The mark is rendered from lightweight LVGL vector primitives, +so the splash needs no image decoder and does not delay USB, Wi-Fi, or the HID executor. + +## Safety model + +- USB exposes only a standard boot-keyboard HID interface. There is no USB serial control path for + the VM to confuse with the input oracle. +- Every cycle must end with a full all-keys-released report. +- Boot, arm, remote abort, watchdog recovery, and USB reconnect all queue an all-keys-released + report before further input. +- A running script fails closed if USB is removed. +- Scripts are bounded by event, repeat, duration, request-size, and trace limits. +- Uploaded event bytes are CRC32-verified before the fixture can arm. +- HTTP endpoints require a bearer token. Credentials are build inputs and are never committed. +- Firmware updates are accepted only while the fixture is safely idle, are authenticated with a + token-bound HMAC-SHA256, and are SHA-256 checked before ESP-IDF validates the application image. +- Two application slots preserve the currently working image. A newly installed image must bring + its Wi-Fi control plane up within 60 seconds or the bootloader rolls back automatically. +- Control traffic is HTTP rather than TLS, so operate it only on the isolated, WPA2-protected lab + Wi-Fi; the bearer token is defense in depth, not a substitute for network isolation. +- The fixture schedules every report locally. Wi-Fi jitter can shift the start acknowledgement but + cannot alter inter-key timing after the script starts. +- The built-in demo requires two deliberate actions—top power, then touch—and uses a fixed, + compiled-in `KeyPath demo OK` sequence. Once armed, it does not abort if Wi-Fi disconnects. It + reports `HID SENT / CHECK JIG`, never an invented pass; only the independent Jig can classify it. +- The deterministic physical timing contract starts at a 4 ms character interval with a 2 ms key + hold. That is already a 250 Hz key cadence. A physical 3 ms diagnostic preserved output but + missed USB deadlines because its alternating 2 ms/1 ms press-release gaps leave no scheduling + margin on the full-speed interrupt endpoint, so the compiler rejects intervals below 4 ms. +- Combined physical runs are strict by default: any report more than 1 ms late fails the run. A + load case may opt into both `--max-late-reports` and `--max-lateness-us` to classify bounded + timing pressure separately from correctness. Exact output, report count, ordering, and + all-keys-released remain mandatory regardless of that budget. +- On ESP32-S3, the HID scheduler owns core 1 at high priority. USB service, network control, sound, + and display work remain on core 0. The motion governor drops the display from 30 to 20 to 8 FPS + and removes expensive particle layers before animation can compete with HID timing. +- Production exposes HID only—no USB serial console—so the VM observes the same device shape used + by the test. The screen reports Wi-Fi/IP/USB state, while authenticated `/v1/status` and + `/v1/trace` provide the deeper diagnostics and exact firmware build identifier. Status also + reports uptime/reset, heap, Wi-Fi reconnect, HTTP restart/request, and handler-latency counters. + +## 30-second offline demo + +The showroom path does not compile the Jig or depend on Wi-Fi after arming. From the KeyPath +worktree, run: + +```bash +Scripts/lab/hid-capture-jig-tool demo +``` + +The command reuses the signed app when its source hash is unchanged, brings the Jig forward, arms +the exact expected text, and puts `PRESS TOP POWER, THEN TAP DEVICE` in the Jig itself. Press the +board's top power button once, then tap the display. The command returns the Jig's exact pass/fail +within 30 seconds and the Jig writes its normal evidence artifact. BOOT or another screen tap while +the demo is running remains a fail-closed abort. + +This is the presentation path, not a replacement for the strict matrix runner. It demonstrates a +known physical HID sequence quickly; product conclusions still require correlated fixture trace, +Jig, resource, Kanata, and VirtualHID evidence. + +## Pico LED states + +| State | Onboard LED | +|---|---| +| Wi-Fi disconnected | repeating double blink | +| Wi-Fi connected / idle | brief heartbeat | +| Script loaded | double blink | +| Armed | solid | +| Running | fast blink | +| Complete | repeating triple blink | +| Error | rapid blink | + +## Waveshare ESP32-S3 manual build + +ESP-IDF 5.5.5 and its Python tools are installed locally under +`~/.cache/keypath-esp32/esp-idf`. The target uses Waveshare's official board component and LVGL. +Build credentials are supplied only through the environment: + +```bash +source ~/.cache/keypath-esp32/esp-idf/export.sh +export KEYPATH_WIFI_SSID_1='Alpern-Home-5G' +export KEYPATH_WIFI_PASSWORD_1='...' +export KEYPATH_WIFI_SSID_3='iPhone' +export KEYPATH_WIFI_PASSWORD_3='...' +export KEYPATH_WIFI_SSID_4='beachFi' +export KEYPATH_WIFI_PASSWORD_4='...' +export KEYPATH_HACKER_DOJO_USERNAME='...' +export KEYPATH_HACKER_DOJO_PASSWORD='...' +export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' +idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 build +``` + +The fixture tries `Alpern-Home-5G` first, `Hacker Dojo` second, `beachFi` third, and `iPhone` fourth. +`Hacker Dojo` is a WPA2-Enterprise PEAP profile; the other three use WPA2-Personal. The enterprise +username and password must be stored through Add Secret rather than checked into the repository. +This personal-fixture prototype does not yet pin the enterprise authentication server's CA +certificate; add certificate validation before distributing the fixture or its credentials. + +The board revision defaults to 2, whose buzzer is on GPIO42. Revision 1 used GPIO33; change +`KeyPath fixture → Waveshare board revision` with `idf.py menuconfig` if the delivered board is an +older revision. Flashing waits for the physical board: + +```bash +idf.py -C Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69 -p PORT flash +``` + +Prefer `pico-hid-fixture-tool install` for normal use. The manual commands remain documented for +toolchain debugging and CI reproduction. + +The on-device scene is continuously alive: a breathing reactor, orbiting HID packets, a live +progress arc, and time-based motion remain smooth even when the frame rate changes. Preparing, +countdown, typing, observation, resolution, pass, fail, and inconclusive phases morph through +colored icons instead of hard cuts. A visible `HID PRIORITY` mode keeps a restrained orbit at 8 FPS +while shedding most particles, so the display still communicates life without competing with +keyboard delivery. + +Secure OTA uses the same KeyPath visual language as the host capture Jig: the opened amber keycap, +illuminated center, and four-point glint. The key light fills from the authenticated image's real +receive/validation progress; the orbiting glint communicates liveness without implying extra +progress. + +The device UI uses LVGL's native Montserrat bitmaps at 12, 14, 16, 20, and 24 px rather than scaling +a single size. Small telemetry stays crisp, the 24 px state and splash wordmark carry the hierarchy, +and icons retain the symbol-complete 20 px cut. These fonts are compiled into flash and require no +runtime rasterization, allocation, or work on the HID core. + +The host Jig keeps product and tool identity separate. Its in-window header bundles and renders the +real KeyPath application artwork, while its Dock/Finder icon depicts a physical test fixture—base +plate, opposing clamps, test key, and measurement probe—and does not reuse the KeyPath app icon. + +## Host capture Jig resource preflight + +### Foreground ownership requirement + +A physical completion run needs exclusive keyboard focus on the macOS desktop under test. USB HID +reports are delivered to the foreground application; they cannot be addressed to a background Jig. +The runner therefore leaves the Jig unfocused during resource checks, script compilation, and fixture +setup, then focuses it immediately before the atomic arm step. Losing focus after arming invalidates +the run. Do not type or switch applications until the runner returns. + +This does not require an otherwise empty Mac. Normal background services are allowed when the +resource gate accepts them, and deliberate load cases add their own bounded workers after arming. +It does require reserving the active desktop for the test. For unattended runs while the host remains +usable, pass the fixture USB device through to a dedicated macOS VM and keep the Jig focused inside +that guest; retain a smaller bare-metal acceptance set because VM USB scheduling is not identical to +physical macOS. + +The focused Shift diagnostic is split into a short admission run and a full matrix: + +```bash +# About 10 minutes including admission, evidence inspection, and one retry allowance. +Scripts/lab/physical-hid-shift-matrix \ + --lead-values 0,4,8 --release-values 0 --repeat 2 + +# Reserve 20-30 minutes. Runs the complete 5x5 lead/release grid. +Scripts/lab/physical-hid-shift-matrix +``` + +The matrix waits up to 60 seconds for a stable Jig resource window before each cell. It does not +focus the Jig while waiting. Each artifact combines exact expected/received text, focus and release +state, macOS event history, fixture timing totals, and the bounded ESP32 report trace. + +The native AppKit HID Capture Jig samples the Mac once per second and will not arm until three +consecutive checks are inside the baseline capture envelope. It evaluates instantaneous CPU use, +one-minute runnable load normalized per logical core, reclaimable memory, macOS memory pressure, +thermal state, and an extreme system-thread ceiling. Current thresholds are centralized and tested +in `hid-capture-jig/Sources/HIDCaptureCore/SystemReadiness.swift`: + +- CPU below 80% +- normalized load below 0.9× per logical core +- at least 2 GB or 8% of physical memory reclaimable, whichever is larger +- normal macOS memory pressure +- no serious/critical thermal state +- fewer than 900 system threads per logical core + +While blocked, the Jig remains idle and shows `PAUSED · MAC BUSY`, live measurements, the specific +reason, and the best first operator action. The file-RPC response contains the same structured +`systemReadiness` assessment. `physical-hid-capture-run` checks it before loading or arming the +fixture, so a busy host cannot mutate the USB test state and then fail late. The Jig rechecks +automatically; no app or machine restart is required. + +During capture, every real key-down is also rendered as a labeled KeyPath keycap at a shared focal +point. The face compresses immediately, then joins a short, fanned stack of the most recent presses; +dense input widens the fan and raises the live burst count without drawing a full keyboard. The model +uses capture timestamps, caps the visible stack at ten keycaps, and gives faster-than-display bursts a +36 ms minimum presentation cadence so each genuine key-down remains perceptible and in order. Each cap +ages out in 900 ms, while the complete, unmodified event ledger remains in the JSON artifact. Reduce +Motion removes travel, compression, and paced playback while preserving live labels and evidence. + +The same AppKit app also has a nonactivating **Typover · Bear Physical HID** monitor mode. In this +mode Bear—not the Jig—must remain frontmost, so the Jig cannot observe keyboard events directly. It +instead animates the ESP32 schedule supplied by the Typover harness and labels that animation +`SCHEDULED ESP32 KEYCAP STACK`. Corrections and misses appear only after the harness verifies the exact +Bear range against Typover's unified-log evidence. This keeps the useful rapid-keypress visualization +without presenting scheduled input as captured input. + +Bear tests use explicit identity on both displays. The AppKit monitor loads Bear's installed macOS app +icon and gives the Bear wordmark primary placement above `TYPOVER · PHYSICAL HID TEST`. The fixture +presentation protocol carries `brand: bear`; the ESP32 then switches from its generic HID-oracle scene +to a coral Bear face, `BEAR / TYPOVER TEST` header, and Bear-specific preparation, typing, verification, +and result titles. The brand value is also returned in the fixture status JSON so artifacts can prove +which presentation mode was active. A firmware build verifies the LVGL scene compiles, but the physical +LCD color, alignment, and legibility must still be checked on the connected board. + +KeyPath physical HID runs likewise send `brand: keypath` by default. The ESP32 keeps the rotating +orbit and progress arcs visible while placing KeyPath's opened, illuminated orange keycap mark in +their center. The shared runner accepts `--brand keypath|bear` for explicit target selection and +records the chosen value as `presentationBrand` in each evidence artifact. + +Bear mode uses its own semantic palette on both displays: coral means Bear identity, focus, and active +testing; mint means verified pass; amber means safe misses or review; and violet means failure or lost +focus. Red is deliberately reserved for Bear's identity rather than reused as an error signal. Every +non-active state also has an explicit text label and result icon so color is never the only evidence. + +The local file-RPC client exposes the monitor independently of a physical run: + +```bash +Scripts/lab/hid-capture-jig-tool open +Scripts/lab/hid-capture-jig-client bear-prepare \ + --run-id preview --case-count 4 --message "Focus the disposable Bear note" +``` + +`bear-begin` starts a scheduled-key presentation, `bear-update` supplies verified progress or results, +and `bear-reset` returns to the normal capture UI. Monitor commands order the floating window without +activating it, ignore mouse events while monitoring, and therefore do not take keyboard focus from +Bear. Normal `focus` or `arm` commands leave monitor mode and restore the Jig's interactive capture +behavior. + +This is the baseline reliability policy. Deliberate load-matrix cases use the runner's bounded CPU +envelope rather than bypassing the gate with an unstructured "ignore busy" switch. The runner +performs the normal calm preflight first, then starts the requested number of tracked CPU workers +only after the Jig and fixture are armed: + +```bash +Scripts/lab/physical-hid-capture-run \ + --run-id shifted-high-load \ + --text Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt \ + --interval-ms 50 --hold-ms 12 --repeat 20 --cycle-gap-ms 50 \ + --cpu-load-workers 6 --cpu-sample-ms 250 +``` + +`--cpu-load-workers` accepts zero through the host's logical CPU count. Every worker is owned and +terminated by the runner, including error paths. The combined artifact records worker count, +logical CPUs, average/maximum CPU, and timestamped CPU/load samples. If capture fails early, the +runner still waits for the fixture to finish and for the macOS event queue to settle, preserving the +complete event ledger instead of a first-mismatch prefix. + +For the repeated-key regression, use the strict resumable campaign rather than an ad hoc typing +script: + +```bash +Scripts/lab/physical-hid-repeat-matrix --exclusive-desktop-confirmed +``` + +The default 60-case plan crosses four input shapes (one repeated key, alternating keys, a rolling +home-row sequence, and shifted symbols), 50/10/5 ms pacing, and five load profiles: calm, two and +six bounded CPU workers, generated Swift compilation, and generated Swift compilation plus two CPU +workers. Every case still passes the normal calm admission gate before its bounded load begins; the +campaign never uses demo mode. The generated compiler workload lives in a temporary directory and +does not clean, touch, or build the KeyPath worktree. + +Each combined artifact distinguishes additions from deletions and substitutions and retains the +Jig's duplicate-down, host-repeat, unmatched-up, focus, release, and event evidence alongside the +ESP32 trace and timing. The summary classifies any inserted character or unexpected repeat event as +`repeated-input-observed`, while focus loss or incomplete release fails closed as `harness-invalid`. +Use the same `--run-id-prefix` and `--resume` after an infrastructure interruption to reuse completed +case artifacts without repeating valid HID runs. The explicit desktop confirmation is mandatory +because a real USB keyboard cannot safely target a background window while the operator is typing. + +## Pico 2 W build + +Install CMake, an Arm embedded compiler, and the Raspberry Pi Pico SDK. Keep Wi-Fi credentials and +the fixture token out of the repository: + +```bash +export PICO_SDK_PATH=/absolute/path/to/pico-sdk +export KEYPATH_WIFI_SSID='lab-network' +export KEYPATH_WIFI_PASSWORD='...' +export KEYPATH_FIXTURE_TOKEN='at-least-16-random-characters' +cmake -S Scripts/lab/pico-hid-fixture -B Scripts/lab/pico-hid-fixture/build -G Ninja +cmake --build Scripts/lab/pico-hid-fixture/build +``` + +The resulting `keypath_pico_hid_fixture.uf2` can be installed by holding BOOTSEL while connecting +the Pico and copying the UF2 to the mounted `RPI-RP2` volume. Build-time secrets remain in the +local build directory and embedded firmware; delete that directory before sharing artifacts. + +When real lab credentials are provisioned, store them through the existing sops-backed secret +workflow and have the build wrapper export them without printing their values. + +## Script protocol + +The first line is: + +```text +KPHID1 RUN_ID EVENT_COUNT REPEAT_COUNT CYCLE_US CRC32 +``` + +Each remaining line is one complete boot-keyboard report: + +```text +AT_US MODIFIERS KEY1 KEY2 KEY3 KEY4 KEY5 KEY6 +``` + +Times are absolute within one cycle and must increase. The last report must contain seven zeros. +The event payload, including final newlines, is protected by the header CRC32. A short cycle can be +repeated many times without storing tens of thousands of reports in Pico RAM. + +## HTTP API + +The fixture advertises `keypath-hid-fixture.local` over mDNS on port 8080. Every request requires +`Authorization: Bearer TOKEN`. + +| Method | Path | Body | +|---|---|---| +| GET | `/v1/status` | — | +| POST | `/v1/script` | Complete `KPHID1` script | +| POST | `/v1/arm` | Run ID | +| POST | `/v1/start` | `RUN_ID DELAY_MS` | +| POST | `/v1/abort` | Empty | +| GET | `/v1/trace?from=N&limit=8` | — | +| POST | `/v1/presentation` | Bounded JSON phase, result, progress, labels, and metrics | +| POST | `/v1/firmware` | Binary ESP32 application image plus SHA-256 and HMAC headers | + +Commands are state-checked. A script cannot start unless the same run ID was loaded and armed. +The start delay must be 100–60000 ms, giving the guest observer time to become ready. + +## Mac mini client + +`Scripts/lab/pico-hid-fixture-client` uses only Python's standard library. It compiles US-keyboard +text into full HID reports and calls the fixture API: + +```bash +export KEYPATH_FIXTURE_HOST=keypath-hid-fixture.local +export KEYPATH_FIXTURE_TOKEN='...' + +Scripts/lab/pico-hid-fixture-client status +Scripts/lab/pico-hid-fixture-client compile-text \ + --run-id swift-load-001 --text reference.txt --repeat 20 --output /tmp/swift-load.kphid +Scripts/lab/pico-hid-fixture-client load-script /tmp/swift-load.kphid +Scripts/lab/pico-hid-fixture-client arm swift-load-001 +Scripts/lab/pico-hid-fixture-client start swift-load-001 --delay-ms 2000 +Scripts/lab/pico-hid-fixture-client trace +Scripts/lab/pico-hid-fixture-client present --phase observing --progress 850 \ + --title 'Swift stress' --detail 'Comparing received text' +Scripts/lab/pico-hid-fixture-client present --phase result --result pass --progress 1000 \ + --title 'Swift stress' --detail 'No repeated keys' \ + --reports-expected 400 --reports-observed 400 --latency-p95-us 620 --safe-release +``` + +Measure the control plane without emitting HID reports: + +```bash +Scripts/lab/pico-hid-fixture-tool soak --requests 120 +``` + +The soak resolves mDNS once, then measures the ESP endpoint by IP, stops after three consecutive +failures, enforces a 750 ms p95 budget, and atomically writes an artifact under +`~/.local/state/keypath-hid-fixture/control/`. Discovery time is recorded separately so slow mDNS +cannot be mistaken for a slow firmware handler. + +`run-text` combines compile, load, arm, and start. Production scenarios should still perform each +stage explicitly so the VM can prove USB admission, arm its independent observers, and verify the +requested load threshold before `start`. + +## Safe Wi-Fi firmware updates + +The initial dual-slot partition layout must be installed once over USB with the normal BOOT/RESET +flash path. After that migration, routine application updates are one command and do not change +the HID-only USB interface: + +```bash +Scripts/lab/pico-hid-fixture-tool update +``` + +The tool builds an exact source-identified image, confirms the fixture is reachable and update-safe, +authenticates the image with the fixture token, streams it into the inactive slot, and waits through +the reboot. Success requires the board to reconnect over Wi-Fi on the exact expected build. The +device shows receive/validation progress while keeping HID execution disabled. Never remove power +during the update. If the new image cannot restore its Wi-Fi control plane within 60 seconds, the +bootloader returns to the previous slot. USB BOOT/RESET remains the recovery path if both application +slots or the network configuration are unusable. + +The matrix campaign executor mirrors live phases and its final classified result to the display +when invoked with `--hid-presentation`. This path is intentionally presentation-only: display +delivery failures are logged to `hid-fixture-presentation.log` and never change test evidence or +campaign classification. The fixture token remains environment-only. + +## Verification without hardware + +```bash +Scripts/lab/pico-hid-fixture/tests/run-tests.sh +python3 Scripts/lab/tests/pico-hid-fixture-client-tests.py +python3 Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py +Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh +``` + +These tests cover CRC and script admission, timing/repeat execution, trace ordering, lateness +metrics, boot/abort/unmount releases, US-keyboard compilation, bearer authentication, endpoint +selection, authenticated OTA image construction and build verification, secure installer behavior, +presentation/result resolution, NDJSON trace decoding, and +the adaptive UI model. The QEMU test boots an ESP32-S3 image +and executes the real parser, scheduler, trace logic, and UI state model on the emulated Xtensa +cores. QEMU does not emulate the Waveshare LCD/touch/buzzer or the ESP32-S3 native USB device +controller, so the physical USB/VM, display, touch, sound, and timing acceptance checks still wait +for the board. + +## First-board acceptance + +1. Flash revision 2, confirm the display, touch coordinates, function button, and transition tones; + retry revision 1 only if the buzzer is silent. +2. Confirm macOS reports exactly one boot-keyboard HID interface and no serial or storage interface. +3. Attach USB directly to a disposable VM and verify a baseline script's received text, report + count, submitted CRC32, transfer completions, and lateness trace. +4. Repeat under sustained CPU, memory, disk, and UI load; `HID PRIORITY` should reduce animation + while the trace remains complete and correctly ordered. +5. During an active run, test touch abort, button abort, Wi-Fi abort, and USB removal. Every path + must end with an all-keys-released report before the fixture can be called hardware-proven. diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c new file mode 100644 index 000000000..23bfb0fb9 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.c @@ -0,0 +1,46 @@ +#include "fixture_button_feedback.h" + +fixture_button_feedback_output_t fixture_button_feedback_resolve( + fixture_button_event_t event, bool boot_held, bool download_hint, + uint64_t elapsed_ms) { + fixture_button_feedback_output_t output = {0}; + bool sustained_boot_feedback = event == FIXTURE_BUTTON_BOOT && boot_held; + if (event == FIXTURE_BUTTON_NONE || + (!sustained_boot_feedback && elapsed_ms >= FIXTURE_BUTTON_FEEDBACK_DURATION_MS)) { + return output; + } + + output.active = true; + switch (event) { + case FIXTURE_BUTTON_POWER: + output.title = "POWER BUTTON"; + output.detail = "TOP button detected"; + output.accent_rgb = 0xffb454u; + break; + case FIXTURE_BUTTON_BOOT: + if (boot_held && download_hint) { + output.title = "BOOT HELD"; + output.detail = "KEEP HOLDING + TAP BOTTOM RESET"; + } else if (download_hint) { + output.title = "BOOT RELEASED"; + output.detail = "Hold it while tapping RESET"; + } else { + output.title = "BOOT BUTTON"; + output.detail = "Test abort requested"; + } + output.accent_rgb = 0x55c7ffu; + break; + case FIXTURE_BUTTON_RESET: + output.title = "RESET / START"; + output.detail = "BOTTOM RST or power cycle"; + output.accent_rgb = 0xff6b6bu; + break; + case FIXTURE_BUTTON_NONE: + return output; + } + + uint64_t phase_ms = elapsed_ms % 360u; + uint64_t triangle_ms = phase_ms <= 180u ? phase_ms : 360u - phase_ms; + output.pulse_per_mille = (uint16_t)(triangle_ms * 1000u / 180u); + return output; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h new file mode 100644 index 000000000..187a1f7f4 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_button_feedback.h @@ -0,0 +1,28 @@ +#ifndef KEYPATH_FIXTURE_BUTTON_FEEDBACK_H +#define KEYPATH_FIXTURE_BUTTON_FEEDBACK_H + +#include +#include + +#define FIXTURE_BUTTON_FEEDBACK_DURATION_MS 1200u + +typedef enum { + FIXTURE_BUTTON_NONE = 0, + FIXTURE_BUTTON_POWER, + FIXTURE_BUTTON_BOOT, + FIXTURE_BUTTON_RESET, +} fixture_button_event_t; + +typedef struct { + bool active; + const char *title; + const char *detail; + uint32_t accent_rgb; + uint16_t pulse_per_mille; +} fixture_button_feedback_output_t; + +fixture_button_feedback_output_t fixture_button_feedback_resolve( + fixture_button_event_t event, bool boot_held, bool download_hint, + uint64_t elapsed_ms); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/src/fixture_config.h.in new file mode 100644 index 000000000..cf7d9bf1a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_config.h.in @@ -0,0 +1,9 @@ +#ifndef KEYPATH_FIXTURE_CONFIG_H +#define KEYPATH_FIXTURE_CONFIG_H + +#define KEYPATH_WIFI_SSID "@KEYPATH_WIFI_SSID_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD "@KEYPATH_WIFI_PASSWORD_ESCAPED@" +#define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" +#define KEYPATH_FIXTURE_HTTP_PORT 8080u + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.c b/Scripts/lab/pico-hid-fixture/src/fixture_core.c new file mode 100644 index 000000000..48f0bde1c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.c @@ -0,0 +1,412 @@ +#include "fixture_core.h" + +#include +#include +#include +#include +#include + +static void set_error(char *target, size_t capacity, const char *message) { + if (target && capacity) { + snprintf(target, capacity, "%s", message ? message : "unknown error"); + } +} + +static bool valid_run_id(const char *value) { + size_t length = value ? strlen(value) : 0u; + if (length == 0u || length > FIXTURE_MAX_RUN_ID) return false; + for (size_t index = 0; index < length; ++index) { + unsigned char character = (unsigned char)value[index]; + if (!(isalnum(character) || character == '-' || character == '_' || character == '.')) return false; + } + return true; +} + +static bool report_is_empty(const fixture_event_t *event) { + if (event->modifiers != 0u) return false; + for (size_t index = 0; index < 6u; ++index) { + if (event->keys[index] != 0u) return false; + } + return true; +} + +static bool report_has_unique_keys(const fixture_event_t *event) { + for (size_t left = 0; left < 6u; ++left) { + if (event->keys[left] == 0u) continue; + for (size_t right = left + 1u; right < 6u; ++right) { + if (event->keys[left] == event->keys[right]) return false; + } + } + return true; +} + +static void invalidate_script(fixture_t *fixture) { + fixture->state = FIXTURE_IDLE; + fixture->event_count = 0u; + fixture->run_id[0] = '\0'; +} + +static void finish_loading(fixture_t *fixture, const char *run_id, uint32_t script_crc32, + uint32_t event_count, uint32_t repeat_count, uint32_t cycle_us) { + snprintf(fixture->run_id, sizeof(fixture->run_id), "%s", run_id); + fixture->script_crc32 = script_crc32; + fixture->event_count = event_count; + fixture->repeat_count = repeat_count; + fixture->cycle_us = cycle_us; + fixture->state = FIXTURE_LOADED; + fixture->next_event = 0u; + fixture->current_repeat = 0u; + fixture->reports_submitted = 0u; + fixture->transfers_completed = 0u; + fixture->late_reports = 0u; + fixture->maximum_lateness_us = 0; + fixture->submitted_crc32 = 0u; + fixture->trace_head = 0u; + fixture->trace_count = 0u; + fixture->error[0] = '\0'; +} + +void fixture_init(fixture_t *fixture) { + memset(fixture, 0, sizeof(*fixture)); + fixture->state = FIXTURE_IDLE; + fixture->pending_release = true; +} + +const char *fixture_state_name(fixture_state_t state) { + switch (state) { + case FIXTURE_BOOTING: return "booting"; + case FIXTURE_IDLE: return "idle"; + case FIXTURE_LOADED: return "loaded"; + case FIXTURE_ARMED: return "armed"; + case FIXTURE_RUNNING: return "running"; + case FIXTURE_COMPLETE: return "complete"; + case FIXTURE_ABORTED: return "aborted"; + case FIXTURE_ERROR: return "error"; + } + return "unknown"; +} + +uint32_t fixture_crc32(const void *bytes, size_t length) { + const uint8_t *cursor = bytes; + uint32_t crc = 0xffffffffu; + for (size_t index = 0; index < length; ++index) { + crc ^= cursor[index]; + for (uint8_t bit = 0; bit < 8u; ++bit) { + crc = (crc >> 1u) ^ (0xedb88320u & (uint32_t)-(int32_t)(crc & 1u)); + } + } + return ~crc; +} + +bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, + char *error, size_t error_capacity) { + if (!fixture || !body || length == 0u) { + set_error(error, error_capacity, "script body is empty"); + return false; + } + if (fixture->state == FIXTURE_RUNNING || fixture->state == FIXTURE_ARMED) { + set_error(error, error_capacity, "fixture is armed or running"); + return false; + } + + // Invalidate the previous script before parsing into the fixture's bounded + // event storage. A malformed replacement must never leave a partly + // overwritten script available to arm. + invalidate_script(fixture); + + const char *newline = memchr(body, '\n', length); + if (!newline) { + set_error(error, error_capacity, "script header is incomplete"); + return false; + } + size_t header_length = (size_t)(newline - body); + if (header_length >= 160u) { + set_error(error, error_capacity, "script header is too long"); + return false; + } + char header[160]; + memcpy(header, body, header_length); + header[header_length] = '\0'; + + char run_id[FIXTURE_MAX_RUN_ID + 1u] = {0}; + uint32_t event_count = 0u, repeat_count = 0u, cycle_us = 0u, declared_crc = 0u; + char extra = '\0'; + int fields = sscanf(header, "KPHID1 %48s %" SCNu32 " %" SCNu32 " %" SCNu32 " %" SCNx32 " %c", + run_id, &event_count, &repeat_count, &cycle_us, &declared_crc, &extra); + if (fields != 5) { + set_error(error, error_capacity, "script header must be KPHID1 run events repeats cycle_us crc32"); + return false; + } + if (!valid_run_id(run_id)) { + set_error(error, error_capacity, "run id contains unsupported characters"); + return false; + } + if (event_count == 0u || event_count > FIXTURE_MAX_EVENTS) { + set_error(error, error_capacity, "event count is outside fixture limits"); + return false; + } + if (repeat_count == 0u || repeat_count > FIXTURE_MAX_REPEATS || + (uint64_t)event_count * repeat_count > FIXTURE_MAX_TOTAL_REPORTS) { + set_error(error, error_capacity, "repeat count is outside fixture limits"); + return false; + } + if (cycle_us == 0u) { + set_error(error, error_capacity, "cycle duration must be positive"); + return false; + } + + const char *events_body = newline + 1; + size_t events_length = length - (size_t)(events_body - body); + if (fixture_crc32(events_body, events_length) != declared_crc) { + set_error(error, error_capacity, "script CRC32 does not match payload"); + return false; + } + + const char *cursor = events_body; + const char *end = body + length; + uint32_t previous_at = 0u; + for (uint32_t index = 0u; index < event_count; ++index) { + const char *line_end = memchr(cursor, '\n', (size_t)(end - cursor)); + if (!line_end) line_end = end; + size_t line_length = (size_t)(line_end - cursor); + if (line_length == 0u || line_length >= 128u) { + set_error(error, error_capacity, "event line is empty or too long"); + return false; + } + char line[128]; + memcpy(line, cursor, line_length); + line[line_length] = '\0'; + fixture_event_t event = {0}; + unsigned int values[7] = {0}; + fields = sscanf(line, "%" SCNu32 " %u %u %u %u %u %u %u %c", &event.at_us, + &values[0], &values[1], &values[2], &values[3], &values[4], &values[5], &values[6], &extra); + if (fields != 8) { + set_error(error, error_capacity, "event must contain time, modifiers, and six key usages"); + return false; + } + if ((index > 0u && event.at_us <= previous_at) || event.at_us >= cycle_us) { + set_error(error, error_capacity, "event times must increase and remain inside the cycle"); + return false; + } + for (size_t value_index = 0u; value_index < 7u; ++value_index) { + if (values[value_index] > 255u) { + set_error(error, error_capacity, "HID report values must fit in one byte"); + return false; + } + } + event.modifiers = (uint8_t)values[0]; + for (size_t key = 0u; key < 6u; ++key) event.keys[key] = (uint8_t)values[key + 1u]; + if (!report_has_unique_keys(&event)) { + set_error(error, error_capacity, "a report contains duplicate nonzero key usages"); + return false; + } + fixture->events[index] = event; + previous_at = event.at_us; + cursor = line_end < end ? line_end + 1 : end; + } + while (cursor < end && isspace((unsigned char)*cursor)) ++cursor; + if (cursor != end) { + set_error(error, error_capacity, "script contains more event lines than declared"); + return false; + } + if (!report_is_empty(&fixture->events[event_count - 1u])) { + set_error(error, error_capacity, "each cycle must end with an all-keys-released report"); + return false; + } + + finish_loading(fixture, run_id, declared_crc, event_count, repeat_count, cycle_us); + set_error(error, error_capacity, ""); + return true; +} + +bool fixture_load_events(fixture_t *fixture, const char *run_id, + const fixture_event_t *events, uint32_t event_count, + uint32_t repeat_count, uint32_t cycle_us, + char *error, size_t error_capacity) { + if (!fixture || !events) { + set_error(error, error_capacity, "event list is empty"); + return false; + } + if (fixture->state == FIXTURE_RUNNING || fixture->state == FIXTURE_ARMED) { + set_error(error, error_capacity, "fixture is armed or running"); + return false; + } + invalidate_script(fixture); + if (!valid_run_id(run_id)) { + set_error(error, error_capacity, "run id contains unsupported characters"); + return false; + } + if (event_count == 0u || event_count > FIXTURE_MAX_EVENTS) { + set_error(error, error_capacity, "event count is outside fixture limits"); + return false; + } + if (repeat_count == 0u || repeat_count > FIXTURE_MAX_REPEATS || + (uint64_t)event_count * repeat_count > FIXTURE_MAX_TOTAL_REPORTS) { + set_error(error, error_capacity, "repeat count is outside fixture limits"); + return false; + } + if (cycle_us == 0u) { + set_error(error, error_capacity, "cycle duration must be positive"); + return false; + } + uint32_t previous_at = 0u; + for (uint32_t index = 0u; index < event_count; ++index) { + if ((index > 0u && events[index].at_us <= previous_at) || events[index].at_us >= cycle_us) { + set_error(error, error_capacity, "event times must increase and remain inside the cycle"); + return false; + } + if (!report_has_unique_keys(&events[index])) { + set_error(error, error_capacity, "a report contains duplicate nonzero key usages"); + return false; + } + previous_at = events[index].at_us; + } + if (!report_is_empty(&events[event_count - 1u])) { + set_error(error, error_capacity, "each cycle must end with an all-keys-released report"); + return false; + } + memcpy(fixture->events, events, event_count * sizeof(*events)); + finish_loading(fixture, run_id, fixture_crc32(events, event_count * sizeof(*events)), + event_count, repeat_count, cycle_us); + set_error(error, error_capacity, ""); + return true; +} + +bool fixture_arm(fixture_t *fixture, const char *run_id, char *error, size_t error_capacity) { + if (fixture->state != FIXTURE_LOADED) { + set_error(error, error_capacity, "a loaded script is required before arming"); + return false; + } + if (!run_id || strcmp(run_id, fixture->run_id) != 0) { + set_error(error, error_capacity, "run id does not match loaded script"); + return false; + } + fixture->state = FIXTURE_ARMED; + fixture->pending_release = true; + return true; +} + +bool fixture_start(fixture_t *fixture, const char *run_id, uint32_t delay_ms, uint64_t now_us, + char *error, size_t error_capacity) { + if (fixture->state != FIXTURE_ARMED) { + set_error(error, error_capacity, "fixture must be armed before starting"); + return false; + } + if (!run_id || strcmp(run_id, fixture->run_id) != 0) { + set_error(error, error_capacity, "run id does not match armed script"); + return false; + } + if (delay_ms < 100u || delay_ms > 60000u) { + set_error(error, error_capacity, "start delay must be between 100 and 60000 ms"); + return false; + } + fixture->start_at_us = now_us + (uint64_t)delay_ms * 1000u; + fixture->next_event = 0u; + fixture->current_repeat = 0u; + fixture->state = FIXTURE_RUNNING; + return true; +} + +void fixture_abort(fixture_t *fixture, const char *reason) { + if (!fixture) return; + fixture->state = FIXTURE_ABORTED; + fixture->pending_release = true; + snprintf(fixture->error, sizeof(fixture->error), "%s", reason ? reason : "aborted"); +} + +bool fixture_abort_if_active(fixture_t *fixture, const char *reason) { + if (!fixture || (fixture->state != FIXTURE_ARMED && fixture->state != FIXTURE_RUNNING)) { + return false; + } + fixture_abort(fixture, reason); + return true; +} + +void fixture_note_transfer_complete(fixture_t *fixture) { + if (fixture) fixture->transfers_completed++; +} + +uint32_t fixture_time_until_next_action_us(const fixture_t *fixture, uint64_t now_us) { + if (!fixture || fixture->pending_release) return 0u; + if (fixture->state != FIXTURE_RUNNING || fixture->event_count == 0u || + fixture->next_event >= fixture->event_count) { + return UINT32_MAX; + } + + uint64_t scheduled = fixture->start_at_us + + (uint64_t)fixture->current_repeat * fixture->cycle_us + + fixture->events[fixture->next_event].at_us; + if (now_us >= scheduled) return 0u; + uint64_t remaining = scheduled - now_us; + return remaining > UINT32_MAX ? UINT32_MAX : (uint32_t)remaining; +} + +static void trace_report(fixture_t *fixture, uint64_t scheduled, uint64_t submitted, + uint8_t modifiers, const uint8_t keys[6]) { + uint32_t slot = fixture->trace_head; + fixture_trace_t *trace = &fixture->trace[slot]; + trace->sequence = fixture->reports_submitted; + trace->scheduled_us = scheduled; + trace->submitted_us = submitted; + trace->lateness_us = (int64_t)(submitted - scheduled); + trace->modifiers = modifiers; + memcpy(trace->keys, keys, 6u); + fixture->trace_head = (slot + 1u) % FIXTURE_TRACE_CAPACITY; + if (fixture->trace_count < FIXTURE_TRACE_CAPACITY) fixture->trace_count++; +} + +static bool submit_report(fixture_t *fixture, uint64_t scheduled, uint64_t now_us, + uint8_t modifiers, const uint8_t keys[6], + fixture_send_report_fn send_report, void *send_context) { + if (!send_report(modifiers, keys, send_context)) return false; + int64_t lateness = (int64_t)(now_us - scheduled); + fixture->reports_submitted++; + if (lateness > 1000) fixture->late_reports++; + if (lateness > fixture->maximum_lateness_us) fixture->maximum_lateness_us = lateness; + uint8_t report[7] = {modifiers, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}; + fixture->submitted_crc32 ^= fixture_crc32(report, sizeof(report)) + (uint32_t)fixture->reports_submitted; + trace_report(fixture, scheduled, now_us, modifiers, keys); + return true; +} + +void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool usb_ready, + fixture_send_report_fn send_report, void *send_context) { + if (!fixture || !send_report) return; + if (fixture->usb_mounted && !usb_mounted && fixture->state == FIXTURE_RUNNING) { + fixture->state = FIXTURE_ERROR; + fixture->pending_release = true; + snprintf(fixture->error, sizeof(fixture->error), "USB unmounted during a running script"); + } + fixture->usb_mounted = usb_mounted; + if (!usb_mounted || !usb_ready) return; + + static const uint8_t empty_keys[6] = {0}; + if (fixture->pending_release) { + if (send_report(0u, empty_keys, send_context)) fixture->pending_release = false; + return; + } + if (fixture->state != FIXTURE_RUNNING || now_us < fixture->start_at_us) return; + + uint64_t scheduled = fixture->start_at_us + (uint64_t)fixture->current_repeat * fixture->cycle_us + + fixture->events[fixture->next_event].at_us; + if (now_us < scheduled) return; + fixture_event_t *event = &fixture->events[fixture->next_event]; + if (!submit_report(fixture, scheduled, now_us, event->modifiers, event->keys, send_report, send_context)) return; + + fixture->next_event++; + if (fixture->next_event == fixture->event_count) { + fixture->next_event = 0u; + fixture->current_repeat++; + if (fixture->current_repeat == fixture->repeat_count) fixture->state = FIXTURE_COMPLETE; + } +} + +uint32_t fixture_trace_count(const fixture_t *fixture) { + return fixture ? fixture->trace_count : 0u; +} + +const fixture_trace_t *fixture_trace_at(const fixture_t *fixture, uint32_t chronological_index) { + if (!fixture || chronological_index >= fixture->trace_count) return NULL; + uint32_t oldest = (fixture->trace_head + FIXTURE_TRACE_CAPACITY - fixture->trace_count) % FIXTURE_TRACE_CAPACITY; + return &fixture->trace[(oldest + chronological_index) % FIXTURE_TRACE_CAPACITY]; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_core.h b/Scripts/lab/pico-hid-fixture/src/fixture_core.h new file mode 100644 index 000000000..b82bf7a2d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_core.h @@ -0,0 +1,91 @@ +#ifndef KEYPATH_FIXTURE_CORE_H +#define KEYPATH_FIXTURE_CORE_H + +#include +#include +#include + +#define FIXTURE_MAX_EVENTS 2048u +#define FIXTURE_MAX_REPEATS 10000u +#define FIXTURE_MAX_TOTAL_REPORTS 1000000u +#define FIXTURE_MAX_RUN_ID 48u +#define FIXTURE_TRACE_CAPACITY 256u + +typedef enum { + FIXTURE_BOOTING = 0, + FIXTURE_IDLE, + FIXTURE_LOADED, + FIXTURE_ARMED, + FIXTURE_RUNNING, + FIXTURE_COMPLETE, + FIXTURE_ABORTED, + FIXTURE_ERROR, +} fixture_state_t; + +typedef struct { + uint32_t at_us; + uint8_t modifiers; + uint8_t keys[6]; +} fixture_event_t; + +typedef struct { + uint64_t sequence; + uint64_t scheduled_us; + uint64_t submitted_us; + int64_t lateness_us; + uint8_t modifiers; + uint8_t keys[6]; +} fixture_trace_t; + +typedef struct { + fixture_state_t state; + char run_id[FIXTURE_MAX_RUN_ID + 1u]; + uint32_t script_crc32; + uint32_t event_count; + uint32_t repeat_count; + uint32_t cycle_us; + fixture_event_t events[FIXTURE_MAX_EVENTS]; + + uint64_t start_at_us; + uint32_t next_event; + uint32_t current_repeat; + uint64_t reports_submitted; + uint64_t transfers_completed; + uint64_t late_reports; + int64_t maximum_lateness_us; + uint32_t submitted_crc32; + bool pending_release; + bool usb_mounted; + + fixture_trace_t trace[FIXTURE_TRACE_CAPACITY]; + uint32_t trace_head; + uint32_t trace_count; + char error[128]; +} fixture_t; + +typedef bool (*fixture_send_report_fn)(uint8_t modifiers, const uint8_t keys[6], void *context); + +void fixture_init(fixture_t *fixture); +const char *fixture_state_name(fixture_state_t state); +uint32_t fixture_crc32(const void *bytes, size_t length); + +bool fixture_load_script(fixture_t *fixture, const char *body, size_t length, + char *error, size_t error_capacity); +bool fixture_load_events(fixture_t *fixture, const char *run_id, + const fixture_event_t *events, uint32_t event_count, + uint32_t repeat_count, uint32_t cycle_us, + char *error, size_t error_capacity); +bool fixture_arm(fixture_t *fixture, const char *run_id, char *error, size_t error_capacity); +bool fixture_start(fixture_t *fixture, const char *run_id, uint32_t delay_ms, uint64_t now_us, + char *error, size_t error_capacity); +void fixture_abort(fixture_t *fixture, const char *reason); +bool fixture_abort_if_active(fixture_t *fixture, const char *reason); +void fixture_note_transfer_complete(fixture_t *fixture); +uint32_t fixture_time_until_next_action_us(const fixture_t *fixture, uint64_t now_us); +void fixture_poll(fixture_t *fixture, uint64_t now_us, bool usb_mounted, bool usb_ready, + fixture_send_report_fn send_report, void *send_context); + +uint32_t fixture_trace_count(const fixture_t *fixture); +const fixture_trace_t *fixture_trace_at(const fixture_t *fixture, uint32_t chronological_index); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_demo.c b/Scripts/lab/pico-hid-fixture/src/fixture_demo.c new file mode 100644 index 000000000..8e023e6cc --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_demo.c @@ -0,0 +1,69 @@ +#include "fixture_demo.h" + +#include + +#define DEMO_SHIFT 0x02u +#define DEMO_INTERVAL_US 60000u +#define DEMO_HOLD_US 20000u +#define DEMO_SHIFT_LEAD_US 5000u +#define DEMO_SHIFT_RELEASE_LAG_US 5000u +#define DEMO_REPEAT_COUNT 14u +#define DEMO_CYCLE_GAP_US 469000u + +static bool demo_usage(char character, uint8_t *usage, bool *shifted) { + if (character >= 'a' && character <= 'z') { + *usage = (uint8_t)(0x04u + (uint8_t)(character - 'a')); + *shifted = false; + return true; + } + if (character >= 'A' && character <= 'Z') { + *usage = (uint8_t)(0x04u + (uint8_t)(tolower((unsigned char)character) - 'a')); + *shifted = true; + return true; + } + if (character == ' ') { + *usage = 0x2cu; + *shifted = false; + return true; + } + if (character == '\n') { + *usage = 0x28u; + *shifted = false; + return true; + } + return false; +} + +bool fixture_demo_load(fixture_t *fixture, char *error, size_t error_capacity) { + fixture_event_t events[sizeof(FIXTURE_DEMO_TEXT) * 4u] = {0}; + uint32_t event_count = 0u; + uint32_t character_index = 0u; + for (const char *cursor = FIXTURE_DEMO_TEXT; *cursor; ++cursor, ++character_index) { + uint8_t usage = 0u; + bool shifted = false; + if (!demo_usage(*cursor, &usage, &shifted)) return false; + uint32_t base_us = character_index * DEMO_INTERVAL_US; + if (shifted) { + events[event_count++] = (fixture_event_t){.at_us = base_us, .modifiers = DEMO_SHIFT}; + events[event_count++] = (fixture_event_t){ + .at_us = base_us + DEMO_SHIFT_LEAD_US, + .modifiers = DEMO_SHIFT, + .keys = {usage}, + }; + events[event_count++] = (fixture_event_t){ + .at_us = base_us + DEMO_SHIFT_LEAD_US + DEMO_HOLD_US, + .modifiers = DEMO_SHIFT, + }; + events[event_count++] = (fixture_event_t){ + .at_us = base_us + DEMO_SHIFT_LEAD_US + DEMO_HOLD_US + DEMO_SHIFT_RELEASE_LAG_US, + }; + } else { + events[event_count++] = (fixture_event_t){.at_us = base_us, .keys = {usage}}; + events[event_count++] = (fixture_event_t){.at_us = base_us + DEMO_HOLD_US}; + } + } + uint32_t cycle_us = character_index * DEMO_INTERVAL_US + DEMO_CYCLE_GAP_US; + return fixture_load_events(fixture, FIXTURE_DEMO_RUN_ID, events, event_count, + DEMO_REPEAT_COUNT, cycle_us, + error, error_capacity); +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_demo.h b/Scripts/lab/pico-hid-fixture/src/fixture_demo.h new file mode 100644 index 000000000..336bcff8a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_demo.h @@ -0,0 +1,14 @@ +#ifndef KEYPATH_FIXTURE_DEMO_H +#define KEYPATH_FIXTURE_DEMO_H + +#include +#include + +#include "fixture_core.h" + +#define FIXTURE_DEMO_RUN_ID "offline-demo" +#define FIXTURE_DEMO_TEXT "KeyPath demo OK\n" + +bool fixture_demo_load(fixture_t *fixture, char *error, size_t error_capacity); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c new file mode 100644 index 000000000..1059abef9 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.c @@ -0,0 +1,83 @@ +#include "fixture_presentation.h" + +#include +#include + +void fixture_presentation_init(fixture_presentation_t *presentation) { + memset(presentation, 0, sizeof(*presentation)); + presentation->phase = FIXTURE_PRESENT_AUTO; +} + +const char *fixture_presentation_phase_name(fixture_presentation_phase_t phase) { + switch (phase) { + case FIXTURE_PRESENT_AUTO: return "auto"; + case FIXTURE_PRESENT_PREPARING: return "preparing"; + case FIXTURE_PRESENT_COUNTDOWN: return "countdown"; + case FIXTURE_PRESENT_TESTING: return "testing"; + case FIXTURE_PRESENT_OBSERVING: return "observing"; + case FIXTURE_PRESENT_RESOLVING: return "resolving"; + case FIXTURE_PRESENT_RESULT: return "result"; + case FIXTURE_PRESENT_NEXT: return "next"; + } + return "auto"; +} + +const char *fixture_result_name(fixture_result_t result) { + switch (result) { + case FIXTURE_RESULT_NONE: return "none"; + case FIXTURE_RESULT_PASS: return "pass"; + case FIXTURE_RESULT_FAIL: return "fail"; + case FIXTURE_RESULT_INCONCLUSIVE: return "inconclusive"; + } + return "none"; +} + +const char *fixture_presentation_brand_name(fixture_presentation_brand_t brand) { + switch (brand) { + case FIXTURE_BRAND_NONE: return "none"; + case FIXTURE_BRAND_KEYPATH: return "keypath"; + case FIXTURE_BRAND_BEAR: return "bear"; + } + return "none"; +} + +bool fixture_presentation_parse_phase(const char *value, fixture_presentation_phase_t *phase) { + if (!value || !phase) return false; + for (int candidate = FIXTURE_PRESENT_AUTO; candidate <= FIXTURE_PRESENT_NEXT; ++candidate) { + if (strcmp(value, fixture_presentation_phase_name((fixture_presentation_phase_t)candidate)) == 0) { + *phase = (fixture_presentation_phase_t)candidate; + return true; + } + } + return false; +} + +bool fixture_presentation_parse_result(const char *value, fixture_result_t *result) { + if (!value || !result) return false; + for (int candidate = FIXTURE_RESULT_NONE; candidate <= FIXTURE_RESULT_INCONCLUSIVE; ++candidate) { + if (strcmp(value, fixture_result_name((fixture_result_t)candidate)) == 0) { + *result = (fixture_result_t)candidate; + return true; + } + } + return false; +} + +bool fixture_presentation_parse_brand(const char *value, fixture_presentation_brand_t *brand) { + if (!value || !brand) return false; + for (int candidate = FIXTURE_BRAND_NONE; candidate <= FIXTURE_BRAND_BEAR; ++candidate) { + if (strcmp(value, fixture_presentation_brand_name((fixture_presentation_brand_t)candidate)) == 0) { + *brand = (fixture_presentation_brand_t)candidate; + return true; + } + } + return false; +} + +bool fixture_presentation_text_valid(const char *value, size_t maximum_length) { + if (!value || strlen(value) > maximum_length) return false; + for (const unsigned char *cursor = (const unsigned char *)value; *cursor; ++cursor) { + if (*cursor < 0x20u || *cursor > 0x7eu || *cursor == '"' || *cursor == '\\') return false; + } + return true; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h new file mode 100644 index 000000000..b01f5e8a3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_presentation.h @@ -0,0 +1,59 @@ +#ifndef KEYPATH_FIXTURE_PRESENTATION_H +#define KEYPATH_FIXTURE_PRESENTATION_H + +#include +#include +#include + +typedef enum { + FIXTURE_PRESENT_AUTO = 0, + FIXTURE_PRESENT_PREPARING, + FIXTURE_PRESENT_COUNTDOWN, + FIXTURE_PRESENT_TESTING, + FIXTURE_PRESENT_OBSERVING, + FIXTURE_PRESENT_RESOLVING, + FIXTURE_PRESENT_RESULT, + FIXTURE_PRESENT_NEXT, +} fixture_presentation_phase_t; + +typedef enum { + FIXTURE_RESULT_NONE = 0, + FIXTURE_RESULT_PASS, + FIXTURE_RESULT_FAIL, + FIXTURE_RESULT_INCONCLUSIVE, +} fixture_result_t; + +typedef enum { + FIXTURE_BRAND_NONE = 0, + FIXTURE_BRAND_KEYPATH, + FIXTURE_BRAND_BEAR, +} fixture_presentation_brand_t; + +typedef struct { + fixture_presentation_phase_t phase; + fixture_result_t result; + fixture_presentation_brand_t brand; + bool branded_firmware_update; + uint16_t progress_per_mille; + uint32_t reports_expected; + uint32_t reports_observed; + uint32_t dropped; + uint32_t duplicated; + uint32_t repeated; + uint32_t latency_p95_us; + bool safe_release; + char title[33]; + char detail[49]; + char next[33]; +} fixture_presentation_t; + +void fixture_presentation_init(fixture_presentation_t *presentation); +const char *fixture_presentation_phase_name(fixture_presentation_phase_t phase); +const char *fixture_result_name(fixture_result_t result); +const char *fixture_presentation_brand_name(fixture_presentation_brand_t brand); +bool fixture_presentation_parse_phase(const char *value, fixture_presentation_phase_t *phase); +bool fixture_presentation_parse_result(const char *value, fixture_result_t *result); +bool fixture_presentation_parse_brand(const char *value, fixture_presentation_brand_t *brand); +bool fixture_presentation_text_valid(const char *value, size_t maximum_length); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c new file mode 100644 index 000000000..8fd206753 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.c @@ -0,0 +1,42 @@ +#include "fixture_splash_model.h" + +static uint16_t smoothstep_per_mille(uint64_t elapsed, uint64_t duration) { + if (elapsed >= duration) return 1000u; + uint64_t t = elapsed * 1000u / duration; + return (uint16_t)(t * t * (3000u - 2u * t) / 1000000u); +} + +fixture_splash_output_t fixture_splash_step(uint64_t elapsed_ms) { + fixture_splash_output_t output = { + .background_opacity = 255u, + .logo_scale = 228u, + }; + + uint16_t reveal = smoothstep_per_mille(elapsed_ms, FIXTURE_SPLASH_FADE_IN_MS); + output.foreground_opacity = (uint8_t)(255u * reveal / 1000u); + output.logo_scale = (uint16_t)(228u + 28u * reveal / 1000u); + + uint64_t wordmark_elapsed = elapsed_ms > 120u ? elapsed_ms - 120u : 0u; + uint16_t wordmark_reveal = smoothstep_per_mille(wordmark_elapsed, 300u); + output.wordmark_opacity = (uint8_t)(255u * wordmark_reveal / 1000u); + + if (elapsed_ms >= FIXTURE_SPLASH_HOLD_END_MS) { + uint64_t fade_elapsed = elapsed_ms - FIXTURE_SPLASH_HOLD_END_MS; + uint16_t fade = smoothstep_per_mille(fade_elapsed, + FIXTURE_SPLASH_TOTAL_MS - FIXTURE_SPLASH_HOLD_END_MS); + uint16_t remaining = (uint16_t)(1000u - fade); + output.foreground_opacity = (uint8_t)(255u * remaining / 1000u); + output.background_opacity = output.foreground_opacity; + output.wordmark_opacity = output.foreground_opacity; + output.logo_scale = (uint16_t)(256u + 18u * fade / 1000u); + } + + if (elapsed_ms >= FIXTURE_SPLASH_TOTAL_MS) { + output.foreground_opacity = 0u; + output.background_opacity = 0u; + output.wordmark_opacity = 0u; + output.logo_scale = 274u; + output.complete = true; + } + return output; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h new file mode 100644 index 000000000..3693221a3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_splash_model.h @@ -0,0 +1,21 @@ +#ifndef KEYPATH_FIXTURE_SPLASH_MODEL_H +#define KEYPATH_FIXTURE_SPLASH_MODEL_H + +#include +#include + +#define FIXTURE_SPLASH_FADE_IN_MS 260u +#define FIXTURE_SPLASH_HOLD_END_MS 1200u +#define FIXTURE_SPLASH_TOTAL_MS 1650u + +typedef struct { + uint8_t foreground_opacity; + uint8_t background_opacity; + uint8_t wordmark_opacity; + uint16_t logo_scale; + bool complete; +} fixture_splash_output_t; + +fixture_splash_output_t fixture_splash_step(uint64_t elapsed_ms); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c new file mode 100644 index 000000000..14be57015 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.c @@ -0,0 +1,82 @@ +#include "fixture_ui_model.h" + +#include + +static fixture_ui_scene_t scene_for(const fixture_ui_input_t *input) { + if (!input->wifi_connected && input->state != FIXTURE_ERROR) return FIXTURE_UI_CONNECTING; + switch (input->state) { + case FIXTURE_BOOTING: return FIXTURE_UI_BOOT; + case FIXTURE_IDLE: return FIXTURE_UI_IDLE; + case FIXTURE_LOADED: return FIXTURE_UI_LOADED; + case FIXTURE_ARMED: return FIXTURE_UI_ARMED; + case FIXTURE_RUNNING: return FIXTURE_UI_RUNNING; + case FIXTURE_COMPLETE: return FIXTURE_UI_COMPLETE; + case FIXTURE_ABORTED: return FIXTURE_UI_ABORTED; + case FIXTURE_ERROR: return FIXTURE_UI_ERROR; + } + return FIXTURE_UI_ERROR; +} + +void fixture_ui_model_init(fixture_ui_model_t *model) { + memset(model, 0, sizeof(*model)); + model->previous_state = FIXTURE_BOOTING; +} + +fixture_ui_output_t fixture_ui_model_step(fixture_ui_model_t *model, + const fixture_ui_input_t *input, + uint64_t now_ms) { + fixture_ui_output_t output = {0}; + output.scene = scene_for(input); + + uint64_t elapsed = model->initialized && now_ms > model->previous_update_ms + ? now_ms - model->previous_update_ms + : 0u; + uint64_t report_delta = model->initialized && input->reports_submitted >= model->previous_reports + ? input->reports_submitted - model->previous_reports + : 0u; + uint64_t late_delta = model->initialized && input->late_reports >= model->previous_late_reports + ? input->late_reports - model->previous_late_reports + : 0u; + + uint32_t decay = elapsed > 1000u ? 1000u : (uint32_t)elapsed; + uint32_t energy = model->energy_per_mille > decay ? model->energy_per_mille - decay : 0u; + uint64_t impulse = report_delta * 95u; + if (impulse > 1000u) impulse = 1000u; + energy += (uint32_t)impulse; + if (energy > 1000u) energy = 1000u; + model->energy_per_mille = (uint16_t)energy; + + bool new_lateness_peak = input->maximum_lateness_us > 1500 && + input->maximum_lateness_us > model->previous_maximum_lateness_us; + bool new_pressure = input->state == FIXTURE_RUNNING && (late_delta > 0u || new_lateness_peak); + if (new_pressure) model->protected_until_ms = now_ms + 1500u; + output.pressure_warning = new_pressure || now_ms < model->protected_until_ms; + + if (output.pressure_warning) { + output.quality = FIXTURE_UI_PROTECTED; + output.frame_interval_ms = 125u; + } else if (input->state == FIXTURE_RUNNING) { + output.quality = FIXTURE_UI_ACTIVE; + output.frame_interval_ms = 50u; + } else { + output.quality = FIXTURE_UI_SHOWCASE; + output.frame_interval_ms = 33u; + } + + uint64_t total = (uint64_t)input->event_count * input->repeat_count; + if (total > 0u) { + uint64_t progress = input->reports_submitted * 1000u / total; + output.progress_per_mille = (uint16_t)(progress > 1000u ? 1000u : progress); + } + output.energy_per_mille = model->energy_per_mille; + output.completion_burst = model->initialized && model->previous_state != FIXTURE_COMPLETE && + input->state == FIXTURE_COMPLETE; + + model->previous_reports = input->reports_submitted; + model->previous_late_reports = input->late_reports; + model->previous_maximum_lateness_us = input->maximum_lateness_us; + model->previous_update_ms = now_ms; + model->previous_state = input->state; + model->initialized = true; + return output; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h new file mode 100644 index 000000000..eb4a9acb3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_ui_model.h @@ -0,0 +1,65 @@ +#ifndef KEYPATH_FIXTURE_UI_MODEL_H +#define KEYPATH_FIXTURE_UI_MODEL_H + +#include +#include + +#include "fixture_core.h" + +typedef enum { + FIXTURE_UI_BOOT = 0, + FIXTURE_UI_CONNECTING, + FIXTURE_UI_IDLE, + FIXTURE_UI_LOADED, + FIXTURE_UI_ARMED, + FIXTURE_UI_RUNNING, + FIXTURE_UI_COMPLETE, + FIXTURE_UI_ABORTED, + FIXTURE_UI_ERROR, +} fixture_ui_scene_t; + +typedef enum { + FIXTURE_UI_SHOWCASE = 0, + FIXTURE_UI_ACTIVE, + FIXTURE_UI_PROTECTED, +} fixture_ui_quality_t; + +typedef struct { + fixture_state_t state; + bool wifi_connected; + bool usb_mounted; + uint32_t event_count; + uint32_t repeat_count; + uint32_t current_repeat; + uint64_t reports_submitted; + uint64_t late_reports; + int64_t maximum_lateness_us; +} fixture_ui_input_t; + +typedef struct { + fixture_ui_scene_t scene; + fixture_ui_quality_t quality; + uint16_t progress_per_mille; + uint16_t energy_per_mille; + uint16_t frame_interval_ms; + bool completion_burst; + bool pressure_warning; +} fixture_ui_output_t; + +typedef struct { + uint64_t previous_reports; + uint64_t previous_late_reports; + int64_t previous_maximum_lateness_us; + uint64_t previous_update_ms; + uint64_t protected_until_ms; + fixture_state_t previous_state; + uint16_t energy_per_mille; + bool initialized; +} fixture_ui_model_t; + +void fixture_ui_model_init(fixture_ui_model_t *model); +fixture_ui_output_t fixture_ui_model_step(fixture_ui_model_t *model, + const fixture_ui_input_t *input, + uint64_t now_ms); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c new file mode 100644 index 000000000..324d213d5 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.c @@ -0,0 +1,165 @@ +#include "fixture_visual_model.h" + +#include + +static const char *scene_title(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_BOOT: return "WAKING UP"; + case FIXTURE_UI_CONNECTING: return "JOINING LAB"; + case FIXTURE_UI_IDLE: return "READY"; + case FIXTURE_UI_LOADED: return "SCRIPT LOADED"; + case FIXTURE_UI_ARMED: return "ARMED"; + case FIXTURE_UI_RUNNING: return "TYPING"; + case FIXTURE_UI_COMPLETE: return "RUN COMPLETE"; + case FIXTURE_UI_ABORTED: return "RUN STOPPED"; + case FIXTURE_UI_ERROR: return "ATTENTION"; + } + return "UNKNOWN"; +} + +static fixture_visual_icon_t scene_icon(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_BOOT: return FIXTURE_ICON_POWER; + case FIXTURE_UI_CONNECTING: return FIXTURE_ICON_WIFI; + case FIXTURE_UI_IDLE: return FIXTURE_ICON_KEYBOARD; + case FIXTURE_UI_LOADED: return FIXTURE_ICON_DOWNLOAD; + case FIXTURE_UI_ARMED: return FIXTURE_ICON_WARNING; + case FIXTURE_UI_RUNNING: return FIXTURE_ICON_PLAY; + case FIXTURE_UI_COMPLETE: return FIXTURE_ICON_OK; + case FIXTURE_UI_ABORTED: return FIXTURE_ICON_STOP; + case FIXTURE_UI_ERROR: return FIXTURE_ICON_CLOSE; + } + return FIXTURE_ICON_WARNING; +} + +static uint32_t scene_accent(fixture_ui_scene_t scene) { + switch (scene) { + case FIXTURE_UI_RUNNING: return 0x55c7ffu; + case FIXTURE_UI_COMPLETE: return 0x44d7a8u; + case FIXTURE_UI_ARMED: return 0xffb454u; + case FIXTURE_UI_ABORTED: return 0x9c7cffu; + case FIXTURE_UI_ERROR: return 0xff5c72u; + default: return 0x56ddb3u; + } +} + +static void apply_phase(const fixture_presentation_t *presentation, + fixture_visual_output_t *visual, const char **title) { + switch (presentation->phase) { + case FIXTURE_PRESENT_PREPARING: + visual->icon = FIXTURE_ICON_REFRESH; + visual->accent_rgb = 0x9c7cffu; + *title = "PREPARING"; + break; + case FIXTURE_PRESENT_COUNTDOWN: + visual->icon = FIXTURE_ICON_BELL; + visual->accent_rgb = 0xffb454u; + visual->angular_speed_milliradians = 2600u; + *title = "STAND BY"; + break; + case FIXTURE_PRESENT_TESTING: + visual->icon = FIXTURE_ICON_KEYBOARD; + visual->accent_rgb = 0x55c7ffu; + visual->angular_speed_milliradians = 4800u; + *title = "SENDING KEYS"; + break; + case FIXTURE_PRESENT_OBSERVING: + visual->icon = FIXTURE_ICON_EYE; + visual->accent_rgb = 0xb58cffu; + visual->angular_speed_milliradians = 1100u; + *title = "OBSERVING"; + break; + case FIXTURE_PRESENT_RESOLVING: + visual->icon = FIXTURE_ICON_SETTINGS; + visual->accent_rgb = 0x36d8d0u; + *title = "RESOLVING"; + break; + case FIXTURE_PRESENT_RESULT: + visual->icon = FIXTURE_ICON_OK; + *title = "RESULT"; + break; + case FIXTURE_PRESENT_NEXT: + visual->icon = FIXTURE_ICON_NEXT; + visual->accent_rgb = 0x55c7ffu; + *title = "UP NEXT"; + break; + case FIXTURE_PRESENT_AUTO: + break; + } +} + +static void apply_result(fixture_result_t result, fixture_visual_output_t *visual, + const char **title) { + switch (result) { + case FIXTURE_RESULT_PASS: + visual->icon = FIXTURE_ICON_OK; + visual->accent_rgb = 0x44d7a8u; + *title = "TEST PASSED"; + break; + case FIXTURE_RESULT_FAIL: + visual->icon = FIXTURE_ICON_CLOSE; + visual->accent_rgb = 0xff5c72u; + *title = "TEST FAILED"; + break; + case FIXTURE_RESULT_INCONCLUSIVE: + visual->icon = FIXTURE_ICON_WARNING; + visual->accent_rgb = 0xffb454u; + *title = "NEEDS REVIEW"; + break; + case FIXTURE_RESULT_NONE: + break; + } +} + +static void apply_bear_palette(fixture_result_t result, + fixture_visual_output_t *visual) { + switch (result) { + case FIXTURE_RESULT_PASS: + visual->accent_rgb = 0x66c9a3u; + break; + case FIXTURE_RESULT_FAIL: + visual->accent_rgb = 0x9b8afbu; + break; + case FIXTURE_RESULT_INCONCLUSIVE: + visual->accent_rgb = 0xf2b84bu; + break; + case FIXTURE_RESULT_NONE: + visual->accent_rgb = 0xe34c42u; + break; + } +} + +void fixture_visual_resolve(const fixture_ui_output_t *ui, + const fixture_presentation_t *presentation, + fixture_visual_output_t *visual) { + const char *title = scene_title(ui->scene); + visual->icon = scene_icon(ui->scene); + if (presentation->branded_firmware_update || + presentation->brand == FIXTURE_BRAND_KEYPATH) { + visual->variant = FIXTURE_VISUAL_KEYPATH_UPDATE; + } else if (presentation->brand == FIXTURE_BRAND_BEAR) { + visual->variant = FIXTURE_VISUAL_BEAR_TEST; + } else { + visual->variant = FIXTURE_VISUAL_STANDARD; + } + visual->accent_rgb = scene_accent(ui->scene); + visual->progress_per_mille = ui->progress_per_mille; + visual->angular_speed_milliradians = ui->scene == FIXTURE_UI_RUNNING ? 4200u : 1550u; + + if (presentation->phase != FIXTURE_PRESENT_AUTO) { + visual->progress_per_mille = presentation->progress_per_mille; + apply_phase(presentation, visual, &title); + if (presentation->title[0]) title = presentation->title; + } + if (presentation->branded_firmware_update && presentation->result == FIXTURE_RESULT_NONE) { + visual->icon = FIXTURE_ICON_DOWNLOAD; + visual->accent_rgb = 0xf3a128u; + visual->angular_speed_milliradians = 2500u; + } + apply_result(presentation->result, visual, &title); + if (presentation->brand == FIXTURE_BRAND_BEAR) { + apply_bear_palette(presentation->result, visual); + } + if (ui->quality == FIXTURE_UI_PROTECTED) visual->angular_speed_milliradians = 480u; + snprintf(visual->title, sizeof(visual->title), "%s", title); +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h new file mode 100644 index 000000000..f14623e9f --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_visual_model.h @@ -0,0 +1,45 @@ +#ifndef KEYPATH_FIXTURE_VISUAL_MODEL_H +#define KEYPATH_FIXTURE_VISUAL_MODEL_H + +#include + +#include "fixture_presentation.h" +#include "fixture_ui_model.h" + +typedef enum { + FIXTURE_ICON_POWER = 0, + FIXTURE_ICON_WIFI, + FIXTURE_ICON_KEYBOARD, + FIXTURE_ICON_DOWNLOAD, + FIXTURE_ICON_WARNING, + FIXTURE_ICON_PLAY, + FIXTURE_ICON_OK, + FIXTURE_ICON_STOP, + FIXTURE_ICON_CLOSE, + FIXTURE_ICON_REFRESH, + FIXTURE_ICON_BELL, + FIXTURE_ICON_EYE, + FIXTURE_ICON_SETTINGS, + FIXTURE_ICON_NEXT, +} fixture_visual_icon_t; + +typedef enum { + FIXTURE_VISUAL_STANDARD = 0, + FIXTURE_VISUAL_KEYPATH_UPDATE, + FIXTURE_VISUAL_BEAR_TEST, +} fixture_visual_variant_t; + +typedef struct { + fixture_visual_icon_t icon; + fixture_visual_variant_t variant; + uint32_t accent_rgb; + uint16_t progress_per_mille; + uint16_t angular_speed_milliradians; + char title[33]; +} fixture_visual_output_t; + +void fixture_visual_resolve(const fixture_ui_output_t *ui, + const fixture_presentation_t *presentation, + fixture_visual_output_t *visual); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c new file mode 100644 index 000000000..b8b8db430 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.c @@ -0,0 +1,22 @@ +#include "fixture_wifi_model.h" + +void fixture_wifi_model_init(fixture_wifi_model_t *model) { + model->profile_index = 0u; + model->failed_attempts = 0u; +} + +bool fixture_wifi_model_note_disconnect(fixture_wifi_model_t *model, + size_t profile_count, + unsigned int attempts_per_profile) { + if (profile_count == 0u || attempts_per_profile == 0u) return false; + model->failed_attempts++; + if (model->failed_attempts < attempts_per_profile) return false; + + model->failed_attempts = 0u; + model->profile_index = (model->profile_index + 1u) % profile_count; + return true; +} + +void fixture_wifi_model_note_connected(fixture_wifi_model_t *model) { + model->failed_attempts = 0u; +} diff --git a/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h new file mode 100644 index 000000000..de5623190 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/fixture_wifi_model.h @@ -0,0 +1,18 @@ +#ifndef KEYPATH_FIXTURE_WIFI_MODEL_H +#define KEYPATH_FIXTURE_WIFI_MODEL_H + +#include +#include + +typedef struct { + size_t profile_index; + unsigned int failed_attempts; +} fixture_wifi_model_t; + +void fixture_wifi_model_init(fixture_wifi_model_t *model); +bool fixture_wifi_model_note_disconnect(fixture_wifi_model_t *model, + size_t profile_count, + unsigned int attempts_per_profile); +void fixture_wifi_model_note_connected(fixture_wifi_model_t *model); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/http_server.c b/Scripts/lab/pico-hid-fixture/src/http_server.c new file mode 100644 index 000000000..24f014947 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/http_server.c @@ -0,0 +1,285 @@ +#include "http_server.h" + +#include +#include +#include +#include + +#include "lwip/pbuf.h" +#include "lwip/tcp.h" +#include "pico/stdlib.h" + +#define REQUEST_CAPACITY (96u * 1024u) +#define RESPONSE_CAPACITY 4096u + +typedef struct { + struct tcp_pcb *pcb; + size_t length; + size_t expected_length; + bool headers_parsed; + char request[REQUEST_CAPACITY + 1u]; +} http_client_t; + +static fixture_t *server_fixture; +static const char *server_token; +static bool network_connected; +static char network_address[48] = "unassigned"; +static bool client_active; + +static void close_client(http_client_t *client) { + if (!client) return; + if (client->pcb) { + tcp_arg(client->pcb, NULL); + tcp_recv(client->pcb, NULL); + tcp_err(client->pcb, NULL); + tcp_close(client->pcb); + } + client_active = false; + free(client); +} + +static const char *reason_phrase(int status) { + switch (status) { + case 200: return "OK"; + case 201: return "Created"; + case 400: return "Bad Request"; + case 401: return "Unauthorized"; + case 404: return "Not Found"; + case 409: return "Conflict"; + case 413: return "Payload Too Large"; + case 503: return "Service Unavailable"; + default: return "Error"; + } +} + +static void reply(http_client_t *client, int status, const char *content_type, const char *body) { + char response[RESPONSE_CAPACITY]; + size_t body_length = body ? strlen(body) : 0u; + int count = snprintf(response, sizeof(response), + "HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %zu\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n\r\n%s", + status, reason_phrase(status), content_type, body_length, body ? body : ""); + if (count > 0 && (size_t)count < sizeof(response)) { + tcp_write(client->pcb, response, (u16_t)count, TCP_WRITE_FLAG_COPY); + tcp_output(client->pcb); + } + close_client(client); +} + +static void json_message(http_client_t *client, int status, bool ok, const char *message) { + char body[256]; + snprintf(body, sizeof(body), "{\"ok\":%s,\"message\":\"%s\"}\n", + ok ? "true" : "false", message ? message : ""); + reply(client, status, "application/json", body); +} + +static char *trim(char *value) { + while (*value == ' ' || *value == '\t' || *value == '\r' || *value == '\n') ++value; + char *end = value + strlen(value); + while (end > value && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) --end; + *end = '\0'; + return value; +} + +static bool authorized(const char *request) { + const char *header = strstr(request, "Authorization: Bearer "); + if (!header) return false; + header += strlen("Authorization: Bearer "); + const char *end = strstr(header, "\r\n"); + size_t token_length = strlen(server_token); + return end && (size_t)(end - header) == token_length && memcmp(header, server_token, token_length) == 0; +} + +static void status_response(http_client_t *client) { + char body[1024]; + snprintf(body, sizeof(body), + "{\"ok\":true,\"firmware\":\"%s\",\"state\":\"%s\"," + "\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," + "\"eventCount\":%" PRIu32 ",\"repeatCount\":%" PRIu32 "," + "\"currentRepeat\":%" PRIu32 ",\"reportsSubmitted\":%" PRIu64 "," + "\"transfersCompleted\":%" PRIu64 ",\"lateReports\":%" PRIu64 "," + "\"maximumLatenessUs\":%" PRId64 ",\"submittedCRC32\":\"%08" PRIx32 "\"," + "\"usbMounted\":%s,\"wifiConnected\":%s,\"address\":\"%s\"," + "\"error\":\"%s\"}\n", + KEYPATH_FIXTURE_FIRMWARE_VERSION, fixture_state_name(server_fixture->state), + server_fixture->run_id, server_fixture->script_crc32, + server_fixture->event_count, server_fixture->repeat_count, + server_fixture->current_repeat, server_fixture->reports_submitted, + server_fixture->transfers_completed, server_fixture->late_reports, + server_fixture->maximum_lateness_us, server_fixture->submitted_crc32, + server_fixture->usb_mounted ? "true" : "false", + network_connected ? "true" : "false", network_address, server_fixture->error); + reply(client, 200, "application/json", body); +} + +static unsigned int query_number(const char *path, const char *name, unsigned int fallback) { + const char *query = strchr(path, '?'); + if (!query) return fallback; + char needle[32]; + snprintf(needle, sizeof(needle), "%s=", name); + const char *found = strstr(query + 1, needle); + if (!found) return fallback; + return (unsigned int)strtoul(found + strlen(needle), NULL, 10); +} + +static void trace_response(http_client_t *client, const char *path) { + unsigned int from = query_number(path, "from", 0u); + unsigned int limit = query_number(path, "limit", 8u); + if (limit > 8u) limit = 8u; + uint32_t available = fixture_trace_count(server_fixture); + char body[3072]; + size_t used = (size_t)snprintf(body, sizeof(body), + "{\"runId\":\"%s\",\"from\":%u,\"available\":%" PRIu32 "}\n", + server_fixture->run_id, from, available); + for (unsigned int index = 0u; index < limit && from + index < available; ++index) { + const fixture_trace_t *trace = fixture_trace_at(server_fixture, from + index); + int count = snprintf(body + used, sizeof(body) - used, + "{\"sequence\":%" PRIu64 ",\"scheduledUs\":%" PRIu64 "," + "\"submittedUs\":%" PRIu64 ",\"latenessUs\":%" PRId64 "," + "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]}\n", + trace->sequence, trace->scheduled_us, trace->submitted_us, + trace->lateness_us, trace->modifiers, trace->keys[0], trace->keys[1], + trace->keys[2], trace->keys[3], trace->keys[4], trace->keys[5]); + if (count < 0 || (size_t)count >= sizeof(body) - used) break; + used += (size_t)count; + } + reply(client, 200, "application/x-ndjson", body); +} + +static void dispatch(http_client_t *client) { + char method[8] = {0}, path[160] = {0}; + if (sscanf(client->request, "%7s %159s", method, path) != 2) { + json_message(client, 400, false, "invalid request line"); + return; + } + if (!authorized(client->request)) { + json_message(client, 401, false, "bearer token required"); + return; + } + char *header_end = strstr(client->request, "\r\n\r\n"); + char *body = header_end ? header_end + 4 : client->request + client->length; + + if (strcmp(method, "GET") == 0 && strcmp(path, "/v1/status") == 0) { + status_response(client); + } else if (strcmp(method, "GET") == 0 && strncmp(path, "/v1/trace", 9u) == 0) { + trace_response(client, path); + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/script") == 0) { + char error[128]; + size_t body_length = client->length - (size_t)(body - client->request); + if (fixture_load_script(server_fixture, body, body_length, error, sizeof(error))) { + json_message(client, 201, true, "script loaded and CRC verified"); + } else { + json_message(client, 409, false, error); + } + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/arm") == 0) { + char error[128]; + if (fixture_arm(server_fixture, trim(body), error, sizeof(error))) { + json_message(client, 200, true, "fixture armed; safety release queued"); + } else { + json_message(client, 409, false, error); + } + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/start") == 0) { + char run_id[FIXTURE_MAX_RUN_ID + 1u] = {0}; + unsigned int delay_ms = 0u; + char extra = '\0', error[128]; + if (sscanf(trim(body), "%48s %u %c", run_id, &delay_ms, &extra) != 2) { + json_message(client, 400, false, "start body must contain run_id and delay_ms"); + } else if (fixture_start(server_fixture, run_id, delay_ms, time_us_64(), error, sizeof(error))) { + json_message(client, 200, true, "locally timed script scheduled"); + } else { + json_message(client, 409, false, error); + } + } else if (strcmp(method, "POST") == 0 && strcmp(path, "/v1/abort") == 0) { + fixture_abort(server_fixture, "remote abort"); + json_message(client, 200, true, "aborted; all-keys-released report queued"); + } else { + json_message(client, 404, false, "endpoint not found"); + } +} + +static err_t receive_callback(void *argument, struct tcp_pcb *pcb, struct pbuf *packet, err_t error) { + (void)error; + http_client_t *client = argument; + if (!packet) { + close_client(client); + return ERR_OK; + } + if (client->length + packet->tot_len > REQUEST_CAPACITY) { + pbuf_free(packet); + json_message(client, 413, false, "request exceeds fixture capacity"); + return ERR_OK; + } + pbuf_copy_partial(packet, client->request + client->length, packet->tot_len, 0u); + client->length += packet->tot_len; + client->request[client->length] = '\0'; + tcp_recved(pcb, packet->tot_len); + pbuf_free(packet); + + if (!client->headers_parsed) { + char *header_end = strstr(client->request, "\r\n\r\n"); + if (!header_end) return ERR_OK; + client->headers_parsed = true; + size_t header_length = (size_t)(header_end + 4 - client->request); + size_t content_length = 0u; + const char *length_header = strstr(client->request, "Content-Length:"); + if (length_header && length_header < header_end) { + content_length = strtoul(length_header + strlen("Content-Length:"), NULL, 10); + } + client->expected_length = header_length + content_length; + if (client->expected_length > REQUEST_CAPACITY) { + json_message(client, 413, false, "declared body exceeds fixture capacity"); + return ERR_OK; + } + } + if (client->headers_parsed && client->length >= client->expected_length) dispatch(client); + return ERR_OK; +} + +static void error_callback(void *argument, err_t error) { + (void)error; + http_client_t *client = argument; + if (client) client->pcb = NULL; + close_client(client); +} + +static err_t accept_callback(void *argument, struct tcp_pcb *pcb, err_t error) { + (void)argument; + if (error != ERR_OK || !pcb || client_active) { + if (pcb) tcp_abort(pcb); + return ERR_ABRT; + } + http_client_t *client = calloc(1u, sizeof(*client)); + if (!client) { + tcp_abort(pcb); + return ERR_ABRT; + } + client_active = true; + client->pcb = pcb; + tcp_arg(pcb, client); + tcp_recv(pcb, receive_callback); + tcp_err(pcb, error_callback); + return ERR_OK; +} + +bool fixture_http_server_init(fixture_t *fixture, const char *bearer_token, uint16_t port) { + server_fixture = fixture; + server_token = bearer_token; + struct tcp_pcb *server = tcp_new_ip_type(IPADDR_TYPE_ANY); + if (!server) return false; + if (tcp_bind(server, NULL, port) != ERR_OK) { + tcp_close(server); + return false; + } + server = tcp_listen_with_backlog(server, 1u); + if (!server) return false; + tcp_accept(server, accept_callback); + return true; +} + +void fixture_http_set_network(bool connected, const char *address) { + network_connected = connected; + snprintf(network_address, sizeof(network_address), "%s", address ? address : "unassigned"); +} diff --git a/Scripts/lab/pico-hid-fixture/src/http_server.h b/Scripts/lab/pico-hid-fixture/src/http_server.h new file mode 100644 index 000000000..11eaa0ca6 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/http_server.h @@ -0,0 +1,12 @@ +#ifndef KEYPATH_FIXTURE_HTTP_SERVER_H +#define KEYPATH_FIXTURE_HTTP_SERVER_H + +#include +#include + +#include "fixture_core.h" + +bool fixture_http_server_init(fixture_t *fixture, const char *bearer_token, uint16_t port); +void fixture_http_set_network(bool connected, const char *address); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/lwipopts.h b/Scripts/lab/pico-hid-fixture/src/lwipopts.h new file mode 100644 index 000000000..4275a6619 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/lwipopts.h @@ -0,0 +1,40 @@ +#ifndef KEYPATH_LWIPOPTS_H +#define KEYPATH_LWIPOPTS_H + +// Raw lwIP configuration for the Pico SDK's polling CYW43 integration. The +// fixture exposes one small HTTP control server and deliberately has no socket +// or netconn compatibility layer. +#define NO_SYS 1 +#define SYS_LIGHTWEIGHT_PROT 0 + +#define LWIP_RAW 1 +#define LWIP_TCP 1 +#define LWIP_UDP 1 +#define LWIP_DHCP 1 +#define LWIP_DNS 1 +#define LWIP_IGMP 1 +#define LWIP_SOCKET 0 +#define LWIP_NETCONN 0 + +#define LWIP_NETIF_HOSTNAME 1 +#define LWIP_NETIF_STATUS_CALLBACK 1 +#define LWIP_NETIF_LINK_CALLBACK 1 +#define LWIP_DHCP_DOES_ACD_CHECK 0 +#define LWIP_MDNS_RESPONDER 1 +#define LWIP_NUM_NETIF_CLIENT_DATA 1 +#define MDNS_MAX_SERVICES 1 + +#define MEM_ALIGNMENT 4 +#define MEM_SIZE 16000 +#define MEMP_NUM_TCP_PCB 4 +#define MEMP_NUM_TCP_PCB_LISTEN 2 +#define MEMP_NUM_TCP_SEG 16 + +#define TCP_MSS 1460 +#define TCP_WND (4 * TCP_MSS) +#define TCP_SND_BUF (4 * TCP_MSS) +#define TCP_QUEUE_OOSEQ 0 + +#define LWIP_CHKSUM_ALGORITHM 3 + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/main.c b/Scripts/lab/pico-hid-fixture/src/main.c new file mode 100644 index 000000000..da5cf54cb --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/main.c @@ -0,0 +1,120 @@ +#include +#include + +#include "bsp/board_api.h" +#include "fixture_config.h" +#include "fixture_core.h" +#include "hardware/watchdog.h" +#include "http_server.h" +#include "lwip/apps/mdns.h" +#include "lwip/ip4_addr.h" +#include "lwip/netif.h" +#include "pico/cyw43_arch.h" +#include "pico/stdlib.h" +#include "tusb.h" +#include "usb_descriptors.h" + +static fixture_t fixture; +static bool mdns_started; + +static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + (void)context; + if (!tud_hid_ready()) return false; + return tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); +} + +void tud_hid_report_complete_cb(uint8_t instance, uint8_t const *report, uint16_t length) { + (void)instance; + (void)report; + (void)length; + fixture_note_transfer_complete(&fixture); +} + +static bool wifi_is_connected(void) { + return cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA) == CYW43_LINK_UP; +} + +static void announce_fixture(void) { + if (!netif_default) return; + if (!mdns_started) { + mdns_resp_init(); + if (mdns_resp_add_netif(netif_default, "keypath-hid-fixture") != ERR_OK) return; + mdns_resp_add_service(netif_default, "KeyPath HID fixture", "_http", + DNSSD_PROTO_TCP, KEYPATH_FIXTURE_HTTP_PORT, NULL, NULL); + mdns_started = true; + } + mdns_resp_announce(netif_default); +} + +static void update_led(uint64_t now_ms, bool wifi_connected) { + uint32_t phase = (uint32_t)(now_ms % 2000u); + bool on = false; + if (!wifi_connected) { + on = phase < 50u || (phase >= 150u && phase < 200u); + } else { + switch (fixture.state) { + case FIXTURE_ARMED: on = true; break; + case FIXTURE_RUNNING: on = (phase % 200u) < 100u; break; + case FIXTURE_COMPLETE: on = phase < 80u || (phase >= 180u && phase < 260u) || + (phase >= 360u && phase < 440u); break; + case FIXTURE_ERROR: on = (phase % 120u) < 60u; break; + case FIXTURE_LOADED: on = phase < 80u || (phase >= 180u && phase < 260u); break; + default: on = phase < 80u; break; + } + } + cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, on ? 1 : 0); +} + +int main(void) { + board_init(); + fixture_init(&fixture); + + tusb_rhport_init_t usb = { + .role = TUSB_ROLE_DEVICE, + .speed = TUSB_SPEED_FULL, + }; + if (!tud_rhport_init(BOARD_TUD_RHPORT, &usb)) return 1; + board_init_after_tusb(); + + if (cyw43_arch_init()) { + fixture.state = FIXTURE_ERROR; + snprintf(fixture.error, sizeof(fixture.error), "CYW43 initialization failed"); + while (true) { + tud_task(); + fixture_poll(&fixture, time_us_64(), tud_mounted(), tud_hid_ready(), + send_keyboard_report, NULL); + } + } + cyw43_arch_enable_sta_mode(); + cyw43_arch_wifi_connect_async(KEYPATH_WIFI_SSID, KEYPATH_WIFI_PASSWORD, CYW43_AUTH_WPA2_AES_PSK); + if (!fixture_http_server_init(&fixture, KEYPATH_FIXTURE_TOKEN, KEYPATH_FIXTURE_HTTP_PORT)) { + fixture.state = FIXTURE_ERROR; + snprintf(fixture.error, sizeof(fixture.error), "HTTP server initialization failed"); + } + watchdog_enable(8000u, true); + + uint64_t next_wifi_retry_ms = 15000u; + bool previous_wifi = false; + while (true) { + uint64_t now_us = time_us_64(); + uint64_t now_ms = now_us / 1000u; + tud_task(); + cyw43_arch_poll(); + bool connected = wifi_is_connected(); + if (connected != previous_wifi) { + const char *address = "unassigned"; + if (connected && netif_default) address = ip4addr_ntoa(netif_ip4_addr(netif_default)); + fixture_http_set_network(connected, address); + if (connected) announce_fixture(); + previous_wifi = connected; + } + if (!connected && now_ms >= next_wifi_retry_ms) { + cyw43_arch_wifi_connect_async(KEYPATH_WIFI_SSID, KEYPATH_WIFI_PASSWORD, CYW43_AUTH_WPA2_AES_PSK); + next_wifi_retry_ms = now_ms + 15000u; + } + fixture_poll(&fixture, now_us, tud_mounted(), tud_hid_ready(), send_keyboard_report, NULL); + update_led(now_ms, connected); + watchdog_update(); + tight_loop_contents(); + } +} diff --git a/Scripts/lab/pico-hid-fixture/src/tusb_config.h b/Scripts/lab/pico-hid-fixture/src/tusb_config.h new file mode 100644 index 000000000..df4837eea --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/tusb_config.h @@ -0,0 +1,33 @@ +#ifndef KEYPATH_TUSB_CONFIG_H +#define KEYPATH_TUSB_CONFIG_H + +#ifndef BOARD_TUD_RHPORT +#define BOARD_TUD_RHPORT 0 +#endif + +#ifndef BOARD_TUD_MAX_SPEED +#define BOARD_TUD_MAX_SPEED OPT_MODE_DEFAULT_SPEED +#endif + +#ifndef CFG_TUSB_MCU +#error CFG_TUSB_MCU must be defined by the Pico SDK +#endif + +#ifndef CFG_TUSB_OS +#define CFG_TUSB_OS OPT_OS_NONE +#endif + +#ifndef CFG_TUSB_DEBUG +#define CFG_TUSB_DEBUG 0 +#endif +#define CFG_TUD_ENABLED 1 +#define CFG_TUD_MAX_SPEED BOARD_TUD_MAX_SPEED +#define CFG_TUD_ENDPOINT0_SIZE 64 +#define CFG_TUD_HID 1 +#define CFG_TUD_CDC 0 +#define CFG_TUD_MSC 0 +#define CFG_TUD_MIDI 0 +#define CFG_TUD_VENDOR 0 +#define CFG_TUD_HID_EP_BUFSIZE 16 + +#endif diff --git a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c new file mode 100644 index 000000000..06a19660b --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.c @@ -0,0 +1,107 @@ +#include + +#include "bsp/board_api.h" +#include "tusb.h" +#include "usb_descriptors.h" + +#define USB_VID 0xCafe +#define USB_PID 0x4010 +#define USB_BCD 0x0200 +#define EPNUM_HID 0x81 + +tusb_desc_device_t const device_descriptor = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = USB_BCD, + .bDeviceClass = 0, + .bDeviceSubClass = 0, + .bDeviceProtocol = 0, + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + .idVendor = USB_VID, + .idProduct = USB_PID, + .bcdDevice = 0x0100, + .iManufacturer = 1, + .iProduct = 2, + .iSerialNumber = 3, + .bNumConfigurations = 1, +}; + +uint8_t const *tud_descriptor_device_cb(void) { + return (uint8_t const *)&device_descriptor; +} + +uint8_t const hid_report_descriptor[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)) +}; + +uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { + (void)instance; + return hid_report_descriptor; +} + +enum { ITF_NUM_HID, ITF_NUM_TOTAL }; +#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) + +uint8_t const configuration_descriptor[] = { + TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, + TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(ITF_NUM_HID, 0, HID_ITF_PROTOCOL_KEYBOARD, + sizeof(hid_report_descriptor), EPNUM_HID, + CFG_TUD_HID_EP_BUFSIZE, 1), +}; + +uint8_t const *tud_descriptor_configuration_cb(uint8_t index) { + (void)index; + return configuration_descriptor; +} + +static const char *string_descriptors[] = { + (const char[]){0x09, 0x04}, + "KeyPath Lab", + "KeyPath Physical HID Fixture", + NULL, +}; +static uint16_t string_buffer[33]; + +uint16_t const *tud_descriptor_string_cb(uint8_t index, uint16_t language_id) { + (void)language_id; + size_t count = 0u; + if (index == 0u) { + memcpy(&string_buffer[1], string_descriptors[0], 2u); + count = 1u; + } else if (index == 3u) { + count = board_usb_get_serial(string_buffer + 1, 32u); + } else { + if (index >= sizeof(string_descriptors) / sizeof(string_descriptors[0])) return NULL; + const char *value = string_descriptors[index]; + if (!value) return NULL; + count = strlen(value); + if (count > 32u) count = 32u; + for (size_t character = 0u; character < count; ++character) { + string_buffer[character + 1u] = (uint8_t)value[character]; + } + } + string_buffer[0] = (uint16_t)((TUSB_DESC_STRING << 8u) | (2u * count + 2u)); + return string_buffer; +} + +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t *buffer, + uint16_t requested_length) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)requested_length; + return 0u; +} + +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t const *buffer, + uint16_t buffer_size) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)buffer_size; +} diff --git a/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h new file mode 100644 index 000000000..9358ed7c9 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/src/usb_descriptors.h @@ -0,0 +1,6 @@ +#ifndef KEYPATH_USB_DESCRIPTORS_H +#define KEYPATH_USB_DESCRIPTORS_H + +enum { REPORT_ID_KEYBOARD = 1 }; + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore new file mode 100644 index 000000000..55267f8a3 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/.gitignore @@ -0,0 +1,4 @@ +build/ +managed_components/ +sdkconfig +sdkconfig.old diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt new file mode 100644 index 000000000..b54d29a46 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(keypath_esp32_s3_hid_fixture) diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock new file mode 100644 index 000000000..d9cf7c042 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/dependencies.lock @@ -0,0 +1,177 @@ +dependencies: + espressif/cmake_utilities: + component_hash: 351350613ceafba240b761b4ea991e0f231ac7a9f59a9ee901f751bddc0bb18f + dependencies: + - name: idf + require: private + version: '>=4.1' + source: + registry_url: https://components.espressif.com + type: service + version: 0.5.3 + espressif/esp_io_expander: + component_hash: 7b7f91270172e46ca4597e3874f48d8412e280e8c295559f5922ec38656e3e5f + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_panel_io_additions: + component_hash: 1856c8b2c11aaa8a9fd67481582162bcad25f7064661a995f35c2ee8dd10ebcc + dependencies: + - name: espressif/cmake_utilities + registry_url: https://components.espressif.com + require: private + version: 0.* + - name: espressif/esp_io_expander + registry_url: https://components.espressif.com + require: public + version: ^1 + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.0.1~1 + espressif/esp_lcd_touch: + component_hash: 3f85a7d95af876f1a6ecca8eb90a81614890d0f03a038390804e5a77e2caf862 + dependencies: + - name: idf + require: private + version: '>=4.4.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.2.1 + espressif/esp_lcd_touch_cst816s: + component_hash: f9fb32ca8642f802f193e9de582a7e3f073c2a8d949d781494bbd9ebf1d2d8ff + dependencies: + - name: espressif/esp_lcd_touch + registry_url: https://components.espressif.com + require: public + version: ^1.2.0 + - name: idf + require: private + version: '>=5.2' + source: + registry_url: https://components.espressif.com + type: service + version: 1.1.1~2 + espressif/esp_lvgl_port: + component_hash: fb6c1fdfe70d4ca39a035d63ca394a35f17ec84ce16ff98ecb13a54155d75da4 + dependencies: + - name: idf + require: private + version: '>=5.2' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: public + version: '>=8,<10' + source: + registry_url: https://components.espressif.com + type: service + version: 2.8.0~1 + espressif/esp_tinyusb: + component_hash: 9a73a76a6bc17907f6e523342ff6b1e440023e8aac2c11f4691212106ec24e66 + dependencies: + - name: idf + require: private + version: '>=5.0' + - name: espressif/tinyusb + registry_url: https://components.espressif.com + require: public + version: '>=0.17.0~2' + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 2.2.1 + espressif/mdns: + component_hash: e81ca7a7f53ea34e78274df054da692c272e1315572876b237b1748e267c013b + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com/ + type: service + version: 1.11.3 + espressif/tinyusb: + component_hash: a72b7d67472914ab76309340fd50d578b31e310963d45ad0f81144bde3314752 + dependencies: + - name: idf + require: private + version: '>=5.0' + source: + registry_url: https://components.espressif.com + type: service + targets: + - esp32s2 + - esp32s3 + - esp32p4 + - esp32h4 + - esp32s31 + version: 0.21.0~1 + idf: + source: + type: idf + version: 5.5.5 + lvgl/lvgl: + component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 9.5.0 + waveshare/esp32_s3_touch_lcd_1_69: + component_hash: d66d289981e6fcba599dd36e530ab3c863f15206dfd2d0b1fc0d6bbb57efd783 + dependencies: + - name: espressif/esp_lcd_panel_io_additions + registry_url: https://components.espressif.com + require: private + version: ^1 + - name: espressif/esp_lcd_touch_cst816s + registry_url: https://components.espressif.com + require: private + version: '*' + - name: espressif/esp_lvgl_port + registry_url: https://components.espressif.com + require: public + version: ^2 + - name: idf + require: private + version: '>=5.3' + - name: lvgl/lvgl + registry_url: https://components.espressif.com + require: private + version: '>=8,<10' + - name: espressif/usb + registry_url: https://components.espressif.com + require: public + rules: + - if: idf_version >=6.0 + version: ^1.0.0 + source: + registry_url: https://components.espressif.com/ + type: service + targets: + - esp32s3 + version: 2.0.0 +direct_dependencies: +- espressif/esp_tinyusb +- espressif/mdns +- idf +- lvgl/lvgl +- waveshare/esp32_s3_touch_lcd_1_69 +manifest_hash: dd2fe7cb72e099a8802f5d9751108d3c2c674ca0b23accd245ae5f102d4bdefe +target: esp32s3 +version: 2.0.0 diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt new file mode 100644 index 000000000..63650e195 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/CMakeLists.txt @@ -0,0 +1,102 @@ +foreach(secret_name + KEYPATH_WIFI_SSID_1 KEYPATH_WIFI_PASSWORD_1 + KEYPATH_WIFI_SSID_3 KEYPATH_WIFI_PASSWORD_3 + KEYPATH_WIFI_SSID_4 KEYPATH_WIFI_PASSWORD_4 + KEYPATH_HACKER_DOJO_USERNAME KEYPATH_HACKER_DOJO_PASSWORD + KEYPATH_FIXTURE_TOKEN) + if(NOT DEFINED ENV{${secret_name}} OR "$ENV{${secret_name}}" STREQUAL "") + message(FATAL_ERROR "${secret_name} must be supplied in the build environment") + endif() + set(secret_value "$ENV{${secret_name}}") + string(REPLACE "\\" "\\\\" secret_value "${secret_value}") + string(REPLACE "\"" "\\\"" secret_value "${secret_value}") + set(${secret_name}_ESCAPED "${secret_value}") +endforeach() + +string(LENGTH "$ENV{KEYPATH_FIXTURE_TOKEN}" fixture_token_length) +if(fixture_token_length LESS 16) + message(FATAL_ERROR "KEYPATH_FIXTURE_TOKEN must contain at least 16 characters") +endif() + +get_filename_component(component_dir "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) +set(shared_source "${component_dir}/../../../src") +set(fixture_sources + "${component_dir}/app_main.c" + "${component_dir}/fixture_board.c" + "${component_dir}/fixture_display.c" + "${component_dir}/fixture_http.c" + "${component_dir}/fixture_qemu_smoke.c" + "${component_dir}/fixture_runtime.c" + "${shared_source}/fixture_button_feedback.c" + "${shared_source}/fixture_core.c" + "${shared_source}/fixture_demo.c" + "${shared_source}/fixture_presentation.c" + "${shared_source}/fixture_splash_model.c" + "${shared_source}/fixture_ui_model.c" + "${shared_source}/fixture_visual_model.c" + "${shared_source}/fixture_wifi_model.c" +) +file(GLOB fixture_headers + "${component_dir}/*.h" + "${shared_source}/*.h" +) +list(SORT fixture_headers) + +# Identify the exact source state, including uncommitted work, without embedding secrets. +set(fixture_source_fingerprint "") +foreach(source_path IN LISTS fixture_sources) + file(SHA256 "${source_path}" source_hash) + string(APPEND fixture_source_fingerprint "${source_hash}\n") +endforeach() +foreach(header_path IN LISTS fixture_headers) + file(SHA256 "${header_path}" header_hash) + string(APPEND fixture_source_fingerprint "${header_hash}\n") +endforeach() +foreach(configuration_path + "${component_dir}/../sdkconfig.defaults" + "${component_dir}/../partitions.csv" + "${component_dir}/CMakeLists.txt" + "${component_dir}/fixture_config.h.in") + file(SHA256 "${configuration_path}" configuration_hash) + string(APPEND fixture_source_fingerprint "${configuration_hash}\n") +endforeach() + +# The source hashes are evaluated at configure time. Tell CMake that edits to +# any fingerprinted input must trigger a reconfigure so OTA verification never +# mistakes a newly built image for the previous source revision. +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + ${fixture_sources} + ${fixture_headers} +) +string(SHA256 fixture_source_hash "${fixture_source_fingerprint}") +string(SUBSTRING "${fixture_source_hash}" 0 12 KEYPATH_FIXTURE_BUILD_ID) + +set(generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated") +file(MAKE_DIRECTORY "${generated_dir}") +configure_file("${component_dir}/fixture_config.h.in" "${generated_dir}/fixture_config.h" @ONLY) + +idf_component_register( + SRCS ${fixture_sources} + INCLUDE_DIRS + "." + "${shared_source}" + "${generated_dir}" + REQUIRES + app_update + esp_event + esp_http_server + esp_netif + esp_timer + esp_wifi + json + mbedtls + mdns + nvs_flash + wpa_supplicant +) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) + +if("$ENV{KEYPATH_QEMU_SMOKE}" STREQUAL "1") + target_compile_definitions(${COMPONENT_LIB} PRIVATE KEYPATH_QEMU_SMOKE=1) +endif() diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild new file mode 100644 index 000000000..4f486953d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/Kconfig.projbuild @@ -0,0 +1,40 @@ +menu "KeyPath HID fixture" + + config KEYPATH_FIXTURE_BOARD_REVISION + int "Waveshare board hardware revision" + range 1 2 + default 2 + help + Select 2 for current boards. Revision 1 used GPIO33 for the buzzer; + revision 2 uses GPIO42. The LCD, touch, and native USB pins are shared. + + config KEYPATH_FIXTURE_HTTP_PORT + int "Fixture HTTP port" + range 1024 65535 + default 8080 + + config KEYPATH_FIXTURE_BRIGHTNESS + int "Display brightness percent" + range 10 100 + default 85 + + config KEYPATH_FIXTURE_SOUND + bool "Enable state-change tones" + default y + + config KEYPATH_FIXTURE_REDUCED_MOTION + bool "Reduce display motion" + default n + help + Keep state, color, and progress feedback while disabling orbital + movement, pulsing geometry, and the completion particle flourish. + + config KEYPATH_FIXTURE_BOOT_SPLASH + bool "Enable the Hacker Dojo boot splash" + default y + help + Show the short display-native identity splash before the live + fixture state. The splash renderer uses bounded redraw regions, + and OTA validation requires its display heartbeat to complete. + +endmenu diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c new file mode 100644 index 000000000..8e6935478 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/app_main.c @@ -0,0 +1,59 @@ +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_system.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "fixture_board.h" +#include "fixture_display.h" +#include "fixture_http.h" +#include "fixture_qemu_smoke.h" +#include "fixture_runtime.h" + +static const char *TAG = "keypath_fixture"; + +static void validate_ota_boot(void *context) { + (void)context; + const esp_partition_t *running = esp_ota_get_running_partition(); + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(running, &state) != ESP_OK || + state != ESP_OTA_IMG_PENDING_VERIFY) { + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "new OTA image pending validation; waiting for control-plane health"); + for (unsigned int attempt = 0u; attempt < 600u; ++attempt) { + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + if (snapshot.ui.wifi_connected && fixture_network_control_ready() && + fixture_display_is_healthy()) { + ESP_ERROR_CHECK(esp_ota_mark_app_valid_cancel_rollback()); + ESP_LOGI(TAG, "OTA image marked valid after Wi-Fi control plane became healthy"); + vTaskDelete(NULL); + return; + } + vTaskDelay(pdMS_TO_TICKS(100)); + } + + ESP_LOGE(TAG, "new OTA image failed its 60-second control-plane health window; rolling back"); + ESP_ERROR_CHECK(esp_ota_mark_app_invalid_rollback_and_reboot()); + esp_restart(); +} + +void app_main(void) { +#ifdef KEYPATH_QEMU_SMOKE + fixture_qemu_smoke_run(); + return; +#endif + fixture_runtime_init(); + fixture_board_init(); + ESP_ERROR_CHECK(fixture_runtime_start_usb()); + fixture_runtime_start_executor(); + fixture_display_start(); + ESP_ERROR_CHECK(fixture_network_start()); + configASSERT(xTaskCreatePinnedToCore(validate_ota_boot, "fixture_ota_validate", 3072, + NULL, 3, NULL, 0) == pdPASS); + ESP_LOGI(TAG, "KeyPath ESP32-S3 physical HID fixture ready"); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c new file mode 100644 index 000000000..2186a0946 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.c @@ -0,0 +1,139 @@ +#include "fixture_board.h" + +#include + +#include "driver/gpio.h" +#include "driver/ledc.h" +#include "fixture_runtime.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#define FIXTURE_BOOT_BUTTON GPIO_NUM_0 +#define FIXTURE_POWER_BUTTON GPIO_NUM_40 +#if CONFIG_KEYPATH_FIXTURE_BOARD_REVISION == 1 +#define FIXTURE_BUZZER GPIO_NUM_33 +#else +#define FIXTURE_BUZZER GPIO_NUM_42 +#endif + +typedef struct { + uint16_t frequency_hz; + uint16_t duration_ms; +} tone_request_t; + +static QueueHandle_t tone_queue; +static volatile bool button_enabled; +static portMUX_TYPE feedback_lock = portMUX_INITIALIZER_UNLOCKED; +static fixture_board_feedback_t latest_feedback; + +static void publish_feedback(fixture_button_event_t event) { + taskENTER_CRITICAL(&feedback_lock); + latest_feedback.event = event; + latest_feedback.sequence++; + taskEXIT_CRITICAL(&feedback_lock); +} + +static void publish_boot_state(bool held, bool download_hint) { + taskENTER_CRITICAL(&feedback_lock); + latest_feedback.boot_held = held; + if (held) latest_feedback.download_hint = download_hint; + taskEXIT_CRITICAL(&feedback_lock); +} + +static void board_task(void *context) { + (void)context; + bool previous_boot_pressed = false; + bool previous_power_pressed = false; + while (true) { + tone_request_t tone; + if (xQueueReceive(tone_queue, &tone, pdMS_TO_TICKS(10)) == pdTRUE) { +#if CONFIG_KEYPATH_FIXTURE_SOUND + ledc_set_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_2, tone.frequency_hz); + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2, 128u); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2); + vTaskDelay(pdMS_TO_TICKS(tone.duration_ms)); + ledc_stop(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_2, 0u); +#endif + } + bool boot_pressed = gpio_get_level(FIXTURE_BOOT_BUTTON) == 0; + bool power_pressed = gpio_get_level(FIXTURE_POWER_BUTTON) == 0; + if (boot_pressed != previous_boot_pressed) { + publish_boot_state(boot_pressed, !button_enabled); + } + if (boot_pressed && !previous_boot_pressed) { + publish_feedback(FIXTURE_BUTTON_BOOT); + if (button_enabled) { + fixture_runtime_abort("physical button abort"); + fixture_board_tone(220u, 90u); + } else { + fixture_board_tone(760u, 55u); + } + } + if (power_pressed && !previous_power_pressed) { + publish_feedback(FIXTURE_BUTTON_POWER); + char error[128]; + if (!button_enabled && fixture_runtime_prepare_demo(error, sizeof(error))) { + fixture_board_tone(760u, 70u); + } else { + fixture_board_tone(520u, 55u); + } + } + previous_boot_pressed = boot_pressed; + previous_power_pressed = power_pressed; + } +} + +void fixture_board_init(void) { + gpio_config_t button = { + .pin_bit_mask = (1ULL << FIXTURE_BOOT_BUTTON) | (1ULL << FIXTURE_POWER_BUTTON), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_ERROR_CHECK(gpio_config(&button)); +#if CONFIG_KEYPATH_FIXTURE_SOUND + ledc_timer_config_t timer = { + .speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LEDC_TIMER_8_BIT, + .timer_num = LEDC_TIMER_2, + .freq_hz = 880, + .clk_cfg = LEDC_AUTO_CLK, + }; + ledc_channel_config_t channel = { + .gpio_num = FIXTURE_BUZZER, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LEDC_CHANNEL_2, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LEDC_TIMER_2, + .duty = 0, + .hpoint = 0, + }; + ESP_ERROR_CHECK(ledc_timer_config(&timer)); + ESP_ERROR_CHECK(ledc_channel_config(&channel)); +#endif + tone_queue = xQueueCreate(4u, sizeof(tone_request_t)); + configASSERT(tone_queue); + /* CHIP_PU reset cannot draw before the CPU stops, so acknowledge it after every restart. */ + publish_feedback(FIXTURE_BUTTON_RESET); + configASSERT(xTaskCreatePinnedToCore(board_task, "fixture_board", 3072, NULL, 5, NULL, 0) == pdPASS); +} + +void fixture_board_tone(unsigned int frequency_hz, unsigned int duration_ms) { + if (!tone_queue || frequency_hz > UINT16_MAX || duration_ms > UINT16_MAX) return; + tone_request_t request = {(uint16_t)frequency_hz, (uint16_t)duration_ms}; + xQueueSend(tone_queue, &request, 0u); +} + +void fixture_board_update(bool armed_or_running) { + button_enabled = armed_or_running; +} + +void fixture_board_feedback_snapshot(fixture_board_feedback_t *feedback) { + if (!feedback) return; + taskENTER_CRITICAL(&feedback_lock); + *feedback = latest_feedback; + taskEXIT_CRITICAL(&feedback_lock); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h new file mode 100644 index 000000000..2c7b8fa1c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_board.h @@ -0,0 +1,21 @@ +#ifndef KEYPATH_ESP32_FIXTURE_BOARD_H +#define KEYPATH_ESP32_FIXTURE_BOARD_H + +#include +#include + +#include "fixture_button_feedback.h" + +typedef struct { + fixture_button_event_t event; + uint32_t sequence; + bool boot_held; + bool download_hint; +} fixture_board_feedback_t; + +void fixture_board_init(void); +void fixture_board_tone(unsigned int frequency_hz, unsigned int duration_ms); +void fixture_board_update(bool armed_or_running); +void fixture_board_feedback_snapshot(fixture_board_feedback_t *feedback); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in new file mode 100644 index 000000000..d7d33047a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_config.h.in @@ -0,0 +1,17 @@ +#ifndef KEYPATH_ESP32_FIXTURE_CONFIG_H +#define KEYPATH_ESP32_FIXTURE_CONFIG_H + +#define KEYPATH_WIFI_SSID_1 "@KEYPATH_WIFI_SSID_1_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_1 "@KEYPATH_WIFI_PASSWORD_1_ESCAPED@" +#define KEYPATH_WIFI_SSID_3 "@KEYPATH_WIFI_SSID_3_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_3 "@KEYPATH_WIFI_PASSWORD_3_ESCAPED@" +#define KEYPATH_WIFI_SSID_4 "@KEYPATH_WIFI_SSID_4_ESCAPED@" +#define KEYPATH_WIFI_PASSWORD_4 "@KEYPATH_WIFI_PASSWORD_4_ESCAPED@" +#define KEYPATH_HACKER_DOJO_SSID "Hacker Dojo" +#define KEYPATH_HACKER_DOJO_USERNAME "@KEYPATH_HACKER_DOJO_USERNAME_ESCAPED@" +#define KEYPATH_HACKER_DOJO_PASSWORD "@KEYPATH_HACKER_DOJO_PASSWORD_ESCAPED@" +#define KEYPATH_FIXTURE_TOKEN "@KEYPATH_FIXTURE_TOKEN_ESCAPED@" +#define KEYPATH_FIXTURE_FIRMWARE_VERSION "0.3.0-esp32s3" +#define KEYPATH_FIXTURE_BUILD_ID "@KEYPATH_FIXTURE_BUILD_ID@" + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c new file mode 100644 index 000000000..46f4f4935 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.c @@ -0,0 +1,821 @@ +#include "fixture_display.h" + +#include +#include +#include + +#include "bsp/esp32_s3_touch_lcd_1_69.h" +#include "esp_timer.h" +#include "fixture_board.h" +#include "fixture_runtime.h" +#include "fixture_splash_model.h" +#include "fixture_visual_model.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lvgl.h" +#include "sdkconfig.h" + +#define PARTICLE_COUNT 12u +#define KEY_COUNT 6u +#define DOJO_BAR_COUNT 4u +#define SPLASH_FRAME_INTERVAL_MS 33u +#define DISPLAY_HEARTBEAT_MAX_AGE_MS 2000u + +typedef struct { + lv_obj_t *screen; + lv_obj_t *halo_outer; + lv_obj_t *halo_inner; + lv_obj_t *orbit; + lv_obj_t *progress; + lv_obj_t *core; + lv_obj_t *brand_keycap; + lv_obj_t *brand_light; + lv_obj_t *brand_fill; + lv_obj_t *brand_lid; + lv_obj_t *brand_glint_horizontal; + lv_obj_t *brand_glint_vertical; + lv_obj_t *icon_front; + lv_obj_t *icon_back; + lv_obj_t *bear_ear_left; + lv_obj_t *bear_ear_right; + lv_obj_t *bear_head; + lv_obj_t *bear_muzzle; + lv_obj_t *bear_eye_left; + lv_obj_t *bear_eye_right; + lv_obj_t *bear_nose; + lv_obj_t *keys[KEY_COUNT]; + lv_obj_t *particles[PARTICLE_COUNT]; + lv_obj_t *eyebrow; + lv_obj_t *state; + lv_obj_t *detail; + lv_obj_t *quality; + fixture_ui_scene_t previous_scene; + fixture_result_t previous_result; + int visual_stage; + float phase; + uint64_t previous_render_ms; + uint64_t icon_transition_started_ms; + uint64_t completion_started_ms; + uint64_t button_feedback_started_ms; + uint32_t button_feedback_sequence; + fixture_button_event_t button_feedback_event; +} display_ui_t; + +typedef struct { + lv_obj_t *root; + lv_obj_t *glow; + lv_obj_t *ring; + lv_obj_t *logo; + lv_obj_t *bars[DOJO_BAR_COUNT]; + lv_obj_t *wordmark; + lv_obj_t *location; +} display_splash_t; + +static display_ui_t ui; +static display_splash_t splash; +static portMUX_TYPE display_health_lock = portMUX_INITIALIZER_UNLOCKED; +static fixture_display_health_t display_health; + +static void note_display_frame(uint64_t now_ms) { + taskENTER_CRITICAL(&display_health_lock); + display_health.frame_sequence++; + display_health.last_frame_ms = now_ms; + taskEXIT_CRITICAL(&display_health_lock); +} + +static void note_display_initialized(bool splash_enabled, bool splash_complete) { + taskENTER_CRITICAL(&display_health_lock); + display_health.initialized = true; + display_health.splash_enabled = splash_enabled; + display_health.splash_complete = splash_complete; + taskEXIT_CRITICAL(&display_health_lock); +} + +static void note_splash_complete(void) { + taskENTER_CRITICAL(&display_health_lock); + display_health.splash_complete = true; + taskEXIT_CRITICAL(&display_health_lock); +} + +static const char *symbol_for(fixture_visual_icon_t icon) { + switch (icon) { + case FIXTURE_ICON_POWER: return LV_SYMBOL_POWER; + case FIXTURE_ICON_WIFI: return LV_SYMBOL_WIFI; + case FIXTURE_ICON_KEYBOARD: return LV_SYMBOL_KEYBOARD; + case FIXTURE_ICON_DOWNLOAD: return LV_SYMBOL_DOWNLOAD; + case FIXTURE_ICON_WARNING: return LV_SYMBOL_WARNING; + case FIXTURE_ICON_PLAY: return LV_SYMBOL_PLAY; + case FIXTURE_ICON_OK: return LV_SYMBOL_OK; + case FIXTURE_ICON_STOP: return LV_SYMBOL_STOP; + case FIXTURE_ICON_CLOSE: return LV_SYMBOL_CLOSE; + case FIXTURE_ICON_REFRESH: return LV_SYMBOL_REFRESH; + case FIXTURE_ICON_BELL: return LV_SYMBOL_BELL; + case FIXTURE_ICON_EYE: return LV_SYMBOL_EYE_OPEN; + case FIXTURE_ICON_SETTINGS: return LV_SYMBOL_SETTINGS; + case FIXTURE_ICON_NEXT: return LV_SYMBOL_NEXT; + } + return LV_SYMBOL_WARNING; +} + +static lv_obj_t *make_circle(lv_obj_t *parent, int size, lv_color_t color, lv_opa_t opacity) { + lv_obj_t *object = lv_obj_create(parent); + lv_obj_remove_style_all(object); + lv_obj_set_size(object, size, size); + lv_obj_set_style_radius(object, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(object, color, 0); + lv_obj_set_style_bg_opa(object, opacity, 0); + lv_obj_clear_flag(object, LV_OBJ_FLAG_CLICKABLE); + return object; +} + +static lv_obj_t *make_rect(lv_obj_t *parent, int x, int y, int width, int height, + lv_color_t color) { + lv_obj_t *object = lv_obj_create(parent); + lv_obj_remove_style_all(object); + lv_obj_set_pos(object, x, y); + lv_obj_set_size(object, width, height); + lv_obj_set_style_radius(object, 2, 0); + lv_obj_set_style_bg_color(object, color, 0); + lv_obj_set_style_bg_opa(object, LV_OPA_COVER, 0); + lv_obj_clear_flag(object, LV_OBJ_FLAG_CLICKABLE); + return object; +} + +static void set_hidden(lv_obj_t *object, bool hidden) { + if (hidden) { + lv_obj_add_flag(object, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_clear_flag(object, LV_OBJ_FLAG_HIDDEN); + } +} + +static void touch_event(lv_event_t *event) { + if (lv_event_get_code(event) != LV_EVENT_PRESSED) return; + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + if (snapshot.ui.state == FIXTURE_ARMED) { + char error[128]; + if (fixture_runtime_start_demo(error, sizeof(error))) { + fixture_board_tone(980u, 70u); + return; + } + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x241323), 0); + fixture_runtime_abort("touch abort"); + fixture_board_tone(220u, 90u); + } else if (snapshot.ui.state == FIXTURE_RUNNING) { + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x241323), 0); + fixture_runtime_abort("touch abort"); + fixture_board_tone(220u, 90u); + } +} + +static void build_ui(void) { + ui.screen = lv_screen_active(); + lv_obj_remove_style_all(ui.screen); + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x071117), 0); + lv_obj_set_style_bg_opa(ui.screen, LV_OPA_COVER, 0); + lv_obj_add_flag(ui.screen, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(ui.screen, touch_event, LV_EVENT_PRESSED, NULL); + + ui.eyebrow = lv_label_create(ui.screen); + lv_label_set_text_static(ui.eyebrow, "KEYPATH / HID ORACLE"); + lv_obj_set_style_text_font(ui.eyebrow, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ui.eyebrow, lv_color_hex(0x71909d), 0); + lv_obj_set_style_text_letter_space(ui.eyebrow, 2, 0); + lv_obj_align(ui.eyebrow, LV_ALIGN_TOP_MID, 0, 15); + + ui.halo_outer = make_circle(ui.screen, 154, lv_color_hex(0x173946), LV_OPA_20); + lv_obj_align(ui.halo_outer, LV_ALIGN_CENTER, 0, -2); + ui.halo_inner = make_circle(ui.screen, 124, lv_color_hex(0x153541), LV_OPA_30); + lv_obj_align(ui.halo_inner, LV_ALIGN_CENTER, 0, -2); + + ui.orbit = lv_arc_create(ui.screen); + lv_obj_set_size(ui.orbit, 142, 142); + lv_obj_align(ui.orbit, LV_ALIGN_CENTER, 0, -2); + lv_arc_set_bg_angles(ui.orbit, 0, 360); + lv_arc_set_angles(ui.orbit, 20, 104); + lv_obj_set_style_arc_width(ui.orbit, 2, LV_PART_MAIN); + lv_obj_set_style_arc_color(ui.orbit, lv_color_hex(0x183943), LV_PART_MAIN); + lv_obj_set_style_arc_width(ui.orbit, 4, LV_PART_INDICATOR); + lv_obj_set_style_arc_rounded(ui.orbit, true, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(ui.orbit, LV_OPA_TRANSP, LV_PART_KNOB); + lv_obj_clear_flag(ui.orbit, LV_OBJ_FLAG_CLICKABLE); + + ui.progress = lv_arc_create(ui.screen); + lv_obj_set_size(ui.progress, 112, 112); + lv_obj_align(ui.progress, LV_ALIGN_CENTER, 0, -2); + lv_arc_set_range(ui.progress, 0, 1000); + lv_arc_set_bg_angles(ui.progress, 135, 45); + lv_arc_set_value(ui.progress, 0); + lv_obj_set_style_arc_width(ui.progress, 5, LV_PART_MAIN); + lv_obj_set_style_arc_color(ui.progress, lv_color_hex(0x18343e), LV_PART_MAIN); + lv_obj_set_style_arc_width(ui.progress, 5, LV_PART_INDICATOR); + lv_obj_set_style_arc_rounded(ui.progress, true, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(ui.progress, LV_OPA_TRANSP, LV_PART_KNOB); + lv_obj_clear_flag(ui.progress, LV_OBJ_FLAG_CLICKABLE); + + ui.core = make_circle(ui.screen, 76, lv_color_hex(0x0d2028), LV_OPA_COVER); + lv_obj_set_style_border_width(ui.core, 1, 0); + lv_obj_set_style_border_color(ui.core, lv_color_hex(0x2f6970), 0); + lv_obj_align(ui.core, LV_ALIGN_CENTER, 0, -2); + + ui.icon_back = lv_label_create(ui.screen); + lv_label_set_text_static(ui.icon_back, LV_SYMBOL_POWER); + lv_obj_set_style_text_font(ui.icon_back, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_opa(ui.icon_back, LV_OPA_TRANSP, 0); + lv_obj_align(ui.icon_back, LV_ALIGN_CENTER, 0, -2); + ui.icon_front = lv_label_create(ui.screen); + lv_label_set_text_static(ui.icon_front, LV_SYMBOL_POWER); + lv_obj_set_style_text_font(ui.icon_front, &lv_font_montserrat_20, 0); + lv_obj_align(ui.icon_front, LV_ALIGN_CENTER, 0, -2); + + /* Display-native Bear identity for Typover's app-specific physical tests. */ + ui.bear_ear_left = make_circle(ui.screen, 27, lv_color_hex(0xe34c42), LV_OPA_COVER); + lv_obj_align(ui.bear_ear_left, LV_ALIGN_CENTER, -27, -28); + ui.bear_ear_right = make_circle(ui.screen, 27, lv_color_hex(0xe34c42), LV_OPA_COVER); + lv_obj_align(ui.bear_ear_right, LV_ALIGN_CENTER, 27, -28); + ui.bear_head = make_circle(ui.screen, 78, lv_color_hex(0xe34c42), LV_OPA_COVER); + lv_obj_set_style_shadow_color(ui.bear_head, lv_color_hex(0xf18c49), 0); + lv_obj_set_style_shadow_width(ui.bear_head, 18, 0); + lv_obj_set_style_shadow_opa(ui.bear_head, LV_OPA_20, 0); + lv_obj_align(ui.bear_head, LV_ALIGN_CENTER, 0, -2); + + ui.bear_muzzle = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.bear_muzzle); + lv_obj_set_size(ui.bear_muzzle, 45, 29); + lv_obj_set_style_radius(ui.bear_muzzle, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ui.bear_muzzle, lv_color_hex(0xffeee0), 0); + lv_obj_set_style_bg_opa(ui.bear_muzzle, LV_OPA_COVER, 0); + lv_obj_align(ui.bear_muzzle, LV_ALIGN_CENTER, 0, 11); + lv_obj_clear_flag(ui.bear_muzzle, LV_OBJ_FLAG_CLICKABLE); + + ui.bear_eye_left = make_circle(ui.screen, 6, lv_color_hex(0x421f20), LV_OPA_COVER); + lv_obj_align(ui.bear_eye_left, LV_ALIGN_CENTER, -14, -10); + ui.bear_eye_right = make_circle(ui.screen, 6, lv_color_hex(0x421f20), LV_OPA_COVER); + lv_obj_align(ui.bear_eye_right, LV_ALIGN_CENTER, 14, -10); + ui.bear_nose = make_circle(ui.screen, 10, lv_color_hex(0x421f20), LV_OPA_COVER); + lv_obj_align(ui.bear_nose, LV_ALIGN_CENTER, 0, 6); + + set_hidden(ui.bear_ear_left, true); + set_hidden(ui.bear_ear_right, true); + set_hidden(ui.bear_head, true); + set_hidden(ui.bear_muzzle, true); + set_hidden(ui.bear_eye_left, true); + set_hidden(ui.bear_eye_right, true); + set_hidden(ui.bear_nose, true); + + for (size_t index = 0; index < KEY_COUNT; ++index) { + ui.keys[index] = lv_obj_create(ui.core); + lv_obj_remove_style_all(ui.keys[index]); + lv_obj_set_size(ui.keys[index], 17, 14); + lv_obj_set_style_radius(ui.keys[index], 4, 0); + lv_obj_set_style_bg_color(ui.keys[index], lv_color_hex(0x56ddb3), 0); + lv_obj_set_style_bg_opa(ui.keys[index], LV_OPA_30, 0); + int column = (int)(index % 3u); + int row = (int)(index / 3u); + lv_obj_set_pos(ui.keys[index], 8 + column * 21, 17 + row * 19); + } + + /* Display-native reconstruction of KeyPath's opened, illuminated keycap. */ + ui.brand_keycap = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.brand_keycap); + lv_obj_set_size(ui.brand_keycap, 78, 62); + lv_obj_set_style_radius(ui.brand_keycap, 17, 0); + lv_obj_set_style_bg_color(ui.brand_keycap, lv_color_hex(0xd95b18), 0); + lv_obj_set_style_bg_opa(ui.brand_keycap, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(ui.brand_keycap, lv_color_hex(0xf3a128), 0); + lv_obj_set_style_shadow_width(ui.brand_keycap, 18, 0); + lv_obj_set_style_shadow_opa(ui.brand_keycap, LV_OPA_20, 0); + lv_obj_align(ui.brand_keycap, LV_ALIGN_CENTER, 0, 5); + lv_obj_clear_flag(ui.brand_keycap, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_light = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.brand_light); + lv_obj_set_size(ui.brand_light, 54, 38); + lv_obj_set_style_radius(ui.brand_light, 12, 0); + lv_obj_set_style_bg_color(ui.brand_light, lv_color_hex(0xfff2c9), 0); + lv_obj_set_style_bg_opa(ui.brand_light, LV_OPA_COVER, 0); + lv_obj_align(ui.brand_light, LV_ALIGN_CENTER, 0, 2); + lv_obj_clear_flag(ui.brand_light, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_fill = lv_obj_create(ui.brand_light); + lv_obj_remove_style_all(ui.brand_fill); + lv_obj_set_size(ui.brand_fill, 54, 1); + lv_obj_set_style_radius(ui.brand_fill, 10, 0); + lv_obj_set_style_bg_color(ui.brand_fill, lv_color_hex(0xf3a128), 0); + lv_obj_set_style_bg_opa(ui.brand_fill, LV_OPA_60, 0); + lv_obj_align(ui.brand_fill, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_clear_flag(ui.brand_fill, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_lid = lv_obj_create(ui.screen); + lv_obj_remove_style_all(ui.brand_lid); + lv_obj_set_size(ui.brand_lid, 68, 27); + lv_obj_set_style_radius(ui.brand_lid, 10, 0); + lv_obj_set_style_bg_color(ui.brand_lid, lv_color_hex(0xf3a128), 0); + lv_obj_set_style_bg_opa(ui.brand_lid, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(ui.brand_lid, lv_color_hex(0xffd36a), 0); + lv_obj_set_style_shadow_width(ui.brand_lid, 12, 0); + lv_obj_set_style_shadow_opa(ui.brand_lid, LV_OPA_20, 0); + lv_obj_align(ui.brand_lid, LV_ALIGN_CENTER, 0, -29); + lv_obj_clear_flag(ui.brand_lid, LV_OBJ_FLAG_CLICKABLE); + + ui.brand_glint_horizontal = make_rect( + ui.screen, 0, 0, 12, 2, lv_color_hex(0xfff7df)); + ui.brand_glint_vertical = make_rect( + ui.screen, 0, 0, 2, 12, lv_color_hex(0xfff7df)); + lv_obj_set_style_radius(ui.brand_glint_horizontal, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_radius(ui.brand_glint_vertical, LV_RADIUS_CIRCLE, 0); + + set_hidden(ui.brand_keycap, true); + set_hidden(ui.brand_light, true); + set_hidden(ui.brand_lid, true); + set_hidden(ui.brand_glint_horizontal, true); + set_hidden(ui.brand_glint_vertical, true); + + for (size_t index = 0; index < PARTICLE_COUNT; ++index) { + ui.particles[index] = make_circle(ui.screen, index % 3u == 0u ? 6 : 4, + lv_color_hex(0x56ddb3), LV_OPA_50); + } + + ui.state = lv_label_create(ui.screen); + lv_label_set_text_static(ui.state, "WAKING UP"); + lv_obj_set_style_text_font(ui.state, &lv_font_montserrat_24, 0); + lv_obj_set_style_text_color(ui.state, lv_color_hex(0xe9f7f4), 0); + lv_obj_set_width(ui.state, 220); + lv_label_set_long_mode(ui.state, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(ui.state, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(ui.state, LV_ALIGN_BOTTOM_MID, 0, -40); + + ui.detail = lv_label_create(ui.screen); + lv_label_set_text_static(ui.detail, "USB + Wi-Fi control"); + lv_obj_set_style_text_font(ui.detail, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(ui.detail, lv_color_hex(0x78909a), 0); + lv_obj_set_width(ui.detail, 220); + lv_label_set_long_mode(ui.detail, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(ui.detail, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(ui.detail, LV_ALIGN_BOTTOM_MID, 0, -20); + + ui.quality = lv_label_create(ui.screen); + lv_label_set_text_static(ui.quality, "CINEMATIC"); + lv_obj_set_style_text_font(ui.quality, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x52727e), 0); + lv_obj_set_style_text_letter_space(ui.quality, 1, 0); + lv_obj_align(ui.quality, LV_ALIGN_TOP_RIGHT, -10, 38); + ui.previous_scene = FIXTURE_UI_BOOT; + ui.visual_stage = -1; +} + +static void build_splash(void) { + lv_color_t dojo_red = lv_color_hex(0xe13838); + lv_color_t white = lv_color_hex(0xffffff); + + splash.root = lv_obj_create(ui.screen); + lv_obj_remove_style_all(splash.root); + lv_obj_set_size(splash.root, LV_PCT(100), LV_PCT(100)); + lv_obj_align(splash.root, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_color(splash.root, lv_color_hex(0x080c10), 0); + lv_obj_set_style_bg_opa(splash.root, LV_OPA_COVER, 0); + lv_obj_clear_flag(splash.root, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE); + + /* + * Keep the glow inside a bounded redraw region. Transforming the previous + * 154 px object at 60 FPS invalidated most of the 240 x 280 display and + * could starve the LVGL flush task on the physical SPI panel. + */ + splash.glow = make_circle(splash.root, 132, dojo_red, LV_OPA_TRANSP); + lv_obj_align(splash.glow, LV_ALIGN_CENTER, 0, -27); + + splash.ring = make_circle(splash.root, 118, dojo_red, LV_OPA_TRANSP); + lv_obj_set_style_border_width(splash.ring, 2, 0); + lv_obj_set_style_border_color(splash.ring, dojo_red, 0); + lv_obj_set_style_border_opa(splash.ring, LV_OPA_TRANSP, 0); + lv_obj_align(splash.ring, LV_ALIGN_CENTER, 0, -27); + + splash.logo = lv_obj_create(splash.root); + lv_obj_remove_style_all(splash.logo); + lv_obj_set_size(splash.logo, 92, 92); + lv_obj_set_style_radius(splash.logo, 24, 0); + lv_obj_set_style_bg_color(splash.logo, dojo_red, 0); + lv_obj_set_style_bg_opa(splash.logo, LV_OPA_TRANSP, 0); + lv_obj_align(splash.logo, LV_ALIGN_CENTER, 0, -27); + + /* Faithful, display-native reconstruction of hackerdojo.org/static/images/logo.png. */ + splash.bars[0] = make_rect(splash.logo, 20, 29, 52, 5, white); + splash.bars[1] = make_rect(splash.logo, 16, 40, 60, 5, white); + splash.bars[2] = make_rect(splash.logo, 31, 26, 5, 41, white); + splash.bars[3] = make_rect(splash.logo, 56, 26, 5, 41, white); + + splash.wordmark = lv_label_create(splash.root); + lv_label_set_text_static(splash.wordmark, "HACKER DOJO"); + lv_obj_set_style_text_font(splash.wordmark, &lv_font_montserrat_24, 0); + lv_obj_set_style_text_color(splash.wordmark, white, 0); + lv_obj_set_style_text_letter_space(splash.wordmark, 3, 0); + lv_obj_set_style_text_opa(splash.wordmark, LV_OPA_TRANSP, 0); + lv_obj_align(splash.wordmark, LV_ALIGN_CENTER, 0, 48); + + splash.location = lv_label_create(splash.root); + lv_label_set_text_static(splash.location, "MOUNTAIN VIEW / SINCE 2009"); + lv_obj_set_style_text_font(splash.location, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(splash.location, lv_color_hex(0x9aa7ad), 0); + lv_obj_set_style_text_letter_space(splash.location, 1, 0); + lv_obj_set_style_text_opa(splash.location, LV_OPA_TRANSP, 0); + lv_obj_align(splash.location, LV_ALIGN_CENTER, 0, 74); +} + +static void render_splash(const fixture_splash_output_t *output, uint64_t elapsed_ms) { + lv_opa_t foreground = (lv_opa_t)output->foreground_opacity; + /* + * Do not animate opacity on the full-screen root. The logo and wordmark + * still fade out before the root is deleted, but every frame now redraws + * only the splash artwork rather than the entire panel. + */ + lv_obj_set_style_bg_opa(splash.root, LV_OPA_COVER, 0); + lv_obj_set_style_bg_opa(splash.logo, foreground, 0); + for (size_t index = 0; index < DOJO_BAR_COUNT; ++index) { + lv_obj_set_style_bg_opa(splash.bars[index], foreground, 0); + } + + lv_obj_set_style_text_opa(splash.wordmark, (lv_opa_t)output->wordmark_opacity, 0); + lv_obj_set_style_text_opa(splash.location, + (lv_opa_t)(output->wordmark_opacity * 3u / 4u), 0); + int wordmark_offset = 5 - (int)(output->wordmark_opacity * 5u / 255u); + lv_obj_set_style_translate_y(splash.wordmark, wordmark_offset, 0); + lv_obj_set_style_translate_y(splash.location, wordmark_offset, 0); + + uint16_t ring_phase = (uint16_t)(elapsed_ms % 800u); + uint16_t ring_fade = (uint16_t)(255u - ring_phase * 255u / 800u); + lv_obj_set_style_border_opa( + splash.ring, + (lv_opa_t)(output->foreground_opacity * ring_fade * 44u / (255u * 255u)), 0); + + int glow_pulse = (int)(sinf((float)elapsed_ms / 230.0f) * 4.0f); + lv_obj_set_style_bg_opa(splash.glow, + (lv_opa_t)(output->foreground_opacity * + (uint32_t)(22 + glow_pulse) / 255u), 0); +} + +static void play_splash(void) { + uint64_t started_ms = (uint64_t)(esp_timer_get_time() / 1000); + while (true) { + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + uint64_t elapsed_ms = now_ms >= started_ms ? now_ms - started_ms : 0u; + fixture_splash_output_t output = fixture_splash_step(elapsed_ms); + bool finished = false; + if (bsp_display_lock(20u)) { + render_splash(&output, elapsed_ms); + note_display_frame(now_ms); + if (output.complete) { + lv_obj_delete(splash.root); + splash.root = NULL; + note_splash_complete(); + finished = true; + } + bsp_display_unlock(); + } + if (finished) return; + vTaskDelay(pdMS_TO_TICKS(SPLASH_FRAME_INTERVAL_MS)); + } +} + +static void announce_transition(const fixture_ui_output_t *output) { + if (output->scene == ui.previous_scene) return; + switch (output->scene) { + case FIXTURE_UI_LOADED: fixture_board_tone(660u, 45u); break; + case FIXTURE_UI_ARMED: fixture_board_tone(880u, 70u); break; + case FIXTURE_UI_RUNNING: fixture_board_tone(1100u, 50u); break; + case FIXTURE_UI_COMPLETE: + fixture_board_tone(880u, 55u); + fixture_board_tone(1320u, 90u); + break; + case FIXTURE_UI_ERROR: fixture_board_tone(180u, 180u); break; + default: break; + } + ui.previous_scene = output->scene; +} + +static void announce_result(fixture_result_t result) { + if (result == ui.previous_result) return; + if (result == FIXTURE_RESULT_PASS) { + fixture_board_tone(880u, 55u); + fixture_board_tone(1320u, 95u); + } else if (result == FIXTURE_RESULT_FAIL) { + fixture_board_tone(180u, 190u); + } else if (result == FIXTURE_RESULT_INCONCLUSIVE) { + fixture_board_tone(540u, 80u); + } + ui.previous_result = result; +} + +static void render_button_feedback(uint64_t now_ms) { + fixture_board_feedback_t feedback; + fixture_board_feedback_snapshot(&feedback); + if (feedback.sequence != ui.button_feedback_sequence) { + ui.button_feedback_sequence = feedback.sequence; + ui.button_feedback_event = feedback.event; + ui.button_feedback_started_ms = now_ms; + } + + uint64_t elapsed_ms = now_ms >= ui.button_feedback_started_ms + ? now_ms - ui.button_feedback_started_ms : 0u; + fixture_button_feedback_output_t output = + fixture_button_feedback_resolve(ui.button_feedback_event, feedback.boot_held, + feedback.download_hint, elapsed_ms); + if (!output.active) return; + + lv_color_t accent = lv_color_hex(output.accent_rgb); + lv_label_set_text(ui.state, output.title); + lv_label_set_text(ui.detail, output.detail); + lv_label_set_text_static(ui.quality, "BUTTON INPUT"); + lv_obj_set_style_text_color(ui.state, accent, 0); + lv_obj_set_style_text_color(ui.quality, accent, 0); + lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(ui.progress, accent, LV_PART_INDICATOR); + lv_obj_set_style_border_color(ui.core, accent, 0); + lv_obj_set_style_bg_color(ui.halo_inner, accent, 0); + lv_obj_set_style_bg_color(ui.halo_outer, accent, 0); + lv_obj_set_style_bg_opa(ui.halo_inner, + (lv_opa_t)(24u + output.pulse_per_mille * 40u / 1000u), 0); + lv_obj_set_style_bg_opa(ui.halo_outer, + (lv_opa_t)(12u + output.pulse_per_mille * 24u / 1000u), 0); + lv_obj_set_style_transform_scale(ui.state, + 256 + (int)(output.pulse_per_mille * 10u / 1000u), 0); +} + +static void render(const fixture_ui_output_t *output, + const fixture_presentation_t *presentation, + const char *automatic_detail, uint64_t now_ms) { + fixture_visual_output_t visual; + fixture_visual_resolve(output, presentation, &visual); + lv_color_t accent = lv_color_hex(visual.accent_rgb); + int energy = output->energy_per_mille; + uint64_t elapsed_ms = ui.previous_render_ms && now_ms > ui.previous_render_ms + ? now_ms - ui.previous_render_ms : output->frame_interval_ms; + if (elapsed_ms > 250u) elapsed_ms = 250u; + float speed = (float)visual.angular_speed_milliradians / 1000.0f; +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + speed = 0.0f; +#endif + ui.phase += speed * (float)elapsed_ms / 1000.0f; + if (ui.phase > 6.2831853f) ui.phase -= 6.2831853f; + ui.previous_render_ms = now_ms; + + float completion_wave = 0.0f; +#if !CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + if (ui.completion_started_ms > 0u && now_ms >= ui.completion_started_ms) { + uint64_t elapsed_ms = now_ms - ui.completion_started_ms; + if (elapsed_ms < 700u) completion_wave = sinf((float)elapsed_ms / 700.0f * 3.1415927f); + } +#endif + + lv_obj_set_style_bg_color(ui.screen, output->scene == FIXTURE_UI_ERROR + ? lv_color_hex(0x190b13) + : lv_color_hex(0x071117), 0); + lv_obj_set_style_transform_scale(ui.state, 256, 0); + lv_label_set_text(ui.state, visual.title); + lv_obj_set_style_text_color(ui.state, accent, 0); + lv_obj_set_style_arc_color(ui.orbit, accent, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(ui.progress, accent, LV_PART_INDICATOR); + lv_obj_set_style_border_color(ui.core, accent, 0); + lv_arc_set_value(ui.progress, visual.progress_per_mille); + + bool branded_update = visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE; + bool bear_test = visual.variant == FIXTURE_VISUAL_BEAR_TEST; + lv_label_set_text_static( + ui.eyebrow, branded_update ? "KEYPATH / SECURE UPDATE" : + bear_test ? "BEAR / TYPOVER TEST" : "KEYPATH / HID ORACLE"); + lv_obj_set_style_text_color(ui.eyebrow, + bear_test ? lv_color_hex(0xe34c42) : lv_color_hex(0x71909d), 0); + set_hidden(ui.core, branded_update || bear_test); + set_hidden(ui.icon_front, branded_update || bear_test); + set_hidden(ui.icon_back, branded_update || bear_test); + set_hidden(ui.brand_keycap, !branded_update); + set_hidden(ui.brand_light, !branded_update); + set_hidden(ui.brand_lid, !branded_update); + set_hidden(ui.brand_glint_horizontal, !branded_update); + set_hidden(ui.brand_glint_vertical, !branded_update); + set_hidden(ui.bear_ear_left, !bear_test); + set_hidden(ui.bear_ear_right, !bear_test); + set_hidden(ui.bear_head, !bear_test); + set_hidden(ui.bear_muzzle, !bear_test); + set_hidden(ui.bear_eye_left, !bear_test); + set_hidden(ui.bear_eye_right, !bear_test); + set_hidden(ui.bear_nose, !bear_test); + + if (bear_test) { + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x160f12), 0); + int bear_pulse = (int)((sinf(ui.phase * 1.6f) + 1.0f) * 6.0f); +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + bear_pulse = 4; +#endif + lv_obj_set_style_shadow_opa(ui.bear_head, (lv_opa_t)(24 + bear_pulse), 0); + lv_obj_set_style_transform_scale(ui.bear_head, 256 + bear_pulse, 0); + lv_obj_set_style_transform_scale(ui.bear_muzzle, 256 + bear_pulse, 0); + } + + if (branded_update) { + int fill_height = 2 + (int)(visual.progress_per_mille * 34u / 1000u); + lv_obj_set_height(ui.brand_fill, fill_height); + lv_obj_align(ui.brand_fill, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(ui.brand_fill, accent, 0); + + int key_light = (int)((sinf(ui.phase * 1.45f) + 1.0f) * 18.0f); + int lid_lift = 2 + (int)((sinf(ui.phase * 1.45f) + 1.0f) * 2.0f); +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + key_light = 18; + lid_lift = 3; +#endif + lv_obj_set_style_bg_opa(ui.brand_light, (lv_opa_t)(205 + key_light), 0); + lv_obj_set_style_shadow_opa(ui.brand_keycap, (lv_opa_t)(18 + key_light / 2), 0); + lv_obj_set_style_translate_y(ui.brand_lid, -lid_lift, 0); + + float glint_angle = ui.phase; +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + glint_angle = (float)visual.progress_per_mille / 1000.0f * 6.2831853f; +#endif + int glint_x = (int)(cosf(glint_angle) * 67.0f); + int glint_y = (int)(sinf(glint_angle) * 67.0f) - 2; + lv_obj_align(ui.brand_glint_horizontal, LV_ALIGN_CENTER, glint_x, glint_y); + lv_obj_align(ui.brand_glint_vertical, LV_ALIGN_CENTER, glint_x, glint_y); + lv_obj_set_style_bg_color(ui.brand_glint_horizontal, lv_color_hex(0xfff7df), 0); + lv_obj_set_style_bg_color(ui.brand_glint_vertical, lv_color_hex(0xfff7df), 0); + lv_obj_set_style_bg_opa(ui.brand_glint_horizontal, LV_OPA_80, 0); + lv_obj_set_style_bg_opa(ui.brand_glint_vertical, LV_OPA_80, 0); + } + + if ((int)visual.icon != ui.visual_stage) { + fixture_visual_icon_t previous = ui.visual_stage < 0 + ? FIXTURE_ICON_POWER + : (fixture_visual_icon_t)ui.visual_stage; + lv_label_set_text(ui.icon_back, symbol_for(previous)); + lv_label_set_text(ui.icon_front, symbol_for(visual.icon)); + ui.visual_stage = (int)visual.icon; + ui.icon_transition_started_ms = now_ms; + } + uint64_t transition_elapsed = now_ms - ui.icon_transition_started_ms; + float transition = transition_elapsed >= 240u ? 1.0f : (float)transition_elapsed / 240.0f; + float eased = 1.0f - powf(1.0f - transition, 3.0f); + lv_obj_set_style_text_color(ui.icon_front, accent, 0); + lv_obj_set_style_text_color(ui.icon_back, accent, 0); + lv_obj_set_style_text_opa(ui.icon_front, (lv_opa_t)(255.0f * eased), 0); + lv_obj_set_style_text_opa(ui.icon_back, (lv_opa_t)(255.0f * (1.0f - transition)), 0); + int icon_pulse = (int)(sinf(ui.phase * 1.4f) * 7.0f); + lv_obj_set_style_transform_scale(ui.icon_front, 232 + (int)(24.0f * eased) + icon_pulse, 0); + lv_obj_set_style_translate_x(ui.icon_front, (int)(7.0f * (1.0f - eased)), 0); + lv_obj_set_style_translate_x(ui.icon_back, (int)(-9.0f * transition), 0); + + int orbit_start = (int)(ui.phase * 57.29578f) % 360; + int orbit_length = output->scene == FIXTURE_UI_RUNNING ? 55 + energy / 14 : 82; + lv_arc_set_angles(ui.orbit, orbit_start, orbit_start + orbit_length); + + int pulse = (int)((sinf(ui.phase * 1.7f) + 1.0f) * 12.0f + completion_wave * 24.0f); +#if CONFIG_KEYPATH_FIXTURE_REDUCED_MOTION + pulse = 0; +#endif + int halo_size = 119 + pulse / 2 + energy / 90; + lv_obj_set_style_transform_scale(ui.halo_inner, halo_size * 256 / 124, 0); + lv_obj_set_style_bg_color(ui.halo_inner, accent, 0); + lv_obj_set_style_bg_opa(ui.halo_inner, (lv_opa_t)(12 + energy / 28), 0); + lv_obj_set_style_bg_color(ui.halo_outer, accent, 0); + lv_obj_set_style_bg_opa(ui.halo_outer, (lv_opa_t)(6 + energy / 45), 0); + + for (size_t index = 0; index < KEY_COUNT; ++index) { + float wave = sinf(ui.phase * 2.4f - (float)index * 0.7f); + int opacity = 35 + (int)((wave + 1.0f) * 38.0f) + energy / 8; + if (opacity > 255) opacity = 255; + lv_obj_set_style_bg_color(ui.keys[index], accent, 0); + lv_obj_set_style_bg_opa(ui.keys[index], (lv_opa_t)(opacity / 3), 0); + } + + float radius = output->scene == FIXTURE_UI_COMPLETE ? 82.0f : 72.0f + energy / 80.0f; + radius += completion_wave * 34.0f; + for (size_t index = 0; index < PARTICLE_COUNT; ++index) { + if (output->quality == FIXTURE_UI_PROTECTED && index >= 4u) { + lv_obj_add_flag(ui.particles[index], LV_OBJ_FLAG_HIDDEN); + continue; + } + lv_obj_clear_flag(ui.particles[index], LV_OBJ_FLAG_HIDDEN); + float angle = ui.phase * (1.0f + (float)(index % 3u) * 0.13f) + + (float)index * 0.5235988f; + float wobble = sinf(ui.phase * 1.9f + (float)index) * (3.0f + energy / 170.0f); + int x = 120 + (int)(cosf(angle) * (radius + wobble)); + int y = 138 + (int)(sinf(angle) * (radius + wobble)); + lv_obj_set_pos(ui.particles[index], x, y); + lv_obj_set_style_bg_color(ui.particles[index], accent, 0); + lv_obj_set_style_bg_opa(ui.particles[index], + (lv_opa_t)(40 + ((index * 31u + (unsigned int)(ui.phase * 80)) % 150u)), 0); + } + + if (presentation->detail[0]) { + lv_label_set_text(ui.detail, presentation->detail); + } else if (presentation->result != FIXTURE_RESULT_NONE && + (presentation->dropped || presentation->duplicated || presentation->repeated)) { + char findings[64]; + snprintf(findings, sizeof(findings), "DROP %" PRIu32 " DUP %" PRIu32 " REPEAT %" PRIu32, + presentation->dropped, presentation->duplicated, presentation->repeated); + lv_label_set_text(ui.detail, findings); + } else if (presentation->phase == FIXTURE_PRESENT_NEXT && presentation->next[0]) { + lv_label_set_text(ui.detail, presentation->next); + } else { + lv_label_set_text(ui.detail, automatic_detail); + } + + if (presentation->result != FIXTURE_RESULT_NONE && presentation->reports_expected > 0u) { + char metrics[64]; + snprintf(metrics, sizeof(metrics), "%" PRIu32 "/%" PRIu32 " P95 %" PRIu32 "us%s", + presentation->reports_observed, presentation->reports_expected, + presentation->latency_p95_us, presentation->safe_release ? " SAFE" : ""); + lv_label_set_text(ui.quality, metrics); + lv_obj_set_style_text_color(ui.quality, accent, 0); + } else + + if (branded_update) { + lv_label_set_text_static(ui.quality, "SECURE OTA"); + lv_obj_set_style_text_color(ui.quality, accent, 0); + } else if (output->quality == FIXTURE_UI_PROTECTED) { + lv_label_set_text_static(ui.quality, "HID PRIORITY"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0xffb454), 0); + } else if (output->quality == FIXTURE_UI_ACTIVE) { + lv_label_set_text_static(ui.quality, "LIVE 20 FPS"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x55c7ff), 0); + } else { + lv_label_set_text_static(ui.quality, "CINEMATIC"); + lv_obj_set_style_text_color(ui.quality, lv_color_hex(0x52727e), 0); + } + render_button_feedback(now_ms); +} + +static void display_task(void *context) { + (void)context; + lv_display_t *display = bsp_display_start(); + configASSERT(display); + ESP_ERROR_CHECK(bsp_display_brightness_set(CONFIG_KEYPATH_FIXTURE_BRIGHTNESS)); + configASSERT(bsp_display_lock(0u)); +#if CONFIG_KEYPATH_FIXTURE_BOOT_SPLASH + ui.screen = lv_screen_active(); + lv_obj_remove_style_all(ui.screen); + lv_obj_set_style_bg_color(ui.screen, lv_color_hex(0x080c10), 0); + lv_obj_set_style_bg_opa(ui.screen, LV_OPA_COVER, 0); + build_splash(); +#else + build_ui(); +#endif + bsp_display_unlock(); +#if CONFIG_KEYPATH_FIXTURE_BOOT_SPLASH + note_display_initialized(true, false); + play_splash(); + configASSERT(bsp_display_lock(100u)); + build_ui(); + bsp_display_unlock(); +#else + note_display_initialized(false, true); +#endif + + fixture_ui_model_t model; + fixture_ui_model_init(&model); + while (true) { + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + fixture_ui_output_t output = fixture_ui_model_step(&model, &snapshot.ui, now_ms); + char automatic_detail[64]; + if (snapshot.ui.state == FIXTURE_ERROR && snapshot.error[0]) { + snprintf(automatic_detail, sizeof(automatic_detail), "%.63s", snapshot.error); + } else if (!snapshot.ui.wifi_connected) { + snprintf(automatic_detail, sizeof(automatic_detail), "Trying %.32s", snapshot.network_name); + } else { + snprintf(automatic_detail, sizeof(automatic_detail), "%s USB %s", + snapshot.network_address, + snapshot.ui.usb_mounted ? "READY" : "WAIT"); + } + if (output.completion_burst) ui.completion_started_ms = now_ms; + announce_transition(&output); + announce_result(snapshot.presentation.result); + fixture_board_update(snapshot.ui.state == FIXTURE_ARMED || snapshot.ui.state == FIXTURE_RUNNING); + if (bsp_display_lock(20u)) { + render(&output, &snapshot.presentation, automatic_detail, now_ms); + note_display_frame(now_ms); + bsp_display_unlock(); + } + vTaskDelay(pdMS_TO_TICKS(output.frame_interval_ms)); + } +} + +void fixture_display_start(void) { + configASSERT(xTaskCreatePinnedToCore(display_task, "fixture_display", 8192, NULL, 6, NULL, 0) == pdPASS); +} + +void fixture_display_health_snapshot(fixture_display_health_t *health) { + if (!health) return; + taskENTER_CRITICAL(&display_health_lock); + *health = display_health; + taskEXIT_CRITICAL(&display_health_lock); +} + +bool fixture_display_is_healthy(void) { + fixture_display_health_t health; + fixture_display_health_snapshot(&health); + uint64_t now_ms = (uint64_t)(esp_timer_get_time() / 1000); + uint64_t age_ms = now_ms >= health.last_frame_ms ? now_ms - health.last_frame_ms : UINT64_MAX; + return health.initialized && health.splash_complete && health.frame_sequence > 0u && + age_ms <= DISPLAY_HEARTBEAT_MAX_AGE_MS; +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h new file mode 100644 index 000000000..19fc8693b --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_display.h @@ -0,0 +1,19 @@ +#ifndef KEYPATH_ESP32_FIXTURE_DISPLAY_H +#define KEYPATH_ESP32_FIXTURE_DISPLAY_H + +#include +#include + +typedef struct { + bool initialized; + bool splash_enabled; + bool splash_complete; + uint64_t frame_sequence; + uint64_t last_frame_ms; +} fixture_display_health_t; + +void fixture_display_start(void); +void fixture_display_health_snapshot(fixture_display_health_t *health); +bool fixture_display_is_healthy(void); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c new file mode 100644 index 000000000..4da4a9a13 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.c @@ -0,0 +1,674 @@ +#include "fixture_http.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_event.h" +#include "esp_check.h" +#include "esp_eap_client.h" +#include "esp_heap_caps.h" +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_ota_ops.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "fixture_config.h" +#include "fixture_display.h" +#include "fixture_runtime.h" +#include "fixture_wifi_model.h" +#include "freertos/FreeRTOS.h" +#include "mdns.h" +#include "mbedtls/md.h" +#include "mbedtls/sha256.h" +#include "nvs_flash.h" +#include "sdkconfig.h" + +#define SCRIPT_CAPACITY (96u * 1024u) +#define OTA_CHUNK_SIZE 4096u +#define SHA256_SIZE 32u +#define SHA256_HEX_SIZE 64u + +static const char *TAG = "fixture_network"; +static char *script_buffer; +static httpd_handle_t http_server; +static fixture_wifi_model_t wifi_model; +static volatile bool control_plane_ready; +static uint32_t wifi_disconnect_count; +static uint32_t wifi_connect_count; +static uint32_t last_wifi_disconnect_reason; +static uint32_t http_server_start_count; +static uint32_t status_request_count; +static uint32_t status_response_count; +static uint32_t status_failure_count; +static uint32_t last_status_latency_us; +static uint32_t maximum_status_latency_us; +static bool enterprise_wifi_enabled; + +typedef enum { + WIFI_PROFILE_PERSONAL, + WIFI_PROFILE_ENTERPRISE, +} wifi_profile_authentication_t; + +typedef struct { + const char *ssid; + const char *username; + const char *password; + wifi_profile_authentication_t authentication; +} wifi_profile_t; + +static const wifi_profile_t wifi_profiles[] = { + /* Expected location order: home, Hacker Dojo, beach, phone hotspot. */ + {KEYPATH_WIFI_SSID_1, NULL, KEYPATH_WIFI_PASSWORD_1, WIFI_PROFILE_PERSONAL}, + {KEYPATH_HACKER_DOJO_SSID, KEYPATH_HACKER_DOJO_USERNAME, + KEYPATH_HACKER_DOJO_PASSWORD, WIFI_PROFILE_ENTERPRISE}, + {KEYPATH_WIFI_SSID_4, NULL, KEYPATH_WIFI_PASSWORD_4, WIFI_PROFILE_PERSONAL}, + {KEYPATH_WIFI_SSID_3, NULL, KEYPATH_WIFI_PASSWORD_3, WIFI_PROFILE_PERSONAL}, +}; + +static bool authorized(httpd_req_t *request) { + char value[192]; + if (httpd_req_get_hdr_value_str(request, "Authorization", value, sizeof(value)) != ESP_OK) return false; + static const char prefix[] = "Bearer "; + return strncmp(value, prefix, sizeof(prefix) - 1u) == 0 && + strcmp(value + sizeof(prefix) - 1u, KEYPATH_FIXTURE_TOKEN) == 0; +} + +static esp_err_t send_json(httpd_req_t *request, const char *status, const char *body) { + httpd_resp_set_status(request, status); + httpd_resp_set_type(request, "application/json"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + httpd_resp_set_hdr(request, "Connection", "close"); + return httpd_resp_sendstr(request, body); +} + +static esp_err_t require_auth(httpd_req_t *request) { + if (authorized(request)) return ESP_OK; + send_json(request, "401 Unauthorized", "{\"ok\":false,\"message\":\"bearer token required\"}\n"); + return ESP_FAIL; +} + +static bool decode_hex(const char *hex, uint8_t *bytes, size_t byte_count) { + if (!hex || strlen(hex) != byte_count * 2u) return false; + for (size_t index = 0u; index < byte_count; ++index) { + uint8_t value = 0u; + for (size_t nibble = 0u; nibble < 2u; ++nibble) { + char character = hex[index * 2u + nibble]; + uint8_t digit; + if (character >= '0' && character <= '9') digit = (uint8_t)(character - '0'); + else if (character >= 'a' && character <= 'f') digit = (uint8_t)(character - 'a' + 10); + else if (character >= 'A' && character <= 'F') digit = (uint8_t)(character - 'A' + 10); + else return false; + value = (uint8_t)((value << 4u) | digit); + } + bytes[index] = value; + } + return true; +} + +static bool constant_time_equal(const uint8_t *left, const uint8_t *right, size_t length) { + uint8_t difference = 0u; + for (size_t index = 0u; index < length; ++index) difference |= left[index] ^ right[index]; + return difference == 0u; +} + +static const char *ota_state_name(esp_ota_img_states_t state) { + switch (state) { + case ESP_OTA_IMG_NEW: return "new"; + case ESP_OTA_IMG_PENDING_VERIFY: return "pending-verify"; + case ESP_OTA_IMG_VALID: return "valid"; + case ESP_OTA_IMG_INVALID: return "invalid"; + case ESP_OTA_IMG_ABORTED: return "aborted"; + case ESP_OTA_IMG_UNDEFINED: return "undefined"; + default: return "unknown"; + } +} + +static esp_err_t firmware_update_failure(httpd_req_t *request, const char *status, + const char *message) { + fixture_runtime_end_firmware_update(false, message); + char body[224]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", message); + return send_json(request, status, body); +} + +static esp_err_t firmware_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + + char error[128]; + if (!fixture_runtime_begin_firmware_update(error, sizeof(error))) { + char body[192]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", error); + return send_json(request, "409 Conflict", body); + } + + const esp_partition_t *partition = esp_ota_get_next_update_partition(NULL); + if (!partition || request->content_len <= 0 || (size_t)request->content_len > partition->size) { + return firmware_update_failure(request, "413 Payload Too Large", + "image does not fit the inactive application slot"); + } + + char sha_hex[SHA256_HEX_SIZE + 1u]; + char hmac_hex[SHA256_HEX_SIZE + 1u]; + uint8_t expected_sha[SHA256_SIZE]; + uint8_t expected_hmac[SHA256_SIZE]; + if (httpd_req_get_hdr_value_str(request, "X-KeyPath-SHA256", sha_hex, sizeof(sha_hex)) != ESP_OK || + httpd_req_get_hdr_value_str(request, "X-KeyPath-HMAC-SHA256", hmac_hex, sizeof(hmac_hex)) != ESP_OK || + !decode_hex(sha_hex, expected_sha, sizeof(expected_sha)) || + !decode_hex(hmac_hex, expected_hmac, sizeof(expected_hmac))) { + return firmware_update_failure(request, "400 Bad Request", + "valid SHA-256 and HMAC-SHA256 headers are required"); + } + + uint8_t *chunk = heap_caps_malloc(OTA_CHUNK_SIZE, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (!chunk) return firmware_update_failure(request, "503 Service Unavailable", + "not enough internal memory for update buffer"); + + mbedtls_sha256_context sha; + mbedtls_md_context_t hmac; + mbedtls_sha256_init(&sha); + mbedtls_md_init(&hmac); + const mbedtls_md_info_t *sha256 = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + esp_ota_handle_t ota_handle = 0u; + bool ota_started = false; + esp_err_t ota_result = ESP_OK; + if (!sha256 || mbedtls_sha256_starts(&sha, 0) != 0 || + mbedtls_md_setup(&hmac, sha256, 1) != 0 || + mbedtls_md_hmac_starts(&hmac, (const unsigned char *)KEYPATH_FIXTURE_TOKEN, + strlen(KEYPATH_FIXTURE_TOKEN)) != 0) { + ota_result = ESP_FAIL; + } else { + ota_result = esp_ota_begin(partition, (size_t)request->content_len, &ota_handle); + ota_started = ota_result == ESP_OK; + } + + size_t received = 0u; + uint16_t last_progress = 0u; + while (ota_result == ESP_OK && received < (size_t)request->content_len) { + size_t remaining = (size_t)request->content_len - received; + size_t wanted = remaining < OTA_CHUNK_SIZE ? remaining : OTA_CHUNK_SIZE; + int count = httpd_req_recv(request, (char *)chunk, wanted); + if (count == HTTPD_SOCK_ERR_TIMEOUT) continue; + if (count <= 0) { + ota_result = ESP_ERR_INVALID_RESPONSE; + break; + } + if (mbedtls_sha256_update(&sha, chunk, (size_t)count) != 0 || + mbedtls_md_hmac_update(&hmac, chunk, (size_t)count) != 0) { + ota_result = ESP_FAIL; + break; + } + ota_result = esp_ota_write(ota_handle, chunk, (size_t)count); + received += (size_t)count; + uint16_t progress = (uint16_t)((received * 900u) / (size_t)request->content_len); + if (progress >= last_progress + 10u || received == (size_t)request->content_len) { + char detail[49]; + snprintf(detail, sizeof(detail), "RECEIVING IMAGE %u%%", progress / 10u); + fixture_runtime_set_firmware_update_progress(progress, detail); + last_progress = progress; + } + } + + uint8_t actual_sha[SHA256_SIZE] = {0}; + uint8_t actual_hmac[SHA256_SIZE] = {0}; + if (ota_result == ESP_OK && + (mbedtls_sha256_finish(&sha, actual_sha) != 0 || + mbedtls_md_hmac_finish(&hmac, actual_hmac) != 0)) { + ota_result = ESP_FAIL; + } + mbedtls_sha256_free(&sha); + mbedtls_md_free(&hmac); + free(chunk); + + if (ota_result != ESP_OK || received != (size_t)request->content_len || + !constant_time_equal(actual_sha, expected_sha, sizeof(actual_sha)) || + !constant_time_equal(actual_hmac, expected_hmac, sizeof(actual_hmac))) { + if (ota_started) esp_ota_abort(ota_handle); + return firmware_update_failure(request, "400 Bad Request", + "image transfer or cryptographic verification failed"); + } + + fixture_runtime_set_firmware_update_progress(950u, "VALIDATING APPLICATION"); + ota_result = esp_ota_end(ota_handle); + ota_started = false; + if (ota_result != ESP_OK) { + return firmware_update_failure(request, "400 Bad Request", + "ESP-IDF rejected the application image"); + } + ota_result = esp_ota_set_boot_partition(partition); + if (ota_result != ESP_OK) { + return firmware_update_failure(request, "500 Internal Server Error", + "could not select the verified application slot"); + } + + fixture_runtime_end_firmware_update(true, "RESTARTING"); + httpd_resp_set_hdr(request, "Connection", "close"); + char response_body[192]; + snprintf(response_body, sizeof(response_body), + "{\"ok\":true,\"message\":\"image verified; restarting\"," + "\"rebooting\":true,\"targetSlot\":\"%s\"}\n", partition->label); + esp_err_t response = send_json(request, "202 Accepted", response_body); + vTaskDelay(pdMS_TO_TICKS(300)); + esp_restart(); + return response; +} + +static esp_err_t status_handler(httpd_req_t *request) { + uint64_t started_us = (uint64_t)esp_timer_get_time(); + ++status_request_count; + if (require_auth(request) != ESP_OK) { + ++status_failure_count; + return ESP_OK; + } + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + fixture_display_health_t display_health; + fixture_display_health_snapshot(&display_health); + const esp_partition_t *running_partition = esp_ota_get_running_partition(); + esp_ota_img_states_t ota_state = ESP_OTA_IMG_UNDEFINED; + const char *ota_state_value = esp_ota_get_state_partition(running_partition, &ota_state) == ESP_OK + ? ota_state_name(ota_state) : "unavailable"; + bool update_ready = !snapshot.pending_release && !snapshot.firmware_update_in_progress && + (snapshot.ui.state == FIXTURE_IDLE || snapshot.ui.state == FIXTURE_COMPLETE || + snapshot.ui.state == FIXTURE_ABORTED || snapshot.ui.state == FIXTURE_ERROR); + char body[3072]; + int body_length = snprintf(body, sizeof(body), + "{\"ok\":true,\"firmware\":\"%s\",\"build\":\"%s\"," + "\"platform\":\"waveshare-esp32-s3-touch-lcd-1.69\"," + "\"state\":\"%s\",\"runId\":\"%s\",\"scriptCRC32\":\"%08" PRIx32 "\"," + "\"eventCount\":%" PRIu32 ",\"repeatCount\":%" PRIu32 ",\"currentRepeat\":%" PRIu32 "," + "\"reportsSubmitted\":%" PRIu64 ",\"transfersCompleted\":%" PRIu64 "," + "\"lateReports\":%" PRIu64 ",\"maximumLatenessUs\":%" PRId64 "," + "\"submittedCRC32\":\"%08" PRIx32 "\",\"usbMounted\":%s," + "\"displayHealthy\":%s,\"displayFrame\":%" PRIu64 "," + "\"displayLastFrameMs\":%" PRIu64 ",\"splashEnabled\":%s,\"splashComplete\":%s," + "\"updateReady\":%s,\"updateInProgress\":%s,\"otaSlot\":\"%s\",\"otaState\":\"%s\"," + "\"wifiConnected\":%s,\"address\":\"%s\",\"network\":\"%s\",\"error\":\"%s\"," + "\"presentation\":{\"phase\":\"%s\",\"result\":\"%s\",\"brand\":\"%s\",\"progress\":%u," + "\"title\":\"%s\",\"detail\":\"%s\",\"next\":\"%s\"," + "\"reportsExpected\":%" PRIu32 ",\"reportsObserved\":%" PRIu32 "," + "\"dropped\":%" PRIu32 ",\"duplicated\":%" PRIu32 ",\"repeated\":%" PRIu32 "," + "\"latencyP95Us\":%" PRIu32 ",\"safeRelease\":%s}," + "\"diagnostics\":{\"uptimeMs\":%" PRIu64 ",\"resetReason\":%d," + "\"freeHeapBytes\":%" PRIu32 ",\"minimumFreeHeapBytes\":%" PRIu32 "," + "\"wifiConnects\":%" PRIu32 ",\"wifiDisconnects\":%" PRIu32 "," + "\"lastWifiDisconnectReason\":%" PRIu32 ",\"httpServerStarts\":%" PRIu32 "," + "\"statusRequests\":%" PRIu32 ",\"statusResponses\":%" PRIu32 "," + "\"statusFailures\":%" PRIu32 ",\"lastStatusLatencyUs\":%" PRIu32 "," + "\"maximumStatusLatencyUs\":%" PRIu32 "}}\n", + KEYPATH_FIXTURE_FIRMWARE_VERSION, KEYPATH_FIXTURE_BUILD_ID, + fixture_state_name(snapshot.ui.state), snapshot.run_id, + snapshot.script_crc32, snapshot.ui.event_count, snapshot.ui.repeat_count, + snapshot.ui.current_repeat, snapshot.ui.reports_submitted, snapshot.transfers_completed, + snapshot.ui.late_reports, snapshot.ui.maximum_lateness_us, snapshot.submitted_crc32, + snapshot.ui.usb_mounted ? "true" : "false", + fixture_display_is_healthy() ? "true" : "false", display_health.frame_sequence, + display_health.last_frame_ms, display_health.splash_enabled ? "true" : "false", + display_health.splash_complete ? "true" : "false", update_ready ? "true" : "false", + snapshot.firmware_update_in_progress ? "true" : "false", running_partition->label, + ota_state_value, snapshot.ui.wifi_connected ? "true" : "false", + snapshot.network_address, snapshot.network_name, snapshot.error, + fixture_presentation_phase_name(snapshot.presentation.phase), + fixture_result_name(snapshot.presentation.result), + fixture_presentation_brand_name(snapshot.presentation.brand), + snapshot.presentation.progress_per_mille, + snapshot.presentation.title, snapshot.presentation.detail, snapshot.presentation.next, + snapshot.presentation.reports_expected, snapshot.presentation.reports_observed, + snapshot.presentation.dropped, snapshot.presentation.duplicated, snapshot.presentation.repeated, + snapshot.presentation.latency_p95_us, snapshot.presentation.safe_release ? "true" : "false", + (uint64_t)(esp_timer_get_time() / 1000), (int)esp_reset_reason(), + (uint32_t)esp_get_free_heap_size(), (uint32_t)esp_get_minimum_free_heap_size(), + wifi_connect_count, wifi_disconnect_count, last_wifi_disconnect_reason, + http_server_start_count, status_request_count, status_response_count, + status_failure_count, last_status_latency_us, maximum_status_latency_us); + if (body_length < 0 || (size_t)body_length >= sizeof(body)) { + ++status_failure_count; + return send_json(request, "500 Internal Server Error", + "{\"ok\":false,\"error\":\"status response overflow\"}\n"); + } + esp_err_t result = send_json(request, "200 OK", body); + uint64_t elapsed_us = (uint64_t)esp_timer_get_time() - started_us; + last_status_latency_us = elapsed_us > UINT32_MAX ? UINT32_MAX : (uint32_t)elapsed_us; + if (last_status_latency_us > maximum_status_latency_us) { + maximum_status_latency_us = last_status_latency_us; + } + if (result == ESP_OK) ++status_response_count; + else ++status_failure_count; + return result; +} + +static esp_err_t receive_body(httpd_req_t *request, size_t maximum, size_t *length) { + if (request->content_len <= 0 || (size_t)request->content_len > maximum) return ESP_ERR_INVALID_SIZE; + size_t received = 0u; + while (received < (size_t)request->content_len) { + int count = httpd_req_recv(request, script_buffer + received, + (size_t)request->content_len - received); + if (count == HTTPD_SOCK_ERR_TIMEOUT) continue; + if (count <= 0) return ESP_FAIL; + received += (size_t)count; + } + script_buffer[received] = '\0'; + *length = received; + return ESP_OK; +} + +static char *trim(char *value) { + while (*value == ' ' || *value == '\t' || *value == '\r' || *value == '\n') ++value; + char *end = value + strlen(value); + while (end > value && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || end[-1] == '\n')) --end; + *end = '\0'; + return value; +} + +static esp_err_t script_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + size_t length; + esp_err_t result = receive_body(request, SCRIPT_CAPACITY, &length); + if (result != ESP_OK) return send_json(request, "413 Payload Too Large", + "{\"ok\":false,\"message\":\"script exceeds fixture capacity\"}\n"); + char error[128]; + if (!fixture_runtime_load(script_buffer, length, error, sizeof(error))) { + char body[192]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", error); + return send_json(request, "409 Conflict", body); + } + return send_json(request, "201 Created", + "{\"ok\":true,\"message\":\"script loaded and CRC verified\"}\n"); +} + +static esp_err_t small_command(httpd_req_t *request, const char *operation) { + if (require_auth(request) != ESP_OK) return ESP_OK; + size_t length; + esp_err_t result = receive_body(request, 127u, &length); + if (result != ESP_OK) return send_json(request, "400 Bad Request", + "{\"ok\":false,\"message\":\"invalid command body\"}\n"); + (void)length; + char *body_value = trim(script_buffer); + char error[128]; + bool ok = false; + const char *success = NULL; + if (strcmp(operation, "arm") == 0) { + ok = fixture_runtime_arm(body_value, error, sizeof(error)); + success = "fixture armed; safety release queued"; + } else { + char run_id[FIXTURE_MAX_RUN_ID + 1u] = {0}; + unsigned int delay_ms; + char extra; + if (sscanf(body_value, "%48s %u %c", run_id, &delay_ms, &extra) != 2) { + return send_json(request, "400 Bad Request", + "{\"ok\":false,\"message\":\"start body must contain run_id and delay_ms\"}\n"); + } + ok = fixture_runtime_start(run_id, delay_ms, error, sizeof(error)); + success = "locally timed script scheduled"; + } + if (!ok) { + char body[192]; + snprintf(body, sizeof(body), "{\"ok\":false,\"message\":\"%s\"}\n", error); + return send_json(request, "409 Conflict", body); + } + char response[192]; + snprintf(response, sizeof(response), "{\"ok\":true,\"message\":\"%s\"}\n", success); + return send_json(request, "200 OK", response); +} + +static esp_err_t arm_handler(httpd_req_t *request) { return small_command(request, "arm"); } +static esp_err_t start_handler(httpd_req_t *request) { return small_command(request, "start"); } + +static esp_err_t abort_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + fixture_runtime_abort("remote abort"); + return send_json(request, "200 OK", + "{\"ok\":true,\"message\":\"aborted; all-keys-released report queued\"}\n"); +} + +static bool json_u32(cJSON *root, const char *name, uint32_t maximum, uint32_t *output) { + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, name); + if (!item) return true; + if (!cJSON_IsNumber(item) || item->valuedouble < 0.0 || item->valuedouble > maximum || + item->valuedouble != (double)(uint32_t)item->valuedouble) return false; + *output = (uint32_t)item->valuedouble; + return true; +} + +static bool json_text(cJSON *root, const char *name, char *output, size_t capacity) { + cJSON *item = cJSON_GetObjectItemCaseSensitive(root, name); + if (!item) return true; + if (!cJSON_IsString(item) || !fixture_presentation_text_valid(item->valuestring, capacity - 1u)) return false; + snprintf(output, capacity, "%s", item->valuestring); + return true; +} + +static esp_err_t presentation_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + size_t length; + if (receive_body(request, 1023u, &length) != ESP_OK) { + return send_json(request, "400 Bad Request", "{\"ok\":false,\"message\":\"invalid presentation body\"}\n"); + } + cJSON *root = cJSON_ParseWithLength(script_buffer, length); + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + cJSON *phase = root ? cJSON_GetObjectItemCaseSensitive(root, "phase") : NULL; + cJSON *result = root ? cJSON_GetObjectItemCaseSensitive(root, "result") : NULL; + cJSON *brand = root ? cJSON_GetObjectItemCaseSensitive(root, "brand") : NULL; + uint32_t progress = 0u; + cJSON *safe_release = root ? cJSON_GetObjectItemCaseSensitive(root, "safeRelease") : NULL; + bool valid = root && cJSON_IsObject(root) && cJSON_IsString(phase) && + fixture_presentation_parse_phase(phase->valuestring, &presentation.phase) && + (!result || (cJSON_IsString(result) && + fixture_presentation_parse_result(result->valuestring, &presentation.result))) && + (!brand || (cJSON_IsString(brand) && + fixture_presentation_parse_brand(brand->valuestring, &presentation.brand))) && + json_u32(root, "progress", 1000u, &progress) && + json_u32(root, "reportsExpected", UINT32_MAX, &presentation.reports_expected) && + json_u32(root, "reportsObserved", UINT32_MAX, &presentation.reports_observed) && + json_u32(root, "dropped", UINT32_MAX, &presentation.dropped) && + json_u32(root, "duplicated", UINT32_MAX, &presentation.duplicated) && + json_u32(root, "repeated", UINT32_MAX, &presentation.repeated) && + json_u32(root, "latencyP95Us", UINT32_MAX, &presentation.latency_p95_us) && + json_text(root, "title", presentation.title, sizeof(presentation.title)) && + json_text(root, "detail", presentation.detail, sizeof(presentation.detail)) && + json_text(root, "next", presentation.next, sizeof(presentation.next)) && + (!safe_release || cJSON_IsBool(safe_release)); + if (valid) { + presentation.progress_per_mille = (uint16_t)progress; + presentation.safe_release = safe_release && cJSON_IsTrue(safe_release); + fixture_runtime_set_presentation(&presentation); + } + cJSON_Delete(root); + if (!valid) { + return send_json(request, "400 Bad Request", + "{\"ok\":false,\"message\":\"invalid phase, result, brand, metric, or display text\"}\n"); + } + return send_json(request, "200 OK", "{\"ok\":true,\"message\":\"presentation updated\"}\n"); +} + +static unsigned int query_number(httpd_req_t *request, const char *name, unsigned int fallback) { + char query[96], value[24]; + if (httpd_req_get_url_query_str(request, query, sizeof(query)) != ESP_OK) return fallback; + if (httpd_query_key_value(query, name, value, sizeof(value)) != ESP_OK) return fallback; + return (unsigned int)strtoul(value, NULL, 10); +} + +static esp_err_t trace_handler(httpd_req_t *request) { + if (require_auth(request) != ESP_OK) return ESP_OK; + unsigned int from = query_number(request, "from", 0u); + unsigned int limit = query_number(request, "limit", 8u); + if (limit > 8u) limit = 8u; + uint32_t available = fixture_runtime_trace_count(); + fixture_runtime_snapshot_t snapshot; + fixture_runtime_snapshot(&snapshot); + httpd_resp_set_type(request, "application/x-ndjson"); + httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + char line[512]; + snprintf(line, sizeof(line), "{\"runId\":\"%s\",\"from\":%u,\"available\":%" PRIu32 "}\n", + snapshot.run_id, from, available); + httpd_resp_send_chunk(request, line, HTTPD_RESP_USE_STRLEN); + for (unsigned int offset = 0u; offset < limit && from + offset < available; ++offset) { + fixture_trace_t trace; + if (!fixture_runtime_trace_at(from + offset, &trace)) break; + snprintf(line, sizeof(line), + "{\"sequence\":%" PRIu64 ",\"scheduledUs\":%" PRIu64 "," + "\"submittedUs\":%" PRIu64 ",\"latenessUs\":%" PRId64 "," + "\"modifiers\":%u,\"keys\":[%u,%u,%u,%u,%u,%u]}\n", + trace.sequence, trace.scheduled_us, trace.submitted_us, trace.lateness_us, + trace.modifiers, trace.keys[0], trace.keys[1], trace.keys[2], + trace.keys[3], trace.keys[4], trace.keys[5]); + httpd_resp_send_chunk(request, line, HTTPD_RESP_USE_STRLEN); + } + return httpd_resp_send_chunk(request, NULL, 0u); +} + +static esp_err_t start_http_server(void) { + if (http_server) return ESP_OK; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = CONFIG_KEYPATH_FIXTURE_HTTP_PORT; + config.max_uri_handlers = 8; + config.stack_size = 8192; + config.core_id = 0; + config.task_priority = 8; + config.lru_purge_enable = true; + config.recv_wait_timeout = 3; + config.send_wait_timeout = 3; + ESP_RETURN_ON_ERROR(httpd_start(&http_server, &config), TAG, "HTTP server start failed"); + ++http_server_start_count; + const httpd_uri_t handlers[] = { + {.uri = "/v1/status", .method = HTTP_GET, .handler = status_handler}, + {.uri = "/v1/script", .method = HTTP_POST, .handler = script_handler}, + {.uri = "/v1/arm", .method = HTTP_POST, .handler = arm_handler}, + {.uri = "/v1/start", .method = HTTP_POST, .handler = start_handler}, + {.uri = "/v1/abort", .method = HTTP_POST, .handler = abort_handler}, + {.uri = "/v1/trace", .method = HTTP_GET, .handler = trace_handler}, + {.uri = "/v1/presentation", .method = HTTP_POST, .handler = presentation_handler}, + {.uri = "/v1/firmware", .method = HTTP_POST, .handler = firmware_handler}, + }; + for (size_t index = 0; index < sizeof(handlers) / sizeof(handlers[0]); ++index) { + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(http_server, &handlers[index]), TAG, + "URI registration failed"); + } + control_plane_ready = true; + return ESP_OK; +} + +static void stop_http_server(void) { + control_plane_ready = false; + if (!http_server) return; + httpd_handle_t server = http_server; + http_server = NULL; + esp_err_t result = httpd_stop(server); + if (result != ESP_OK) { + ESP_LOGW(TAG, "HTTP server stop failed: %s", esp_err_to_name(result)); + } +} + +bool fixture_network_control_ready(void) { + return control_plane_ready; +} + +static esp_err_t select_wifi_profile(size_t index) { + wifi_model.profile_index = index % (sizeof(wifi_profiles) / sizeof(wifi_profiles[0])); + const wifi_profile_t *profile = &wifi_profiles[wifi_model.profile_index]; + + if (enterprise_wifi_enabled) { + ESP_RETURN_ON_ERROR(esp_wifi_sta_enterprise_disable(), TAG, + "could not disable the previous enterprise profile"); + enterprise_wifi_enabled = false; + } + + wifi_config_t wifi = {0}; + snprintf((char *)wifi.sta.ssid, sizeof(wifi.sta.ssid), "%s", profile->ssid); + wifi.sta.pmf_cfg.capable = true; + wifi.sta.pmf_cfg.required = false; + if (profile->authentication == WIFI_PROFILE_ENTERPRISE) { + wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_ENTERPRISE; + } else { + snprintf((char *)wifi.sta.password, sizeof(wifi.sta.password), "%s", profile->password); + wifi.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + } + fixture_runtime_set_network_name(profile->ssid); + ESP_LOGI(TAG, "selecting Wi-Fi profile %u", (unsigned int)(wifi_model.profile_index + 1u)); + ESP_RETURN_ON_ERROR(esp_wifi_set_config(WIFI_IF_STA, &wifi), TAG, + "could not configure the selected Wi-Fi profile"); + + if (profile->authentication == WIFI_PROFILE_ENTERPRISE) { + size_t username_length = strlen(profile->username); + size_t password_length = strlen(profile->password); + ESP_RETURN_ON_ERROR( + esp_eap_client_set_identity((const unsigned char *)profile->username, + (int)username_length), + TAG, "could not configure enterprise identity"); + ESP_RETURN_ON_ERROR( + esp_eap_client_set_username((const unsigned char *)profile->username, + (int)username_length), + TAG, "could not configure enterprise username"); + ESP_RETURN_ON_ERROR( + esp_eap_client_set_password((const unsigned char *)profile->password, + (int)password_length), + TAG, "could not configure enterprise password"); + ESP_RETURN_ON_ERROR(esp_eap_client_set_eap_methods(ESP_EAP_TYPE_PEAP), TAG, + "could not select PEAP authentication"); + ESP_RETURN_ON_ERROR(esp_wifi_sta_enterprise_enable(), TAG, + "could not enable enterprise authentication"); + enterprise_wifi_enabled = true; + } + return ESP_OK; +} + +static void wifi_event(void *argument, esp_event_base_t base, int32_t id, void *data) { + (void)argument; + if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *event = data; + ++wifi_disconnect_count; + last_wifi_disconnect_reason = event ? event->reason : 0u; + fixture_runtime_set_network(false, "unassigned"); + stop_http_server(); + if (fixture_wifi_model_note_disconnect(&wifi_model, + sizeof(wifi_profiles) / sizeof(wifi_profiles[0]), + 2u)) { + ESP_ERROR_CHECK(select_wifi_profile(wifi_model.profile_index)); + } + esp_wifi_connect(); + } else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { + ++wifi_connect_count; + ip_event_got_ip_t *event = data; + char address[16]; + snprintf(address, sizeof(address), IPSTR, IP2STR(&event->ip_info.ip)); + fixture_runtime_set_network(true, address); + fixture_wifi_model_note_connected(&wifi_model); + start_http_server(); + } +} + +esp_err_t fixture_network_start(void) { + script_buffer = heap_caps_malloc(SCRIPT_CAPACITY + 1u, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!script_buffer) return ESP_ERR_NO_MEM; + esp_err_t result = nvs_flash_init(); + if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + result = nvs_flash_init(); + } + ESP_RETURN_ON_ERROR(result, TAG, "NVS initialization failed"); + ESP_RETURN_ON_ERROR(esp_netif_init(), TAG, "network stack initialization failed"); + ESP_RETURN_ON_ERROR(esp_event_loop_create_default(), TAG, "event loop initialization failed"); + if (!esp_netif_create_default_wifi_sta()) return ESP_FAIL; + + wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT(); + ESP_RETURN_ON_ERROR(esp_wifi_init(&init), TAG, "Wi-Fi initialization failed"); + fixture_wifi_model_init(&wifi_model); + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event, NULL)); + ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event, NULL)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(select_wifi_profile(0u)); + + ESP_ERROR_CHECK(mdns_init()); + ESP_ERROR_CHECK(mdns_hostname_set("keypath-hid-fixture")); + ESP_ERROR_CHECK(mdns_instance_name_set("KeyPath HID fixture")); + ESP_ERROR_CHECK(mdns_service_add("KeyPath HID fixture", "_http", "_tcp", + CONFIG_KEYPATH_FIXTURE_HTTP_PORT, NULL, 0)); + ESP_RETURN_ON_ERROR(esp_wifi_start(), TAG, "Wi-Fi start failed"); + return esp_wifi_set_ps(WIFI_PS_NONE); +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h new file mode 100644 index 000000000..8229bc80a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_http.h @@ -0,0 +1,11 @@ +#ifndef KEYPATH_ESP32_FIXTURE_HTTP_H +#define KEYPATH_ESP32_FIXTURE_HTTP_H + +#include + +#include "esp_err.h" + +esp_err_t fixture_network_start(void); +bool fixture_network_control_ready(void); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c new file mode 100644 index 000000000..d3e005f73 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.c @@ -0,0 +1,87 @@ +#include "fixture_qemu_smoke.h" + +#include +#include +#include +#include + +#include "esp_rom_sys.h" +#include "fixture_core.h" +#include "fixture_ui_model.h" +#include "fixture_visual_model.h" + +typedef struct { + uint32_t count; + uint8_t first_key; + uint8_t final_key; +} smoke_reports_t; + +static bool capture_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + (void)modifiers; + smoke_reports_t *reports = context; + if (reports->count == 0u) reports->first_key = keys[0]; + reports->final_key = keys[0]; + reports->count++; + return true; +} + +static bool run_core_smoke(void) { + static const char events[] = + "0 0 4 0 0 0 0 0\n" + "50000 0 0 0 0 0 0 0\n"; + static char script[256]; + static char error[128]; + static fixture_t fixture; + static smoke_reports_t reports; + + fixture_init(&fixture); + fixture_poll(&fixture, 0u, true, true, capture_report, &reports); + int length = snprintf(script, sizeof(script), "KPHID1 qemu-smoke 2 2 100000 %08" PRIx32 "\n%s", + fixture_crc32(events, strlen(events)), events); + if (length <= 0 || (size_t)length >= sizeof(script)) return false; + if (!fixture_load_script(&fixture, script, (size_t)length, error, sizeof(error))) return false; + if (!fixture_arm(&fixture, "qemu-smoke", error, sizeof(error))) return false; + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + if (!fixture_start(&fixture, "qemu-smoke", 100u, 0u, error, sizeof(error))) return false; + fixture_poll(&fixture, 100000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 150000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 200000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 250000u, true, true, capture_report, &reports); + + static fixture_ui_model_t ui; + fixture_ui_model_init(&ui); + fixture_ui_input_t input = { + .state = fixture.state, + .wifi_connected = true, + .usb_mounted = true, + .event_count = fixture.event_count, + .repeat_count = fixture.repeat_count, + .reports_submitted = fixture.reports_submitted, + }; + fixture_ui_output_t output = fixture_ui_model_step(&ui, &input, 250u); + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_RESULT; + presentation.result = FIXTURE_RESULT_PASS; + presentation.progress_per_mille = 1000u; + fixture_visual_output_t visual; + fixture_visual_resolve(&output, &presentation, &visual); + + return fixture.state == FIXTURE_COMPLETE && + fixture.reports_submitted == 4u && + fixture_trace_count(&fixture) == 4u && + reports.count == 6u && + reports.final_key == 0u && + output.scene == FIXTURE_UI_COMPLETE && + output.progress_per_mille == 1000u && + visual.icon == FIXTURE_ICON_OK && + visual.accent_rgb == 0x44d7a8u; +} + +void fixture_qemu_smoke_run(void) { + if (run_core_smoke()) { + esp_rom_printf("KEYPATH_QEMU_SMOKE_PASS\n"); + } else { + esp_rom_printf("KEYPATH_QEMU_SMOKE_FAIL\n"); + } +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h new file mode 100644 index 000000000..ca8b1ed9c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_qemu_smoke.h @@ -0,0 +1,6 @@ +#ifndef KEYPATH_FIXTURE_QEMU_SMOKE_H +#define KEYPATH_FIXTURE_QEMU_SMOKE_H + +void fixture_qemu_smoke_run(void); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c new file mode 100644 index 000000000..610748a6a --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.c @@ -0,0 +1,397 @@ +#include "fixture_runtime.h" + +#include +#include + +#include "class/hid/hid_device.h" +#include "esp_mac.h" +#include "esp_rom_sys.h" +#include "esp_timer.h" +#include "fixture_demo.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "tinyusb.h" +#include "tinyusb_default_config.h" + +#define REPORT_ID_KEYBOARD 1u +#define HID_ENDPOINT 0x81u +#define CONFIGURATION_LENGTH (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN) + +static fixture_t fixture; +static StaticSemaphore_t fixture_mutex_storage; +static SemaphoreHandle_t fixture_mutex; +static bool network_connected; +static char network_address[48] = "unassigned"; +static char network_name[33] = "unassigned"; +static fixture_presentation_t presentation; +static bool firmware_update_in_progress; + +static void runtime_lock(void) { + configASSERT(xSemaphoreTake(fixture_mutex, portMAX_DELAY) == pdTRUE); +} + +static void runtime_unlock(void) { + configASSERT(xSemaphoreGive(fixture_mutex) == pdTRUE); +} + +static const tusb_desc_device_t device_descriptor = { + .bLength = sizeof(tusb_desc_device_t), + .bDescriptorType = TUSB_DESC_DEVICE, + .bcdUSB = 0x0200, + .bDeviceClass = 0, + .bDeviceSubClass = 0, + .bDeviceProtocol = 0, + .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, + .idVendor = 0xcafe, + .idProduct = 0x4010, + .bcdDevice = 0x0200, + .iManufacturer = 1, + .iProduct = 2, + .iSerialNumber = 3, + .bNumConfigurations = 1, +}; + +static const uint8_t hid_report_descriptor[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), +}; + +static const uint8_t configuration_descriptor[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, CONFIGURATION_LENGTH, + TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 4, HID_ITF_PROTOCOL_KEYBOARD, + sizeof(hid_report_descriptor), HID_ENDPOINT, 16, 1), +}; + +static char serial_number[13]; +static const char *string_descriptors[] = { + (const char[]){0x09, 0x04}, + "KeyPath Lab", + "KeyPath Physical HID Fixture", + serial_number, + "Keyboard oracle", +}; + +uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { + (void)instance; + return hid_report_descriptor; +} + +uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t *buffer, + uint16_t requested_length) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)requested_length; + return 0u; +} + +void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, + hid_report_type_t report_type, uint8_t const *buffer, + uint16_t buffer_size) { + (void)instance; + (void)report_id; + (void)report_type; + (void)buffer; + (void)buffer_size; +} + +void tud_hid_report_complete_cb(uint8_t instance, uint8_t const *report, uint16_t length) { + (void)instance; + (void)report; + (void)length; + runtime_lock(); + fixture_note_transfer_complete(&fixture); + runtime_unlock(); +} + +static bool send_keyboard_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + (void)context; + return tud_hid_ready() && tud_hid_keyboard_report(REPORT_ID_KEYBOARD, modifiers, keys); +} + +static void executor_task(void *context) { + (void)context; + while (true) { + bool running; + uint32_t wait_us; + runtime_lock(); + uint64_t now_us = (uint64_t)esp_timer_get_time(); + fixture_state_t previous_state = fixture.state; + fixture_poll(&fixture, now_us, tud_mounted(), tud_hid_ready(), + send_keyboard_report, NULL); + bool offline_demo = strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0; + if (offline_demo && fixture.state == FIXTURE_RUNNING && fixture.event_count > 0u) { + uint64_t total_reports = (uint64_t)fixture.event_count * fixture.repeat_count; + presentation.progress_per_mille = + (uint16_t)((fixture.reports_submitted * 1000u) / total_reports); + presentation.reports_observed = (uint32_t)fixture.reports_submitted; + } else if (offline_demo && previous_state == FIXTURE_RUNNING && + fixture.state == FIXTURE_COMPLETE) { + presentation.phase = FIXTURE_PRESENT_RESULT; + presentation.result = FIXTURE_RESULT_INCONCLUSIVE; + presentation.progress_per_mille = 1000u; + presentation.reports_observed = (uint32_t)fixture.reports_submitted; + snprintf(presentation.title, sizeof(presentation.title), "HID SENT"); + snprintf(presentation.detail, sizeof(presentation.detail), "CHECK JIG FOR RESULT"); + snprintf(presentation.next, sizeof(presentation.next), "POWER TO RUN AGAIN"); + } + running = fixture.state == FIXTURE_RUNNING; + wait_us = fixture_time_until_next_action_us(&fixture, now_us); + runtime_unlock(); + + if (!running) { + /* USB mount and safety-release state do not need a 1 kHz idle poll. */ + vTaskDelay(pdMS_TO_TICKS(5)); + } else if (wait_us > 1500u) { + /* + * Leave at least 1 ms for the precision spin, but yield frequently enough + * that touch, HTTP abort, and status snapshots can acquire the state lock. + */ + uint32_t sleep_ms = (wait_us - 1000u) / 1000u; + if (sleep_ms > 10u) sleep_ms = 10u; + vTaskDelay(pdMS_TO_TICKS(sleep_ms)); + } else { + esp_rom_delay_us(25u); + } + } +} + +void fixture_runtime_init(void) { + fixture_mutex = xSemaphoreCreateMutexStatic(&fixture_mutex_storage); + configASSERT(fixture_mutex); + fixture_init(&fixture); + fixture_presentation_init(&presentation); +} + +esp_err_t fixture_runtime_start_usb(void) { + uint8_t mac[6]; + esp_err_t result = esp_read_mac(mac, ESP_MAC_WIFI_STA); + if (result != ESP_OK) return result; + snprintf(serial_number, sizeof(serial_number), "%02X%02X%02X%02X%02X%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + tinyusb_config_t config = TINYUSB_DEFAULT_CONFIG(); + /* Keep USB service off the dedicated, busy-waiting HID scheduler core. */ + config.task = TINYUSB_TASK_CUSTOM(4096, 18, 0); + config.descriptor.device = &device_descriptor; + config.descriptor.full_speed_config = configuration_descriptor; +#if (TUD_OPT_HIGH_SPEED) + config.descriptor.high_speed_config = configuration_descriptor; +#endif + config.descriptor.string = string_descriptors; + config.descriptor.string_count = sizeof(string_descriptors) / sizeof(string_descriptors[0]); + return tinyusb_driver_install(&config); +} + +void fixture_runtime_start_executor(void) { + configASSERT(xTaskCreatePinnedToCore(executor_task, "fixture_hid", 4096, NULL, 20, NULL, 1) == pdPASS); +} + +void fixture_runtime_set_network(bool connected, const char *address) { + runtime_lock(); + bool offline_demo = strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0; + if (network_connected && !connected && !offline_demo) { + fixture_abort_if_active(&fixture, "Wi-Fi disconnected during active run"); + } + network_connected = connected; + snprintf(network_address, sizeof(network_address), "%s", address ? address : "unassigned"); + runtime_unlock(); +} + +void fixture_runtime_set_network_name(const char *name) { + runtime_lock(); + snprintf(network_name, sizeof(network_name), "%s", name ? name : "unassigned"); + runtime_unlock(); +} + +void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot) { + memset(snapshot, 0, sizeof(*snapshot)); + runtime_lock(); + snapshot->ui.state = fixture.state; + snapshot->ui.wifi_connected = network_connected; + snapshot->ui.usb_mounted = fixture.usb_mounted; + snapshot->ui.event_count = fixture.event_count; + snapshot->ui.repeat_count = fixture.repeat_count; + snapshot->ui.current_repeat = fixture.current_repeat; + snapshot->ui.reports_submitted = fixture.reports_submitted; + snapshot->ui.late_reports = fixture.late_reports; + snapshot->ui.maximum_lateness_us = fixture.maximum_lateness_us; + snprintf(snapshot->run_id, sizeof(snapshot->run_id), "%s", fixture.run_id); + snprintf(snapshot->error, sizeof(snapshot->error), "%s", fixture.error); + snprintf(snapshot->network_address, sizeof(snapshot->network_address), "%s", network_address); + snprintf(snapshot->network_name, sizeof(snapshot->network_name), "%s", network_name); + snapshot->script_crc32 = fixture.script_crc32; + snapshot->next_event = fixture.next_event; + snapshot->transfers_completed = fixture.transfers_completed; + snapshot->submitted_crc32 = fixture.submitted_crc32; + snapshot->pending_release = fixture.pending_release; + snapshot->firmware_update_in_progress = firmware_update_in_progress; + snapshot->presentation = presentation; + runtime_unlock(); +} + +bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity) { + runtime_lock(); + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else { + ok = fixture_load_script(&fixture, body, length, error, capacity); + } + runtime_unlock(); + return ok; +} + +bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity) { + runtime_lock(); + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else { + ok = fixture_arm(&fixture, run_id, error, capacity); + } + runtime_unlock(); + return ok; +} + +bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity) { + runtime_lock(); + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else { + ok = fixture_start(&fixture, run_id, delay_ms, (uint64_t)esp_timer_get_time(), error, capacity); + } + runtime_unlock(); + return ok; +} + +bool fixture_runtime_prepare_demo(char *error, size_t capacity) { + runtime_lock(); + bool terminal = fixture.state == FIXTURE_IDLE || fixture.state == FIXTURE_COMPLETE || + fixture.state == FIXTURE_ABORTED || fixture.state == FIXTURE_ERROR; + bool ok = false; + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update in progress"); + } else if (!terminal) { + snprintf(error, capacity, "fixture is busy"); + } else if (fixture_demo_load(&fixture, error, capacity) && + fixture_arm(&fixture, FIXTURE_DEMO_RUN_ID, error, capacity)) { + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_NEXT; + presentation.reports_expected = fixture.event_count; + snprintf(presentation.title, sizeof(presentation.title), "DEMO ARMED"); + snprintf(presentation.detail, sizeof(presentation.detail), "TAP SCREEN TO TYPE"); + snprintf(presentation.next, sizeof(presentation.next), "TAP SCREEN"); + ok = true; + } + runtime_unlock(); + return ok; +} + +bool fixture_runtime_start_demo(char *error, size_t capacity) { + runtime_lock(); + bool ok = false; + if (fixture.state != FIXTURE_ARMED || strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) != 0) { + snprintf(error, capacity, "offline demo is not armed"); + } else if (fixture_start(&fixture, FIXTURE_DEMO_RUN_ID, 500u, + (uint64_t)esp_timer_get_time(), error, capacity)) { + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.result = FIXTURE_RESULT_NONE; + presentation.progress_per_mille = 0u; + snprintf(presentation.title, sizeof(presentation.title), "TYPING DEMO"); + snprintf(presentation.detail, sizeof(presentation.detail), "KEYPATH HID IN MOTION"); + snprintf(presentation.next, sizeof(presentation.next), "CHECK CAPTURE JIG"); + ok = true; + } + runtime_unlock(); + return ok; +} + +void fixture_runtime_abort(const char *reason) { + runtime_lock(); + fixture_abort(&fixture, reason); + runtime_unlock(); +} + +void fixture_runtime_set_presentation(const fixture_presentation_t *value) { + runtime_lock(); + presentation = *value; + runtime_unlock(); +} + +bool fixture_runtime_begin_firmware_update(char *error, size_t capacity) { + runtime_lock(); + bool terminal = fixture.state == FIXTURE_IDLE || fixture.state == FIXTURE_COMPLETE || + fixture.state == FIXTURE_ABORTED || fixture.state == FIXTURE_ERROR; + bool safe = terminal && !fixture.pending_release && !firmware_update_in_progress; + if (!safe) { + if (firmware_update_in_progress) { + snprintf(error, capacity, "firmware update already in progress"); + } else if (fixture.pending_release) { + snprintf(error, capacity, "waiting for the all-keys-released safety report"); + } else { + snprintf(error, capacity, "fixture must be idle or in a terminal state before updating"); + } + } else { + firmware_update_in_progress = true; + fixture_presentation_init(&presentation); + presentation.brand = FIXTURE_BRAND_KEYPATH; + presentation.branded_firmware_update = true; + presentation.phase = FIXTURE_PRESENT_PREPARING; + snprintf(presentation.title, sizeof(presentation.title), "FIRMWARE UPDATE"); + snprintf(presentation.detail, sizeof(presentation.detail), "AUTHENTICATING IMAGE"); + snprintf(presentation.next, sizeof(presentation.next), "KEEP POWER CONNECTED"); + } + runtime_unlock(); + return safe; +} + +void fixture_runtime_set_firmware_update_progress(uint16_t progress_per_mille, const char *detail) { + runtime_lock(); + if (firmware_update_in_progress) { + presentation.phase = FIXTURE_PRESENT_PREPARING; + presentation.progress_per_mille = progress_per_mille > 1000u ? 1000u : progress_per_mille; + snprintf(presentation.detail, sizeof(presentation.detail), "%s", detail ? detail : "UPDATING"); + } + runtime_unlock(); +} + +void fixture_runtime_end_firmware_update(bool success, const char *detail) { + runtime_lock(); + if (firmware_update_in_progress) { + presentation.phase = FIXTURE_PRESENT_RESULT; + presentation.result = success ? FIXTURE_RESULT_PASS : FIXTURE_RESULT_FAIL; + presentation.progress_per_mille = success ? 1000u : presentation.progress_per_mille; + snprintf(presentation.title, sizeof(presentation.title), "%s", + success ? "UPDATE VERIFIED" : "UPDATE REJECTED"); + snprintf(presentation.detail, sizeof(presentation.detail), "%s", + detail ? detail : (success ? "RESTARTING" : "IMAGE NOT INSTALLED")); + snprintf(presentation.next, sizeof(presentation.next), "%s", + success ? "RESTARTING SAFELY" : "CURRENT FIRMWARE SAFE"); + firmware_update_in_progress = false; + } + runtime_unlock(); +} + +uint32_t fixture_runtime_trace_count(void) { + runtime_lock(); + uint32_t count = fixture_trace_count(&fixture); + runtime_unlock(); + return count; +} + +bool fixture_runtime_trace_at(uint32_t index, fixture_trace_t *trace) { + bool found = false; + runtime_lock(); + const fixture_trace_t *source = fixture_trace_at(&fixture, index); + if (source) { + *trace = *source; + found = true; + } + runtime_unlock(); + return found; +} diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h new file mode 100644 index 000000000..08d2a10dc --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/fixture_runtime.h @@ -0,0 +1,47 @@ +#ifndef KEYPATH_ESP32_FIXTURE_RUNTIME_H +#define KEYPATH_ESP32_FIXTURE_RUNTIME_H + +#include +#include +#include + +#include "esp_err.h" +#include "fixture_core.h" +#include "fixture_presentation.h" +#include "fixture_ui_model.h" + +typedef struct { + fixture_ui_input_t ui; + char run_id[FIXTURE_MAX_RUN_ID + 1u]; + char error[128]; + char network_address[48]; + char network_name[33]; + uint32_t script_crc32; + uint32_t next_event; + uint64_t transfers_completed; + uint32_t submitted_crc32; + bool pending_release; + bool firmware_update_in_progress; + fixture_presentation_t presentation; +} fixture_runtime_snapshot_t; + +void fixture_runtime_init(void); +esp_err_t fixture_runtime_start_usb(void); +void fixture_runtime_start_executor(void); +void fixture_runtime_set_network(bool connected, const char *address); +void fixture_runtime_set_network_name(const char *name); +void fixture_runtime_snapshot(fixture_runtime_snapshot_t *snapshot); +bool fixture_runtime_load(const char *body, size_t length, char *error, size_t capacity); +bool fixture_runtime_arm(const char *run_id, char *error, size_t capacity); +bool fixture_runtime_start(const char *run_id, uint32_t delay_ms, char *error, size_t capacity); +bool fixture_runtime_prepare_demo(char *error, size_t capacity); +bool fixture_runtime_start_demo(char *error, size_t capacity); +void fixture_runtime_abort(const char *reason); +void fixture_runtime_set_presentation(const fixture_presentation_t *presentation); +bool fixture_runtime_begin_firmware_update(char *error, size_t capacity); +void fixture_runtime_set_firmware_update_progress(uint16_t progress_per_mille, const char *detail); +void fixture_runtime_end_firmware_update(bool success, const char *detail); +uint32_t fixture_runtime_trace_count(void); +bool fixture_runtime_trace_at(uint32_t index, fixture_trace_t *trace); + +#endif diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml new file mode 100644 index 000000000..4be786dd4 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/main/idf_component.yml @@ -0,0 +1,6 @@ +dependencies: + idf: "==5.5.5" + espressif/esp_tinyusb: "^2.0.0" + espressif/mdns: "^1.0.3" + waveshare/esp32_s3_touch_lcd_1_69: "==2.0.0" + lvgl/lvgl: "^9.3.0" diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv new file mode 100644 index 000000000..d7597038d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/partitions.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +ota_0, app, ota_0, 0x10000, 0x300000, +ota_1, app, ota_1, 0x310000, 0x300000, +coredump, data, coredump,0x610000, 0x10000, diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults new file mode 100644 index 000000000..2714dc3b7 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.defaults @@ -0,0 +1,31 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_CONF_MINIMAL=y +CONFIG_LV_USE_ARC=y +CONFIG_LV_USE_LABEL=y +CONFIG_LV_FONT_MONTSERRAT_12=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_16=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_KEYPATH_FIXTURE_BOOT_SPLASH=y +CONFIG_LV_BUILD_EXAMPLES=n +CONFIG_LV_BUILD_DEMOS=n +CONFIG_BSP_DISPLAY_LVGL_BUF_HEIGHT=40 +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_CDC_COUNT=0 +CONFIG_TINYUSB_MIDI_COUNT=0 +CONFIG_ESP_CONSOLE_NONE=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=8 +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n diff --git a/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults new file mode 100644 index 000000000..df40057f1 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/targets/waveshare-esp32-s3-touch-lcd-1.69/sdkconfig.qemu.defaults @@ -0,0 +1,29 @@ +# QEMU smoke configuration. Keep hardware-relevant defaults aligned with sdkconfig.defaults. +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y +CONFIG_SPIRAM_USE_MALLOC=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y +CONFIG_LV_COLOR_DEPTH_16=y +CONFIG_LV_CONF_MINIMAL=y +CONFIG_LV_USE_ARC=y +CONFIG_LV_USE_LABEL=y +CONFIG_LV_FONT_MONTSERRAT_12=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_16=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_24=y +CONFIG_LV_BUILD_EXAMPLES=n +CONFIG_LV_BUILD_DEMOS=n +CONFIG_BSP_DISPLAY_LVGL_BUF_HEIGHT=40 +CONFIG_TINYUSB_HID_COUNT=1 +CONFIG_TINYUSB_CDC_COUNT=0 +CONFIG_TINYUSB_MIDI_COUNT=0 +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=8 +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n diff --git a/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c new file mode 100644 index 000000000..2a64d702c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixture_core_tests.c @@ -0,0 +1,516 @@ +#include "fixture_button_feedback.h" +#include "fixture_core.h" +#include "fixture_demo.h" +#include "fixture_presentation.h" +#include "fixture_splash_model.h" +#include "fixture_ui_model.h" +#include "fixture_visual_model.h" +#include "fixture_wifi_model.h" + +#include +#include +#include + +typedef struct { + uint32_t count; + uint8_t modifiers[32]; + uint8_t keys[32][6]; +} reports_t; + +static bool capture_report(uint8_t modifiers, const uint8_t keys[6], void *context) { + reports_t *reports = context; + assert(reports->count < 32u); + reports->modifiers[reports->count] = modifiers; + memcpy(reports->keys[reports->count], keys, 6u); + reports->count++; + return true; +} + +static void make_script(char *output, size_t capacity, const char *run_id, + uint32_t repeats, const char *events, uint32_t event_count, uint32_t cycle_us) { + uint32_t crc = fixture_crc32(events, strlen(events)); + snprintf(output, capacity, "KPHID1 %s %u %u %u %08x\n%s", + run_id, event_count, repeats, cycle_us, crc, events); +} + +static void drain_initial_release(fixture_t *fixture, reports_t *reports) { + fixture_poll(fixture, 0u, true, true, capture_report, reports); + assert(reports->count == 1u); +} + +static void test_load_arm_run_and_repeat(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "run-42", 2u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture.state == FIXTURE_LOADED); + assert(fixture_arm(&fixture, "run-42", error, sizeof(error))); + fixture_poll(&fixture, 10u, true, true, capture_report, &reports); + assert(reports.count == 2u); /* pre-arm safety release */ + assert(fixture_start(&fixture, "run-42", 100u, 1000000u, error, sizeof(error))); + + fixture_poll(&fixture, 1099999u, true, true, capture_report, &reports); + assert(reports.count == 2u); + fixture_poll(&fixture, 1100000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 1150000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 1200000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 1250000u, true, true, capture_report, &reports); + assert(fixture.state == FIXTURE_COMPLETE); + assert(fixture.reports_submitted == 4u); + assert(reports.count == 6u); + assert(reports.keys[2][0] == 4u); + assert(reports.keys[3][0] == 0u); + assert(fixture_trace_count(&fixture) == 4u); + assert(fixture_trace_at(&fixture, 0u)->sequence == 1u); +} + +static void test_rejects_corrupt_and_unsafe_scripts(void) { + fixture_t fixture; + fixture_init(&fixture); + char error[128]; + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512]; + make_script(script, sizeof(script), "safe", 1u, events, 2u, 100000u); + script[strlen(script) - 2u] = '1'; + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "CRC32")); + + const char *held = "0 0 4 0 0 0 0 0\n"; + make_script(script, sizeof(script), "unsafe", 1u, held, 1u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "all-keys-released")); + + const char *duplicate = "0 0 4 4 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + make_script(script, sizeof(script), "duplicate", 1u, duplicate, 2u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(strstr(error, "duplicate")); +} + +static void test_loads_compiled_in_events_with_the_same_safety_checks(void) { + fixture_t fixture; + fixture_init(&fixture); + char error[128]; + const fixture_event_t events[] = { + {.at_us = 0u, .modifiers = 2u, .keys = {14u}}, + {.at_us = 2000u, .modifiers = 2u}, + {.at_us = 4000u}, + }; + assert(fixture_load_events(&fixture, "offline-demo", events, 3u, 1u, 12000u, + error, sizeof(error))); + assert(fixture.state == FIXTURE_LOADED); + assert(fixture.event_count == 3u); + assert(fixture.events[0].keys[0] == 14u); + assert(fixture.script_crc32 != 0u); + + fixture_event_t unsafe[] = {{.at_us = 0u, .keys = {4u}}}; + assert(!fixture_load_events(&fixture, "unsafe", unsafe, 1u, 1u, 12000u, + error, sizeof(error))); + assert(strstr(error, "all-keys-released")); + assert(fixture.state == FIXTURE_IDLE); +} + +static void test_offline_demo_is_precompiled_with_safe_shift_timing(void) { + fixture_t fixture; + fixture_init(&fixture); + char error[128]; + assert(fixture_demo_load(&fixture, error, sizeof(error))); + assert(strcmp(fixture.run_id, FIXTURE_DEMO_RUN_ID) == 0); + assert(fixture.repeat_count == 14u); + assert(fixture.cycle_us == 1429000u); + assert((uint64_t)fixture.repeat_count * fixture.cycle_us == 20006000u); + assert(fixture.event_count > strlen(FIXTURE_DEMO_TEXT) * 2u); + assert(fixture.events[0].modifiers == 2u); + assert(fixture.events[0].keys[0] == 0u); + assert(fixture.events[1].at_us == 5000u); + assert(fixture.events[1].keys[0] == 14u); /* K */ + const fixture_event_t *release = &fixture.events[fixture.event_count - 1u]; + assert(release->modifiers == 0u); + assert(release->keys[0] == 0u); +} + +static void test_failed_replacement_invalidates_previous_script(void) { + fixture_t fixture; + fixture_init(&fixture); + char script[512], error[128]; + + const char *safe = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + make_script(script, sizeof(script), "previous", 1u, safe, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + + const char *unsafe = "0 0 5 0 0 0 0 0\n"; + make_script(script, sizeof(script), "replacement", 1u, unsafe, 1u, 100000u); + assert(!fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture.state == FIXTURE_IDLE); + assert(fixture.event_count == 0u); + assert(fixture.run_id[0] == '\0'); + assert(!fixture_arm(&fixture, "previous", error, sizeof(error))); +} + +static void test_abort_and_unmount_force_release(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "safety", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "safety", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "safety", 100u, 1000u, error, sizeof(error))); + fixture_poll(&fixture, 101000u, true, true, capture_report, &reports); + fixture_poll(&fixture, 101001u, false, false, capture_report, &reports); + assert(fixture.state == FIXTURE_ERROR); + assert(fixture.pending_release); + fixture_poll(&fixture, 101002u, true, true, capture_report, &reports); + assert(!fixture.pending_release); + assert(reports.keys[reports.count - 1u][0] == 0u); + + fixture_abort(&fixture, "operator abort"); + assert(fixture.state == FIXTURE_ABORTED); + fixture_poll(&fixture, 101003u, true, true, capture_report, &reports); + assert(reports.keys[reports.count - 1u][0] == 0u); +} + +static void test_control_plane_disconnect_aborts_only_active_runs(void) { + fixture_t fixture; + fixture_init(&fixture); + assert(!fixture_abort_if_active(&fixture, "Wi-Fi disconnected")); + assert(fixture.state == FIXTURE_IDLE); + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "network", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(!fixture_abort_if_active(&fixture, "Wi-Fi disconnected")); + assert(fixture_arm(&fixture, "network", error, sizeof(error))); + assert(fixture_abort_if_active(&fixture, "Wi-Fi disconnected")); + assert(fixture.state == FIXTURE_ABORTED); + assert(fixture.pending_release); + assert(strstr(fixture.error, "Wi-Fi")); +} + +static void test_lateness_metrics(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + drain_initial_release(&fixture, &reports); + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "late", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "late", error, sizeof(error))); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + assert(fixture_start(&fixture, "late", 100u, 0u, error, sizeof(error))); + fixture_poll(&fixture, 103000u, true, true, capture_report, &reports); + assert(fixture.late_reports == 1u); + assert(fixture.maximum_lateness_us == 3000); +} + +static void test_next_action_deadline_supports_cooperative_waiting(void) { + fixture_t fixture; + reports_t reports = {0}; + fixture_init(&fixture); + assert(fixture_time_until_next_action_us(&fixture, 0u) == 0u); + drain_initial_release(&fixture, &reports); + assert(fixture_time_until_next_action_us(&fixture, 0u) == UINT32_MAX); + + const char *events = "0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n"; + char script[512], error[128]; + make_script(script, sizeof(script), "cooperative", 1u, events, 2u, 100000u); + assert(fixture_load_script(&fixture, script, strlen(script), error, sizeof(error))); + assert(fixture_arm(&fixture, "cooperative", error, sizeof(error))); + assert(fixture_time_until_next_action_us(&fixture, 0u) == 0u); + fixture_poll(&fixture, 1u, true, true, capture_report, &reports); + + assert(fixture_start(&fixture, "cooperative", 100u, 1000000u, error, sizeof(error))); + assert(fixture_time_until_next_action_us(&fixture, 1000000u) == 100000u); + assert(fixture_time_until_next_action_us(&fixture, 1099000u) == 1000u); + assert(fixture_time_until_next_action_us(&fixture, 1100000u) == 0u); + fixture_poll(&fixture, 1100000u, true, true, capture_report, &reports); + assert(fixture_time_until_next_action_us(&fixture, 1100000u) == 50000u); +} + +static void test_ui_model_prioritizes_hid_and_tracks_progress(void) { + fixture_ui_model_t model; + fixture_ui_model_init(&model); + fixture_ui_input_t input = { + .state = FIXTURE_RUNNING, + .wifi_connected = true, + .usb_mounted = true, + .event_count = 10u, + .repeat_count = 10u, + }; + fixture_ui_output_t output = fixture_ui_model_step(&model, &input, 1000u); + assert(output.scene == FIXTURE_UI_RUNNING); + assert(output.quality == FIXTURE_UI_ACTIVE); + assert(output.frame_interval_ms == 50u); + + input.reports_submitted = 25u; + output = fixture_ui_model_step(&model, &input, 1010u); + assert(output.progress_per_mille == 250u); + assert(output.energy_per_mille > 900u); + + input.late_reports = 1u; + input.maximum_lateness_us = 2000; + output = fixture_ui_model_step(&model, &input, 1020u); + assert(output.quality == FIXTURE_UI_PROTECTED); + assert(output.frame_interval_ms == 125u); + assert(output.pressure_warning); + + output = fixture_ui_model_step(&model, &input, 2400u); + assert(output.quality == FIXTURE_UI_PROTECTED); + output = fixture_ui_model_step(&model, &input, 2600u); + assert(output.quality == FIXTURE_UI_ACTIVE); + + input.state = FIXTURE_COMPLETE; + input.reports_submitted = 100u; + output = fixture_ui_model_step(&model, &input, 2610u); + assert(output.scene == FIXTURE_UI_COMPLETE); + assert(output.progress_per_mille == 1000u); + assert(output.completion_burst); + output = fixture_ui_model_step(&model, &input, 2620u); + assert(!output.completion_burst); +} + +static void test_presentation_contract(void) { + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + assert(presentation.phase == FIXTURE_PRESENT_AUTO); + assert(fixture_presentation_parse_phase("observing", &presentation.phase)); + assert(presentation.phase == FIXTURE_PRESENT_OBSERVING); + assert(fixture_presentation_parse_result("inconclusive", &presentation.result)); + assert(presentation.result == FIXTURE_RESULT_INCONCLUSIVE); + assert(fixture_presentation_parse_brand("bear", &presentation.brand)); + assert(presentation.brand == FIXTURE_BRAND_BEAR); + assert(strcmp(fixture_presentation_brand_name(presentation.brand), "bear") == 0); + assert(fixture_presentation_text_valid("Swift stress / pass 2", 32u)); + assert(!fixture_presentation_text_valid("unsafe \"label\"", 32u)); + assert(!fixture_presentation_parse_phase("dancing", &presentation.phase)); + assert(!fixture_presentation_parse_brand("unknown", &presentation.brand)); +} + +static void test_visual_model_resolves_automatic_and_campaign_states(void) { + fixture_ui_output_t ui = { + .scene = FIXTURE_UI_IDLE, + .quality = FIXTURE_UI_SHOWCASE, + .progress_per_mille = 125u, + }; + fixture_presentation_t presentation; + fixture_presentation_init(&presentation); + fixture_visual_output_t visual; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(visual.accent_rgb == 0x56ddb3u); + assert(visual.progress_per_mille == 125u); + assert(visual.angular_speed_milliradians == 1550u); + assert(strcmp(visual.title, "READY") == 0); + + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.progress_per_mille = 640u; + snprintf(presentation.title, sizeof(presentation.title), "Swift stress"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(visual.accent_rgb == 0x55c7ffu); + assert(visual.progress_per_mille == 640u); + assert(visual.angular_speed_milliradians == 4800u); + assert(strcmp(visual.title, "Swift stress") == 0); + + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_PREPARING; + presentation.branded_firmware_update = true; + presentation.progress_per_mille = 420u; + snprintf(presentation.title, sizeof(presentation.title), "FIRMWARE UPDATE"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE); + assert(visual.icon == FIXTURE_ICON_DOWNLOAD); + assert(visual.accent_rgb == 0xf3a128u); + assert(visual.progress_per_mille == 420u); + assert(visual.angular_speed_milliradians == 2500u); + assert(strcmp(visual.title, "FIRMWARE UPDATE") == 0); + + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.brand = FIXTURE_BRAND_KEYPATH; + snprintf(presentation.title, sizeof(presentation.title), "KEYPATH HID TEST"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.variant == FIXTURE_VISUAL_KEYPATH_UPDATE); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(strcmp(visual.title, "KEYPATH HID TEST") == 0); + + fixture_presentation_init(&presentation); + presentation.phase = FIXTURE_PRESENT_TESTING; + presentation.brand = FIXTURE_BRAND_BEAR; + snprintf(presentation.title, sizeof(presentation.title), "TYPING IN BEAR"); + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.variant == FIXTURE_VISUAL_BEAR_TEST); + assert(visual.icon == FIXTURE_ICON_KEYBOARD); + assert(visual.accent_rgb == 0xe34c42u); + assert(strcmp(visual.title, "TYPING IN BEAR") == 0); + + presentation.result = FIXTURE_RESULT_FAIL; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_CLOSE); + assert(visual.accent_rgb == 0x9b8afbu); + assert(strcmp(visual.title, "TEST FAILED") == 0); + + presentation.result = FIXTURE_RESULT_INCONCLUSIVE; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_WARNING); + assert(visual.accent_rgb == 0xf2b84bu); + + presentation.result = FIXTURE_RESULT_PASS; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.icon == FIXTURE_ICON_OK); + assert(visual.accent_rgb == 0x66c9a3u); + + ui.quality = FIXTURE_UI_PROTECTED; + fixture_visual_resolve(&ui, &presentation, &visual); + assert(visual.angular_speed_milliradians == 480u); +} + +static void test_ui_model_connection_error_and_counter_reset(void) { + fixture_ui_model_t model; + fixture_ui_model_init(&model); + fixture_ui_input_t input = {.state = FIXTURE_BOOTING}; + fixture_ui_output_t output = fixture_ui_model_step(&model, &input, 0u); + assert(output.scene == FIXTURE_UI_CONNECTING); + + input.state = FIXTURE_ERROR; + output = fixture_ui_model_step(&model, &input, 10u); + assert(output.scene == FIXTURE_UI_ERROR); + + input.state = FIXTURE_RUNNING; + input.wifi_connected = true; + input.event_count = 2u; + input.repeat_count = 2u; + input.reports_submitted = 10u; + output = fixture_ui_model_step(&model, &input, 20u); + assert(output.progress_per_mille == 1000u); + + input.reports_submitted = 0u; + output = fixture_ui_model_step(&model, &input, 30u); + assert(output.progress_per_mille == 0u); + assert(output.energy_per_mille <= 1000u); +} + +static void test_wifi_profiles_retry_in_priority_order_and_wrap(void) { + fixture_wifi_model_t model; + fixture_wifi_model_init(&model); + assert(model.profile_index == 0u); /* 529beach */ + + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 0u); + assert(fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 1u); /* Alpern-Home */ + + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + fixture_wifi_model_note_connected(&model); + assert(model.failed_attempts == 0u); + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 2u); /* iPhone */ + + assert(!fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(fixture_wifi_model_note_disconnect(&model, 3u, 2u)); + assert(model.profile_index == 0u); /* wrap back to 529beach */ +} + +static void test_splash_reveals_holds_and_fades_without_blocking_boot(void) { + fixture_splash_output_t splash = fixture_splash_step(0u); + assert(splash.foreground_opacity == 0u); + assert(splash.background_opacity == 255u); + assert(splash.wordmark_opacity == 0u); + assert(splash.logo_scale == 228u); + assert(!splash.complete); + + splash = fixture_splash_step(FIXTURE_SPLASH_FADE_IN_MS); + assert(splash.foreground_opacity == 255u); + assert(splash.logo_scale == 256u); + + splash = fixture_splash_step(500u); + assert(splash.foreground_opacity == 255u); + assert(splash.wordmark_opacity == 255u); + + splash = fixture_splash_step(FIXTURE_SPLASH_HOLD_END_MS); + assert(splash.foreground_opacity == 255u); + assert(splash.background_opacity == 255u); + + splash = fixture_splash_step(1450u); + assert(splash.foreground_opacity < 128u); + assert(splash.background_opacity == splash.foreground_opacity); + assert(splash.logo_scale > 256u); + assert(!splash.complete); + + splash = fixture_splash_step(FIXTURE_SPLASH_TOTAL_MS); + assert(splash.foreground_opacity == 0u); + assert(splash.background_opacity == 0u); + assert(splash.complete); +} + +static void test_button_feedback_identifies_physical_positions_and_expires(void) { + fixture_button_feedback_output_t feedback = + fixture_button_feedback_resolve(FIXTURE_BUTTON_POWER, false, false, 0u); + assert(feedback.active); + assert(strcmp(feedback.title, "POWER BUTTON") == 0); + assert(strcmp(feedback.detail, "TOP button detected") == 0); + assert(feedback.accent_rgb == 0xffb454u); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_BOOT, true, true, 180u); + assert(feedback.active); + assert(strcmp(feedback.title, "BOOT HELD") == 0); + assert(strstr(feedback.detail, "TAP BOTTOM RESET")); + assert(feedback.pulse_per_mille == 1000u); + + feedback = fixture_button_feedback_resolve( + FIXTURE_BUTTON_BOOT, true, true, FIXTURE_BUTTON_FEEDBACK_DURATION_MS * 4u); + assert(feedback.active); + assert(strcmp(feedback.title, "BOOT HELD") == 0); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_BOOT, false, true, 200u); + assert(feedback.active); + assert(strcmp(feedback.title, "BOOT RELEASED") == 0); + assert(strstr(feedback.detail, "while tapping RESET")); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_BOOT, true, false, 200u); + assert(feedback.active); + assert(strcmp(feedback.detail, "Test abort requested") == 0); + + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_RESET, false, false, 600u); + assert(feedback.active); + assert(strcmp(feedback.title, "RESET / START") == 0); + assert(strstr(feedback.detail, "BOTTOM RST")); + + feedback = fixture_button_feedback_resolve( + FIXTURE_BUTTON_BOOT, false, true, FIXTURE_BUTTON_FEEDBACK_DURATION_MS); + assert(!feedback.active); + feedback = fixture_button_feedback_resolve(FIXTURE_BUTTON_NONE, false, false, 0u); + assert(!feedback.active); +} + +int main(void) { + test_load_arm_run_and_repeat(); + test_rejects_corrupt_and_unsafe_scripts(); + test_loads_compiled_in_events_with_the_same_safety_checks(); + test_offline_demo_is_precompiled_with_safe_shift_timing(); + test_failed_replacement_invalidates_previous_script(); + test_abort_and_unmount_force_release(); + test_control_plane_disconnect_aborts_only_active_runs(); + test_lateness_metrics(); + test_next_action_deadline_supports_cooperative_waiting(); + test_ui_model_prioritizes_hid_and_tracks_progress(); + test_ui_model_connection_error_and_counter_reset(); + test_presentation_contract(); + test_visual_model_resolves_automatic_and_campaign_states(); + test_wifi_profiles_retry_in_priority_order_and_wrap(); + test_splash_reveals_holds_and_fades_without_blocking_boot(); + test_button_feedback_identifies_physical_positions_and_expires(); + puts("physical HID fixture core tests passed"); + return 0; +} diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt new file mode 100644 index 000000000..3a078b945 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/baseline.txt @@ -0,0 +1 @@ +keypath-fixture-baseline-20260727 diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt new file mode 100644 index 000000000..b2ba30591 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/burst.txt @@ -0,0 +1 @@ +aZ9!?-_=+[] diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt new file mode 100644 index 000000000..4106b96e1 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-alternating.txt @@ -0,0 +1 @@ +abababababababababababababababababababababababababababababababab diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt new file mode 100644 index 000000000..430d0429b --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-roll.txt @@ -0,0 +1 @@ +asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl;asdfjkl; diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt new file mode 100644 index 000000000..93f308c87 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/repeat-single.txt @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt b/Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt new file mode 100644 index 000000000..04bfaec9d --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/fixtures/shift-and-symbols.txt @@ -0,0 +1 @@ +KeyPath! Shift_Test: []{} <>? 12345 diff --git a/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh new file mode 100755 index 000000000..a096ab181 --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/run-esp32-qemu-smoke.sh @@ -0,0 +1,85 @@ +#!/bin/bash +set -euo pipefail + +fixture_root=$(cd "$(dirname "$0")/.." && pwd) +target="$fixture_root/targets/waveshare-esp32-s3-touch-lcd-1.69" +idf_path=${IDF_PATH:-"$HOME/.cache/keypath-esp32/esp-idf"} +build_dir=${KEYPATH_ESP32_QEMU_BUILD_DIR:-"${TMPDIR:-/tmp}/keypath-esp32-qemu-smoke-build"} +qemu_defaults="$target/sdkconfig.qemu.defaults" +defaults_stamp="$build_dir/.keypath-qemu-defaults.sha256" + +if [[ ! -f "$idf_path/export.sh" ]]; then + echo "ESP-IDF was not found at $idf_path" >&2 + exit 1 +fi + +if [[ -z "${IDF_PYTHON_ENV_PATH:-}" ]]; then + for candidate in "$HOME"/.espressif/python_env/idf5.5_py*_env; do + if [[ -x "$candidate/bin/python" ]]; then + IDF_PYTHON_ENV_PATH=$candidate + export IDF_PYTHON_ENV_PATH + break + fi + done +fi + +# shellcheck disable=SC1090 +source "$idf_path/export.sh" >/dev/null +export KEYPATH_WIFI_SSID_1=fixture-qemu-primary +export KEYPATH_WIFI_PASSWORD_1=fixture-qemu-placeholder +export KEYPATH_WIFI_SSID_3=fixture-qemu-fallback-two +export KEYPATH_WIFI_PASSWORD_3=fixture-qemu-placeholder +export KEYPATH_WIFI_SSID_4=fixture-qemu-current-location +export KEYPATH_WIFI_PASSWORD_4=fixture-qemu-placeholder +export KEYPATH_HACKER_DOJO_USERNAME=fixture-qemu-enterprise-user +export KEYPATH_HACKER_DOJO_PASSWORD=fixture-qemu-enterprise-password +export KEYPATH_FIXTURE_TOKEN=fixture-qemu-token-placeholder +export KEYPATH_QEMU_SMOKE=1 + +defaults_hash=$(shasum -a 256 "$qemu_defaults" | awk '{print $1}') +stored_hash=$(cat "$defaults_stamp" 2>/dev/null || true) +if [[ ! -f "$build_dir/sdkconfig" || "$stored_hash" != "$defaults_hash" ]]; then + rm -f "$build_dir/sdkconfig" + idf.py -C "$target" -B "$build_dir" \ + -D "SDKCONFIG=$build_dir/sdkconfig" \ + -D "SDKCONFIG_DEFAULTS=$qemu_defaults" \ + reconfigure build >/dev/null + printf '%s\n' "$defaults_hash" >"$defaults_stamp" +else + idf.py -C "$target" -B "$build_dir" build >/dev/null +fi + +log_file=$(mktemp "${TMPDIR:-/tmp}/keypath-qemu-smoke.XXXXXX") +qemu_pid= +cleanup() { + if [[ -n "$qemu_pid" ]] && kill -0 "$qemu_pid" 2>/dev/null; then + child_pids=$(pgrep -P "$qemu_pid" 2>/dev/null || true) + [[ -z "$child_pids" ]] || kill $child_pids 2>/dev/null || true + kill "$qemu_pid" 2>/dev/null || true + wait "$qemu_pid" 2>/dev/null || true + fi + rm -f "$log_file" +} +trap cleanup EXIT INT TERM + +idf.py -C "$target" -B "$build_dir" qemu >"$log_file" 2>&1 & +qemu_pid=$! +for _ in $(seq 1 120); do + if grep -q "KEYPATH_QEMU_SMOKE_PASS" "$log_file"; then + echo "ESP32-S3 QEMU smoke test passed" + exit 0 + fi + if grep -q "KEYPATH_QEMU_SMOKE_FAIL" "$log_file"; then + cat "$log_file" >&2 + exit 1 + fi + if ! kill -0 "$qemu_pid" 2>/dev/null; then + cat "$log_file" >&2 + exit 1 + fi + sleep 0.25 +done + +cat "$log_file" >&2 +echo "Timed out waiting for the ESP32-S3 QEMU smoke marker" >&2 +exit 1 diff --git a/Scripts/lab/pico-hid-fixture/tests/run-tests.sh b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh new file mode 100755 index 000000000..2cab9f23c --- /dev/null +++ b/Scripts/lab/pico-hid-fixture/tests/run-tests.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +fixture_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +test_binary=$(mktemp "${TMPDIR:-/tmp}/keypath-pico-core.XXXXXX") +trap 'rm -f "$test_binary"' EXIT HUP INT TERM + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$fixture_root/src" \ + "$fixture_root/src/fixture_button_feedback.c" \ + "$fixture_root/src/fixture_core.c" \ + "$fixture_root/src/fixture_demo.c" \ + "$fixture_root/src/fixture_presentation.c" \ + "$fixture_root/src/fixture_splash_model.c" \ + "$fixture_root/src/fixture_ui_model.c" \ + "$fixture_root/src/fixture_visual_model.c" \ + "$fixture_root/src/fixture_wifi_model.c" \ + "$fixture_root/tests/fixture_core_tests.c" \ + -o "$test_binary" +"$test_binary" diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 4877e1b1b..699b32342 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -44,6 +44,11 @@ valid_id() { [[ "$1" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid identifier: $1" } +valid_archive_key() { + valid_id "$1" + [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}(-h[0-9a-f]{40})?(-[0-9a-f]{64})?$' ]] || die "invalid archive key" +} + launcher_for() { case "$1" in 15) print -r -- "$LAUNCHER_15" ;; @@ -66,9 +71,11 @@ configure_tart_path() { } base_for() { - local macos=$1 lane=$2 + local macos=$1 lane=$2 desktop=${3:-0} if [[ "$macos" == "15" ]]; then [[ "$lane" == "managed-functional" ]] && print keypath-macos-15-managed || print ghcr.io/cirruslabs/macos-sequoia-base:latest + elif [[ ("$macos" == "26" || "$macos" == "27") && "$desktop" == "1" && "$lane" != "managed-functional" ]]; then + print "keypath-macos-$macos-desktop" else [[ "$lane" == "managed-functional" ]] && print "keypath-macos-$macos-managed" || print "keypath-macos-$macos" fi @@ -82,7 +89,8 @@ field() { } set_field() { - local manifest=$1 key=$2 value=$3 temp="${manifest}.tmp.$$" + local manifest=$1 key=$2 value=$3 + local temp="${manifest}.tmp.$$" awk -F '\t' -v key="$key" -v value="$value" 'BEGIN {OFS="\t"} $1 == key {$0=key OFS value; found=1} {print} END {if (!found) print key, value}' "$manifest" > "$temp" mv "$temp" "$manifest" } @@ -307,7 +315,7 @@ managed_enrollment_id_for() { } approve_peekaboo_capture() { - local lease=$1 manifest macos resource key ip prompt_command prompt_coords attempt + local lease=$1 manifest macos resource key ip prompt_command prompt_coords private_prompt_command private_prompt_coords attempt approved manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "15" ]] || die "Peekaboo capture approval currently supports only the Tart macOS 15 lane" @@ -321,6 +329,8 @@ approve_peekaboo_capture() { ip=$($TART ip "$resource") [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" prompt_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run() {\n var matches = Application("System Events").processes.whose({name: "NotificationCenter"})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "";\n var window = matches[0].windows[0];\n try {\n var size = window.size();\n if (window.subrole() === "AXSystemDialog" && size[0] === 1024 && size[1] === 768) return "512,399";\n } catch (_) {}\n return "";\n}\'' + private_prompt_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run() {\n var matches = Application("System Events").processes.whose({name: "UserNotificationCenter"})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "";\n var window = matches[0].windows[0];\n try {\n var message = window.staticTexts().map(function(item) { return item.value() || ""; }).join(" ");\n if (window.subrole() !== "AXSystemDialog" || message.indexOf("boo.peekaboo.peekaboo") === -1 || message.indexOf("private window picker") === -1) return "";\n var buttons = window.buttons().filter(function(item) { return item.name() === "Allow"; });\n if (buttons.length !== 1) return "";\n var position = buttons[0].position();\n var size = buttons[0].size();\n return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n } catch (_) {}\n return "";\n}\'' + approved=0 prompt_coords= for attempt in {1..20}; do prompt_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" \ @@ -328,16 +338,43 @@ approve_peekaboo_capture() { [[ -n "$prompt_coords" ]] && break sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_POLL_SECONDS:-0.2}" done - if [[ -z "$prompt_coords" ]]; then + if [[ -n "$prompt_coords" ]]; then + [[ "$prompt_coords" =~ '^[0-9]+,[0-9]+$' ]] || die "Peekaboo capture approval prompt coordinates are invalid" + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" \ + --x "${prompt_coords%,*}" --y "${prompt_coords#*,}" >/dev/null + approved=1 + sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_SETTLE_SECONDS:-5}" + fi + + # macOS 15 can follow the full-screen ScreenCaptureKit prompt with a second, + # smaller dialog authorizing Peekaboo's private-window-picker bypass. Leaving + # that dialog on screen makes later RFB coordinates hit the obscured Settings + # sidebar. Match the exact Peekaboo request and derive the Allow button center + # from the live AX tree instead of storing another fixed coordinate. + private_prompt_coords= + for attempt in {1..20}; do + private_prompt_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" \ + "/bin/zsh -lc $(printf %q "$private_prompt_command")") + [[ -n "$private_prompt_coords" ]] && break + sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_POLL_SECONDS:-0.2}" + done + if [[ -n "$private_prompt_coords" ]]; then + [[ "$private_prompt_coords" =~ '^[0-9]+,[0-9]+$' ]] || die "Peekaboo private capture approval coordinates are invalid" + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" \ + --x "${private_prompt_coords%,*}" --y "${private_prompt_coords#*,}" >/dev/null + approved=1 + sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_SETTLE_SECONDS:-5}" + private_prompt_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" \ + "/bin/zsh -lc $(printf %q "$private_prompt_command")") + [[ -z "$private_prompt_coords" ]] || die "Peekaboo private capture approval prompt remained visible" + fi + + if ((approved)); then + record_command "$lease" passed approve-peekaboo-capture + print "peekaboo_capture_approval\tpassed" + else print "peekaboo_capture_approval\talready-approved" - return 0 fi - [[ "$prompt_coords" =~ '^[0-9]+,[0-9]+$' ]] || die "Peekaboo capture approval prompt coordinates are invalid" - "$CRABBOX" desktop click --provider tart --target macos --id "$resource" \ - --x "${prompt_coords%,*}" --y "${prompt_coords#*,}" >/dev/null - sleep "${KEYPATH_LAB_CAPTURE_APPROVAL_SETTLE_SECONDS:-5}" - record_command "$lease" passed approve-peekaboo-capture - print "peekaboo_capture_approval\tpassed" } rehydrate_managed_clone() { @@ -441,7 +478,7 @@ resume_managed_policy() { [[ "$lane" == managed-functional ]] || die "managed policy resume requires a managed-functional lease" set +e - rehydrate_managed_clone "$lease" > "$LOGS/$lease/managed-policy.log" 2>&1 + (rehydrate_managed_clone "$lease") > "$LOGS/$lease/managed-policy.log" 2>&1 result=$? set -e set_field "$manifest" managed_policy_result "$result" @@ -478,18 +515,23 @@ run_with_download() { warmup_desktop() { local macos=$1 lane=$2 slug=$3 if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then - "$CRABBOX" warmup --provider "$(provider_for "$macos")" --target macos --desktop --slug "$slug" --ttl 2h + if [[ "$(provider_for "$macos")" == "parallels" ]]; then + "$CRABBOX" warmup --provider parallels --target macos --desktop \ + --parallels-template "$(base_for "$macos" "$lane" 1)" --slug "$slug" --ttl 2h + else + "$CRABBOX" warmup --provider tart --target macos --desktop --slug "$slug" --ttl 2h + fi elif [[ "$macos" == "15" ]]; then if [[ "${USER:-}" == "clawd" ]]; then export TART_HOME="$LAB_ROOT/TartHome-clawd"; else export TART_HOME="$LAB_ROOT/TartHome"; fi configure_tart_path "$CRABBOX" warmup --provider tart --target macos --desktop \ - --tart-image "$(base_for "$macos" "$lane")" \ + --tart-image "$(base_for "$macos" "$lane" 1)" \ --tart-user admin --tart-cpu 4 --tart-memory 8192 --tart-random-serial --ssh-port 22 \ --slug "$slug" --ttl 2h else export PATH="$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" "$CRABBOX" warmup --provider parallels --target macos --desktop \ - --parallels-template "$(base_for "$macos" "$lane")" --parallels-user keypathqa \ + --parallels-template "$(base_for "$macos" "$lane" 1)" --parallels-user keypathqa \ --parallels-work-root /Users/keypathqa/crabbox --ssh-port 22 \ --slug "$slug" --ttl 2h fi @@ -541,16 +583,31 @@ preflight() { } prepare_upload() { - valid_id "$1" - [[ "$1" =~ '^[0-9a-f]{40}-[0-9a-f]{64}$' ]] || die "invalid archive key" + valid_archive_key "$1" mktemp "/tmp/keypath-lab.XXXXXXXX" } +archive_status() { + local key=$1 commit=$2 installer_sha=$3 installer_name=$4 destination ready + valid_archive_key "$key" + [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" + [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" + [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" + destination="$ARCHIVES/$key" + ready="$destination/ready.tsv" + [[ -f "$ready" && -d "$destination/repo/.git" ]] || return 1 + [[ "$(field "$ready" owner)" == "$OWNER" ]] || die "archive ownership mismatch" + [[ "$(field "$ready" keypath_commit)" == "$commit" ]] || die "archive commit mismatch" + [[ "$(field "$ready" installer_sha256)" == "$installer_sha" ]] || die "archive installer checksum mismatch" + [[ "$(field "$ready" installer_name)" == "$installer_name" ]] || die "archive installer name mismatch" + print "archive\tready\t$key" +} + install_archive() { local source=$1 key=$2 commit=$3 installer_sha=$4 installer_name=$5 [[ "$source" =~ '^/tmp/keypath-lab\.[A-Za-z0-9]+$' ]] || die "invalid upload ticket" [[ -f "$source" && ! -L "$source" && -O "$source" ]] || die "upload ticket is not an owned regular file" - valid_id "$key" + valid_archive_key "$key" [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" @@ -575,6 +632,10 @@ install_archive() { git -C "$staging/repo" config user.name "KeyPath Lab" git -C "$staging/repo" config user.email "keypath-lab@localhost" git -C "$staging/repo" add -A + # The product checkout ignores local campaign state under .keypath-lab, but + # an immutable lab archive must retain its installer, policy, and fixture + # payload when it is cloned into a disposable operation worktree. + git -C "$staging/repo" add -f .keypath-lab GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z git -C "$staging/repo" commit -q -m "KeyPath lab archive $commit" [[ -z "$(git -C "$staging/repo" status --porcelain)" ]] || die "archive checkout is dirty" { @@ -614,6 +675,164 @@ install_archive() { print "archive\tcreated\t$key" } +derive_archive() { + local overlay=$1 source_key=$2 key=$3 commit=$4 installer_sha=$5 installer_name=$6 harness_commit=$7 + [[ "$overlay" =~ '^/tmp/keypath-lab\.[A-Za-z0-9]+$' ]] || die "invalid upload ticket" + [[ -f "$overlay" && ! -L "$overlay" && -O "$overlay" ]] || die "upload ticket is not an owned regular file" + valid_archive_key "$source_key" + valid_archive_key "$key" + [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" + [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" + [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" + [[ "$harness_commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid harness commit" + ensure_roots + local source="$ARCHIVES/$source_key" destination="$ARCHIVES/$key" + local staging="$ARCHIVES/.staging-$key-$$" lock="$ARCHIVES/.lock-$key" attempt actual_sha + [[ -f "$source/ready.tsv" && -d "$source/repo/.git" ]] || die "source archive is unavailable" + [[ "$(field "$source/ready.tsv" owner)" == "$OWNER" ]] || die "source archive ownership mismatch" + [[ "$(field "$source/ready.tsv" keypath_commit)" == "$commit" ]] || die "source archive commit mismatch" + [[ "$(field "$source/ready.tsv" installer_sha256)" == "$installer_sha" ]] || die "source archive installer mismatch" + if [[ -f "$destination/ready.tsv" ]]; then + rm -f "$overlay" + print "archive\treused\t$key" + return + fi + if ! mkdir "$lock" 2>/dev/null; then + rm -f "$overlay" + for attempt in {1..100}; do + [[ -f "$destination/ready.tsv" ]] && { print "archive\treused\t$key"; return; } + sleep 0.1 + done + die "timed out waiting for concurrent derived archive publish: $key" + fi + git clone -q --no-hardlinks "$source/repo" "$staging/repo" + rm -rf "$staging/repo/Scripts/lab" + tar -xzf "$overlay" -C "$staging/repo" + rm -f "$overlay" + actual_sha=$(shasum -a 256 "$staging/repo/.keypath-lab/installer/$installer_name" | awk '{print $1}') + [[ "$actual_sha" == "$installer_sha" ]] || die "derived archive installer checksum mismatch" + git -C "$staging/repo" config user.name "KeyPath Lab" + git -C "$staging/repo" config user.email "keypath-lab@localhost" + git -C "$staging/repo" add -A + git -C "$staging/repo" add -f .keypath-lab + GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ + git -C "$staging/repo" commit -q -m "KeyPath lab harness $harness_commit" + [[ -z "$(git -C "$staging/repo" status --porcelain)" ]] || die "derived archive checkout is dirty" + { + print "owner\t$OWNER" + print "keypath_commit\t$commit" + print "harness_commit\t$harness_commit" + print "installer_sha256\t$installer_sha" + print "installer_name\t$installer_name" + print "derived_from\t$source_key" + print "created_at\t$(utc_now)" + } > "$staging/ready.tsv" + if [[ -e "$destination" ]]; then + rm -rf "$staging" + rmdir "$lock" + die "derived archive destination exists without a ready marker: $key" + fi + mv "$staging" "$destination" + rmdir "$lock" + print "archive\tderived\t$key" +} + +find_fixture_archive() { + local fixture_sha=$1 fixture_name=$2 candidate source key actual_sha + [[ "$fixture_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid fixture checksum" + [[ "$fixture_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid fixture name" + ensure_roots + for candidate in "$ARCHIVES"/*(/N); do + source="$candidate/repo/.keypath-lab/source.tsv" + [[ -f "$candidate/ready.tsv" && -f "$source" ]] || continue + [[ "$(field "$candidate/ready.tsv" owner)" == "$OWNER" ]] || continue + [[ "$(field "$source" fixture_name)" == "$fixture_name" ]] || continue + [[ "$(field "$source" fixture_sha256)" == "$fixture_sha" ]] || continue + [[ -f "$candidate/repo/.keypath-lab/fixtures/$fixture_name" && ! -L "$candidate/repo/.keypath-lab/fixtures/$fixture_name" ]] || continue + actual_sha=$(shasum -a 256 "$candidate/repo/.keypath-lab/fixtures/$fixture_name" | awk '{print $1}') + [[ "$actual_sha" == "$fixture_sha" ]] || continue + key=${candidate:t} + valid_archive_key "$key" + print "fixture_archive\t$key" + return 0 + done + return 1 +} + +derive_fixture_archive() { + local source_key=$1 fixture_source_key=$2 key=$3 commit=$4 installer_sha=$5 installer_name=$6 + local harness_commit=$7 fixture_sha=$8 fixture_name=$9 + local source="$ARCHIVES/$source_key" fixture_source="$ARCHIVES/$fixture_source_key" destination="$ARCHIVES/$key" + local staging="$ARCHIVES/.staging-$key-$$" lock="$ARCHIVES/.lock-$key" attempt actual_sha fixture_path source_metadata + valid_archive_key "$source_key" + valid_archive_key "$fixture_source_key" + valid_archive_key "$key" + [[ "$commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid commit SHA" + [[ "$installer_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid installer checksum" + [[ "$installer_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid installer name" + [[ "$harness_commit" =~ '^[0-9a-f]{40}$' ]] || die "invalid harness commit" + [[ "$fixture_sha" =~ '^[0-9a-f]{64}$' ]] || die "invalid fixture checksum" + [[ "$fixture_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "invalid fixture name" + [[ "$key" == "$source_key-$fixture_sha" ]] || die "fixture-derived archive key does not match its source and checksum" + ensure_roots + [[ -f "$source/ready.tsv" && -d "$source/repo/.git" ]] || die "candidate archive is unavailable" + [[ "$(field "$source/ready.tsv" owner)" == "$OWNER" ]] || die "candidate archive ownership mismatch" + [[ "$(field "$source/ready.tsv" keypath_commit)" == "$commit" ]] || die "candidate archive commit mismatch" + [[ "$(field "$source/ready.tsv" installer_sha256)" == "$installer_sha" ]] || die "candidate archive installer mismatch" + [[ "$(field "$source/ready.tsv" harness_commit)" == "$harness_commit" ]] || die "candidate archive harness mismatch" + source_metadata="$fixture_source/repo/.keypath-lab/source.tsv" + fixture_path="$fixture_source/repo/.keypath-lab/fixtures/$fixture_name" + [[ -f "$fixture_source/ready.tsv" && "$(field "$fixture_source/ready.tsv" owner)" == "$OWNER" ]] || die "fixture archive ownership mismatch" + [[ -f "$source_metadata" && "$(field "$source_metadata" fixture_name)" == "$fixture_name" ]] || die "fixture archive name mismatch" + [[ "$(field "$source_metadata" fixture_sha256)" == "$fixture_sha" ]] || die "fixture archive metadata checksum mismatch" + [[ -f "$fixture_path" && ! -L "$fixture_path" ]] || die "fixture archive payload is unavailable" + actual_sha=$(shasum -a 256 "$fixture_path" | awk '{print $1}') + [[ "$actual_sha" == "$fixture_sha" ]] || die "fixture archive payload checksum mismatch" + if [[ -f "$destination/ready.tsv" ]]; then + [[ "$(field "$destination/ready.tsv" fixture_sha256)" == "$fixture_sha" ]] || die "fixture-derived archive checksum mismatch" + print "archive\treused\t$key" + return + fi + if ! mkdir "$lock" 2>/dev/null; then + for attempt in {1..100}; do + [[ -f "$destination/ready.tsv" ]] && { print "archive\treused\t$key"; return; } + sleep 0.1 + done + die "timed out waiting for concurrent fixture-derived archive publish: $key" + fi + git clone -q --no-hardlinks "$source/repo" "$staging/repo" + mkdir -p "$staging/repo/.keypath-lab/fixtures" + cp "$fixture_path" "$staging/repo/.keypath-lab/fixtures/$fixture_name" + set_field "$staging/repo/.keypath-lab/source.tsv" fixture_name "$fixture_name" + set_field "$staging/repo/.keypath-lab/source.tsv" fixture_sha256 "$fixture_sha" + git -C "$staging/repo" config user.name "KeyPath Lab" + git -C "$staging/repo" config user.email "keypath-lab@localhost" + git -C "$staging/repo" add -f .keypath-lab + GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ + git -C "$staging/repo" commit -q -m "KeyPath lab fixture $fixture_sha" + [[ -z "$(git -C "$staging/repo" status --porcelain)" ]] || die "fixture-derived archive checkout is dirty" + { + print "owner\t$OWNER" + print "keypath_commit\t$commit" + print "harness_commit\t$harness_commit" + print "installer_sha256\t$installer_sha" + print "installer_name\t$installer_name" + print "fixture_sha256\t$fixture_sha" + print "fixture_name\t$fixture_name" + print "derived_from\t$source_key" + print "fixture_derived_from\t$fixture_source_key" + print "created_at\t$(utc_now)" + } > "$staging/ready.tsv" + if [[ -e "$destination" ]]; then + rm -rf "$staging" + rmdir "$lock" + die "fixture-derived archive destination exists without a ready marker: $key" + fi + mv "$staging" "$destination" + rmdir "$lock" + print "archive\tfixture-derived\t$key" +} + write_provisional_lease_manifest() { local lease=$1 slug=$2 macos=$3 lane=$4 provider=$5 archive_key=$6 commit=$7 installer_sha=$8 installer_name=$9 repo=${10} created=${11} expires=${12} desktop=${13} local manifest identity_scope @@ -628,7 +847,7 @@ write_provisional_lease_manifest() { print "slug\t$slug" print "macos\t$macos" print "test_lane\t$lane" - print "base_name\t$(base_for "$macos" "$lane")" + print "base_name\t$(base_for "$macos" "$lane" "$desktop")" print "managed_identity_scope\t$identity_scope" print "provider\t$provider" print "archive_key\t$archive_key" @@ -673,7 +892,7 @@ create_lease() { if [[ "${KEYPATH_LAB_TESTING:-0}" != "1" && "$macos" == "15" && "$lane" == "managed-functional" ]]; then desktop=1 fi - valid_id "$archive_key" + valid_archive_key "$archive_key" archive="$ARCHIVES/$archive_key" [[ -f "$archive/ready.tsv" && -d "$archive/repo/.git" ]] || die "prepared archive not found: $archive_key" ttl_seconds=$(duration_seconds "$ttl") @@ -731,7 +950,7 @@ create_lease() { print "slug\t$slug" print "macos\t$macos" print "test_lane\t$lane" - print "base_name\t$(base_for "$macos" "$lane")" + print "base_name\t$(base_for "$macos" "$lane" "$desktop")" print "managed_identity_scope\t$identity_scope" print "provider\t$provider" print "archive_key\t$archive_key" @@ -748,6 +967,10 @@ create_lease() { print "tart_usb_passthrough\t$([[ "$tart_usb_passthrough" == "1" ]] && print true || print false)" print "provider_resource\t${provider_resource:-unknown}" } > "$manifest" + # Emit the durable controller identity as soon as the owned manifest exists. + # Callers must be able to adopt and clean up a lease even when a later guest + # verification or managed-policy step fails. + print "lease_id\t$lease" if (( create_status != 0 )); then set_field "$manifest" status provisioning-failed set_field "$manifest" provision_result "$create_status" @@ -769,7 +992,7 @@ create_lease() { set_field "$manifest" macos_build "${build:-unknown}" if [[ "$lane" == managed-functional ]]; then set +e - rehydrate_managed_clone "$lease" > "$LOGS/$lease/managed-policy.log" 2>&1 + (rehydrate_managed_clone "$lease") > "$LOGS/$lease/managed-policy.log" 2>&1 managed_policy_exit=$? set -e set_field "$manifest" managed_policy_result "$managed_policy_exit" @@ -827,17 +1050,146 @@ install_app() { return "$exit_code" } +install_runtime() { + local lease=$1 mode=${2:-strict} manifest lane runtime_status exit_code recovery_exit + manifest=$(owned_manifest "$lease") + lane=$(field "$manifest" test_lane) + runtime_status=$(field "$manifest" install_runtime_status) + + if [[ "$runtime_status" == "mutation-started" ]]; then + # A controller interruption can strand the manifest before the guest + # installer mutation begins. Recover only when durable guest evidence proves + # that no install report or mutation state was ever written. + set +e + (run_command "$lease" /bin/zsh -lc \ + 'out=.keypath-lab/scenario-output/install-runtime; test ! -e "$out/state.tsv" && test ! -e "$out/install-report.json" && test -e "$out/preflight-inspect.json"') + recovery_exit=$? + set -e + if ((recovery_exit == 0)); then + set_field "$manifest" install_runtime_status staged + runtime_status=staged + print "install_runtime_recovery\tpreflight-only" + else + die "install-runtime has an uncertain prior mutation; inspect artifacts before any retry" + fi + fi + if [[ "$runtime_status" == "failed" ]]; then + die "install-runtime has an uncertain or failed prior mutation; inspect artifacts before any retry" + fi + if [[ "$runtime_status" == "passed" ]]; then + print "install_runtime\tpassed" + return 0 + fi + + if [[ "$runtime_status" == "uninstalled" ]]; then + # Preserve the first installation's durable evidence before creating a + # fresh output directory for the reinstall. Fail closed rather than + # overwriting either evidence set on an ambiguous retry. + run_command "$lease" /bin/zsh -lc \ + 'source=.keypath-lab/scenario-output/install-runtime; target=.keypath-lab/scenario-output/install-runtime-before-reinstall; test -d "$source"; test ! -e "$target"; mv "$source" "$target"' || \ + die "could not preserve the first install-runtime evidence before reinstall" + install_app "$lease" || { + set_field "$manifest" install_runtime_status failed + set_field "$manifest" install_runtime_at "$(utc_now)" + return 1 + } + set_field "$manifest" install_runtime_status staged + runtime_status=staged + fi + + if [[ -z "$runtime_status" ]]; then + install_app "$lease" || { + set_field "$manifest" install_runtime_status failed + set_field "$manifest" install_runtime_at "$(utc_now)" + return 1 + } + set_field "$manifest" install_runtime_status staged + fi + + if [[ "$runtime_status" != "awaiting-approval" ]]; then + set_field "$manifest" install_runtime_status mutation-started + fi + + set +e + if [[ "$mode" == "allow-no-input-device" ]]; then + (run_command "$lease" /usr/bin/env KEYPATH_INSTALL_RUNTIME_ALLOW_NO_INPUT_DEVICE=1 \ + /bin/zsh Scripts/lab/install-runtime "$lane") + else + (run_command "$lease" /bin/zsh Scripts/lab/install-runtime "$lane") + fi + exit_code=$? + set -e + case "$exit_code" in + 0) set_field "$manifest" install_runtime_status passed ;; + 4) set_field "$manifest" install_runtime_status awaiting-approval ;; + *) set_field "$manifest" install_runtime_status failed ;; + esac + set_field "$manifest" install_runtime_at "$(utc_now)" + return "$exit_code" +} + +install_upgrade_runtime() { + local lease=$1 manifest macos repo fixture_name + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "26" ]] || die "upgrade runtime boundary currently requires macOS 26" + [[ "$(field "$manifest" test_lane)" == "managed-functional" ]] || die "upgrade runtime boundary requires a managed-functional lease" + repo=$(field "$manifest" worktree) + fixture_name=$(awk -F $'\t' '$1 == "fixture_name" {print $2}' "$repo/.keypath-lab/source.tsv") + [[ -n "$fixture_name" ]] || die "upgrade runtime boundary requires an admitted fixture archive" + install_runtime "$lease" allow-no-input-device +} + +install_fixture() { + local lease=$1 manifest macos lane repo fixture_name provider_resource guest_repo command exit_code admission_command + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + lane=$(field "$manifest" test_lane) + repo=$(field "$manifest" worktree) + provider_resource=$(field "$manifest" provider_resource) + prepare_worktree "$repo" + fixture_name=$(awk -F $'\t' '$1 == "fixture_name" {print $2}' "$repo/.keypath-lab/source.tsv") + [[ -n "$fixture_name" && "$fixture_name" =~ '^[A-Za-z0-9._-]+$' ]] || die "lease does not contain a valid upgrade fixture" + [[ -f "$repo/.keypath-lab/fixtures/$fixture_name" ]] || die "upgrade fixture is missing from lease archive" + guest_repo="/Users/$([[ "$macos" == "15" ]] && print admin || print keypathqa)/crabbox/$lease/repo" + admission_command="cd '$guest_repo'; Scripts/lab/mdm/verify-lane '$lane'" + if [[ "$lane" == "managed-functional" ]]; then + admission_command+=" --manifest /Library/KeyPathLab/managed-policy/manifest.json" + fi + command="setopt errexit nounset pipefail; $admission_command; rm -rf /tmp/keypath-fixture-install; mkdir -p /tmp/keypath-fixture-install; ditto -x -k '$guest_repo/.keypath-lab/fixtures/$fixture_name' /tmp/keypath-fixture-install; cd '$guest_repo'; if [[ '$lane' == managed-functional ]]; then Scripts/lab/mdm/verify-artifact-policy --app /tmp/keypath-fixture-install/KeyPath.app --manifest /Library/KeyPathLab/managed-policy/manifest.json; fi; rm -rf /Applications/KeyPath.app; ditto /tmp/keypath-fixture-install/KeyPath.app /Applications/KeyPath.app" + set +e + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + print "admission $lane" >> "$LOGS/$lease/install-fixture.log" + print "install-fixture $macos $lease $provider_resource $fixture_name" >> "$LOGS/$lease/install-fixture.log" + exit_code=0 + elif [[ "$macos" == "15" ]]; then + (cd "$repo" && "$(launcher_for "$macos")" run "$lease" -- /bin/zsh -lc "sudo -n /bin/zsh -lc $(printf %q "$command")") > "$LOGS/$lease/install-fixture.log" 2>&1 + exit_code=$? + else + [[ "$provider_resource" =~ '^[A-Fa-f0-9-]+$' && "$provider_resource" != "unknown" ]] || die "invalid Parallels resource id" + "/Applications/Parallels Desktop.app/Contents/MacOS/prlctl" exec "$provider_resource" /bin/zsh -lc "$command" > "$LOGS/$lease/install-fixture.log" 2>&1 + exit_code=$? + fi + set -e + set_field "$manifest" install_fixture_result "$exit_code" + set_field "$manifest" install_fixture_at "$(utc_now)" + cat "$LOGS/$lease/install-fixture.log" + return "$exit_code" +} + run_command() { local lease=$1; shift - local manifest macos launcher repo log exit_code + local manifest macos launcher repo log exit_code guest_home guest_path manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) launcher=$(launcher_for "$macos") repo=$(field "$manifest" worktree) + guest_home="/Users/$([[ "$macos" == "15" ]] && print admin || print keypathqa)" + guest_path="$guest_home/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" prepare_worktree "$repo" log="$LOGS/$lease/run-$(date -u +%Y%m%dT%H%M%SZ).log" set +e - (cd "$repo" && "$launcher" run "$lease" -- "$@") 2>&1 | tee "$log" + (cd "$repo" && "$launcher" run "$lease" -- /usr/bin/env "PATH=$guest_path" "$@") 2>&1 | tee "$log" exit_code=${pipestatus[1]} set -e if (( exit_code == 0 )); then record_command "$lease" passed "$@"; else record_command "$lease" "failed:$exit_code" "$@"; fi @@ -849,6 +1201,9 @@ run_command() { secure_dialog_input() { local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 local manifest macos resource key ip secret_file guest_command exit_code + local focus_command focus_result button_geometry_command button_coords postcondition_command postcondition_result + local button_press_command button_press_result native_submit_exit focus_transport submit_transport + local click_x click_y focus_x focus_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "15" ]] || die "secure dialog input currently supports only the Tart macOS 15 lane" @@ -871,20 +1226,127 @@ secure_dialog_input() { if [[ "${USER:-}" == "clawd" ]]; then export TART_HOME="$LAB_ROOT/TartHome-clawd"; else export TART_HOME="$LAB_ROOT/TartHome"; fi ip=$($TART ip "$resource") [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" + export PATH="$LAB_ROOT/CompatTools/bin:$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + + if [[ "$field_label" == "AXSecureTextField" ]]; then + [[ -n "$submit_button" ]] || die "AXSecureTextField requires a submit button for postcondition verification" + [[ "$already_focused" == "0" ]] || die "AXSecureTextField does not use --already-focused" + + # SecurityAgent rejects synthetic Accessibility typing. Use Accessibility + # only to focus and inspect the protected sheet, then deliver the secret as + # real RFB key events over CrabBox stdin. The secret never enters argv, + # guest storage, or controller output. + focus_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) throw new Error("dialog process not found");\n var window = matches[0].windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var fields = root.textFields.whose({subrole: "AXSecureTextField"})();\n var field = fields.length > 0 ? fields[0] : descendants(root).find(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n if (!field) throw new Error("secure text field not found");\n field.focused = true;\n var position = field.position();\n var size = field.size();\n return (field.focused() ? "focused" : "not-focused") + "," + Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n}\' -- '$(printf %q "$app") + set +e + focus_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$focus_command")" /dev/null 2>&1 + exit_code=$? + set -e + if (( exit_code != 0 )); then + # The Accessibility focus postcondition above is authoritative, and the + # secure typing step below independently proves that the field accepts + # input. Tart's RFB pointer transport may be unavailable even while the + # logged-in session remains fully controllable through System Events. + focus_transport=accessibility-only + fi + + # Secure authorization sheets ignore Tart's VNC key events. Stream the + # secret directly into one guest osascript process and synthesize local key + # events in the logged-in session. The value never enters argv, logs, the + # clipboard, or a guest file. The process returns only a filled/empty + # postcondition; it never prints the value or its length. + local secure_type_command + secure_type_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("Foundation");\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;\n var payload = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js;\n if (!payload) throw new Error("secure input is empty");\n var events = Application("System Events");\n var process = events.processes.byName(argv[0]);\n if (process.windows().length === 0) throw new Error("secure dialog process not found");\n var window = process.windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var fields = root.textFields.whose({subrole: "AXSecureTextField"})();\n var field = fields.length > 0 ? fields[0] : descendants(root).find(function(element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n if (!field) throw new Error("secure text field not found");\n field.focused = true;\n for (var i = 0; i < 128; i++) events.keyCode(51);\n events.keystroke(payload);\n delay(0.2);\n if (String(field.value()).length === 0) throw new Error("secure text field remained empty");\n return "filled";\n}\' -- '$(printf %q "$app") + set +e + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$secure_type_command")" < "$secret_file" >/dev/null 2>&1 + exit_code=$? + set -e + if (( exit_code != 0 )); then + record_command "$lease" "failed:42" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + die "secure dialog input failed while streaming masked input" + fi + + button_geometry_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var process = Application("System Events").processes.byName(argv[0]);\n var window = process.windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var button = root.buttons().find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) button = descendants(root).find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) throw new Error("submit button not found");\n var position = button.position();\n var size = button.size();\n return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2);\n}\' -- '$(printf %q "$app")' '$(printf %q "$submit_button") + set +e + button_coords=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$button_geometry_command")" 0 ? sheets[0] : window;\n var button = root.buttons().find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) button = descendants(root).find(function (element) {\n try { return element.role() === "AXButton" && (element.name() === argv[1] || element.description() === argv[1]); } catch (_) { return false; }\n });\n if (!button) throw new Error("submit button not found");\n var actions = button.actions.whose({name: "AXPress"})();\n if (actions.length !== 1) throw new Error("submit button does not expose one AXPress action");\n actions[0].perform();\n return "pressed";\n}\' -- '$(printf %q "$app")' '$(printf %q "$submit_button") + + submit_transport=native-pointer + set +e + "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$click_x" --y "$click_y" >/dev/null 2>&1 + native_submit_exit=$? + set -e + + postcondition_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "closed";\n var window = matches[0].windows[0];\n var sheets = window.sheets();\n var root = sheets.length > 0 ? sheets[0] : window;\n var open = root.textFields.whose({subrole: "AXSecureTextField"})().length > 0 || descendants(root).some(function (element) {\n try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; }\n });\n return open ? "open" : "closed";\n}\' -- '$(printf %q "$app") + postcondition_result=open + if (( native_submit_exit == 0 )); then + for attempt in {1..150}; do + postcondition_result=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$postcondition_command")" "$secret_path"; unset secret_value; ' - guest_command+="$field_command \"\$secret_path\" $(printf %q "$app") >/dev/null; rm -f \"\$secret_path\"; trap - EXIT" - elif [[ "$already_focused" == "0" ]]; then + if [[ "$already_focused" == "0" ]]; then refresh_args=(/opt/homebrew/bin/peekaboo see --app "$app" --json) printf -v refresh_command '%q ' "${refresh_args[@]}" guest_command+="$refresh_command >/dev/null || exit 40; " @@ -896,26 +1358,13 @@ secure_dialog_input() { elif [[ -n "$submit_button" ]]; then die "--already-focused cannot be combined with a submit button" fi - if [[ "$field_label" != "AXSecureTextField" ]]; then - guest_command+='PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true /opt/homebrew/bin/mcporter call --stdio '\''peekaboo mcp serve --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock"'\'' --env PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true type text=@/dev/stdin clear=true --output json --timeout 20000 >/dev/null 2>&1 || exit 42' - fi + guest_command+='PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true /opt/homebrew/bin/mcporter call --stdio '\''peekaboo mcp serve --bridge-socket "$HOME/Library/Application Support/Peekaboo/daemon.sock"'\'' --env PEEKABOO_VISUALIZER_MASK_TYPED_TEXT=true type text=@/dev/stdin clear=true --output json --timeout 20000 >/dev/null 2>&1 || exit 42' if [[ -n "$submit_button" ]]; then - if [[ "$field_label" == "AXSecureTextField" ]]; then - button_geometry_args=(/usr/bin/osascript -l JavaScript -e 'function descendants(element) { var result = []; try { var children = element.uiElements(); for (var i = 0; i < children.length; i++) { result.push(children[i]); result = result.concat(descendants(children[i])); } } catch (_) {} return result; } function run(argv) { var process = Application("System Events").processes.byName(argv[0]); var label = argv[1]; var button = descendants(process.windows[0]).find(function (element) { try { return element.role() === "AXButton" && (element.name() === label || element.description() === label); } catch (_) { return false; } }); if (!button) throw new Error("submit button not found"); var position = button.position(); var size = button.size(); return Math.round(position[0] + size[0] / 2) + "," + Math.round(position[1] + size[1] / 2); }' "$app" "$submit_button") - printf -v button_geometry_command '%q ' "${button_geometry_args[@]}" - guest_command+="; button_coords=\$( $button_geometry_command ); [[ \"\$button_coords\" =~ '^-?[0-9]+,-?[0-9]+$' ]] || exit 78; /opt/homebrew/bin/peekaboo click --coords \"\$button_coords\" --global-coords --foreground --input-strategy synthOnly --json >/dev/null 2>&1 || true" - else - submit_args=(/opt/homebrew/bin/peekaboo click "$submit_button" --app "$app" --foreground --json) - printf -v submit_command '%q ' "${submit_args[@]}" - printf -v submit_label_quoted '%q' "$submit_button" - guest_command+="; $refresh_command >/tmp/keypath-secure-submit.json || exit 44; if ! $submit_command >/dev/null; then $refresh_command >/tmp/keypath-secure-submit.json || exit 43; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); raise SystemExit(1 if any(e.get(\"label\")==sys.argv[2] for e in elements) else 0)' /tmp/keypath-secure-submit.json $submit_label_quoted || exit 43; fi" - guest_command+="; for attempt in {1..150}; do $refresh_command >/tmp/keypath-secure-postcondition.json || exit 44; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 1)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted && break; sleep 0.1; done; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 79)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted" - fi - fi - if [[ "$field_label" == "AXSecureTextField" ]]; then - postcondition_args=(/usr/bin/osascript -l JavaScript -e 'function descendants(element) { var result = []; try { var children = element.uiElements(); for (var i = 0; i < children.length; i++) { result.push(children[i]); result = result.concat(descendants(children[i])); } } catch (_) {} return result; } function run(argv) { var processes = Application("System Events").processes.whose({name: argv[0]})(); if (processes.length === 0 || processes[0].windows().length === 0) return "closed"; var open = descendants(processes[0].windows[0]).some(function (element) { try { return element.subrole() === "AXSecureTextField"; } catch (_) { return false; } }); return open ? "open" : "closed"; }' "$app") - printf -v postcondition_command '%q ' "${postcondition_args[@]}" - guest_command+="; for attempt in {1..150}; do [[ \$( $postcondition_command ) == closed ]] && exit 0; sleep 0.1; done; exit 77" + submit_args=(/opt/homebrew/bin/peekaboo click "$submit_button" --app "$app" --foreground --json) + printf -v submit_command '%q ' "${submit_args[@]}" + printf -v submit_label_quoted '%q' "$submit_button" + guest_command+="; $refresh_command >/tmp/keypath-secure-submit.json || exit 44; if ! $submit_command >/dev/null; then $refresh_command >/tmp/keypath-secure-submit.json || exit 43; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); raise SystemExit(1 if any(e.get(\"label\")==sys.argv[2] for e in elements) else 0)' /tmp/keypath-secure-submit.json $submit_label_quoted || exit 43; fi" + guest_command+="; for attempt in {1..150}; do $refresh_command >/tmp/keypath-secure-postcondition.json || exit 44; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 1)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted && break; sleep 0.1; done; /usr/bin/env python3 -c 'import json,sys; elements=json.load(open(sys.argv[1])).get(\"data\",{}).get(\"ui_elements\",[]); labels={e.get(\"label\") for e in elements}; raise SystemExit(0 if sys.argv[2] not in labels and sys.argv[3] not in labels else 79)' /tmp/keypath-secure-postcondition.json $(printf %q "$field_label") $submit_label_quoted" fi set +e "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" < "$secret_file" @@ -960,7 +1409,8 @@ secure_dialog_input() { protected_click() { local lease=$1 app=$2 expected_before=$3 expected_after=$4 coordinate_space=$5 x=$6 y=$7 count=${8:-1} - local manifest macos resource key ip before after guest_command geometry_command geometry + local manifest macos resource key ip before after before_frontmost after_frontmost guest_command geometry_command geometry + local occlusion occlusion_command occlusion_qualification_script local native_width native_height logical_width logical_height scale_x scale_y manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) @@ -974,6 +1424,7 @@ protected_click() { if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then before=${KEYPATH_LAB_TEST_WINDOW_BEFORE:-$expected_before} + before_frontmost=${KEYPATH_LAB_TEST_FRONTMOST_BEFORE:-true} else key="$HOME/Library/Application Support/crabbox/testboxes/$lease/id_ed25519" [[ -f "$key" && ! -L "$key" && -O "$key" ]] || die "owned CrabBox SSH key not found for lease" @@ -981,9 +1432,13 @@ protected_click() { export PATH="$LAB_ROOT/CompatTools/bin:$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" ip=$($TART ip "$resource") [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" - guest_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "";\n return matches[0].windows[0].name() || "__UNTITLED__";\n}\' -- '$(printf %q "$app") - before=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")") + guest_command=$'/usr/bin/osascript -l JavaScript -e \'\nfunction run(argv) {\n var matches = Application("System Events").processes.whose({name: argv[0]})();\n if (matches.length === 0 || matches[0].windows().length === 0) return "false\\t";\n var name = matches[0].windows[0].name() || "__UNTITLED__";\n return String(matches[0].frontmost()) + "\\t" + name;\n}\' -- '$(printf %q "$app") + IFS=$'\t' read -r before_frontmost before <<< "$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")")" fi + [[ "$before_frontmost" == "true" ]] || { + record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" + die "protected click precondition failed: '$app' is not frontmost" + } [[ "$expected_before" == "__ANY__" && -n "$before" ]] || [[ "$before" == "$expected_before" ]] || { record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" die "protected click precondition failed: expected window '$expected_before', found '${before:-unknown}'" @@ -993,7 +1448,7 @@ protected_click() { if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then geometry=${KEYPATH_LAB_TEST_DISPLAY_GEOMETRY:-'2048 1536 1024 768'} else - geometry_command='/opt/homebrew/bin/peekaboo list windows --app '$(printf %q "$app")' --json | /usr/bin/env python3 -c '\''import json,re,sys; data=json.load(sys.stdin).get("data",{}); windows=data.get("windows",data if isinstance(data,list) else []); names=[w.get("screenName","") for w in windows if isinstance(w,dict)]; m=next((re.search(r"([0-9]+)×([0-9]+)",n) for n in names if re.search(r"([0-9]+)×([0-9]+)",n)),None); print(f"{m.group(1)} {m.group(2)}" if m else "",end="")'\''; printf " "; /usr/bin/osascript -l JavaScript -e '\''ObjC.import("AppKit"); var s=$.NSScreen.mainScreen.frame.size; s.width+" "+s.height'\''' + geometry_command='/usr/bin/osascript -l JavaScript -e '\''ObjC.import("AppKit"); var screen=$.NSScreen.mainScreen; var logical=screen.frame.size; var scale=Number(screen.backingScaleFactor); Math.round(logical.width*scale)+" "+Math.round(logical.height*scale)+" "+Math.round(logical.width)+" "+Math.round(logical.height)'\''' geometry=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$geometry_command")") fi IFS=' ' read -r native_width native_height logical_width logical_height <<< "$geometry" @@ -1006,6 +1461,74 @@ protected_click() { y=$((y * scale_y)) fi + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + occlusion=${KEYPATH_LAB_TEST_OCCLUSION:-} + else + occlusion_command=$'/usr/bin/osascript -l JavaScript -e \'\nObjC.import("AppKit");\nfunction contains(element, x, y) {\n try {\n var position = element.position();\n var size = element.size();\n return size[0] > 1 && size[1] > 1 && x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1];\n } catch (_) { return false; }\n}\nfunction bounds(element) {\n var position = element.position();\n var size = element.size();\n return Math.round(position[0]) + "," + Math.round(position[1]) + "," + Math.round(size[0]) + "," + Math.round(size[1]);\n}\nfunction descendants(element) {\n var result = [];\n try {\n var children = element.uiElements();\n for (var i = 0; i < children.length; i++) {\n result.push(children[i]);\n result = result.concat(descendants(children[i]));\n }\n } catch (_) {}\n return result;\n}\nfunction run(argv) {\n var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor);\n var events = Application("System Events");\n var processNames = ["NotificationCenter", "UserNotificationCenter"];\n for (var p = 0; p < processNames.length; p++) {\n var matches = events.processes.whose({name: processNames[p]})();\n if (matches.length === 0) continue;\n var windows = matches[0].windows();\n for (var w = 0; w < windows.length; w++) {\n try {\n if (windows[w].subrole() === "AXSystemDialog" && contains(windows[w], x, y)) {\n return processNames[p] + ":dialog:" + bounds(windows[w]);\n }\n } catch (_) {}\n var elements = descendants(windows[w]);\n for (var e = 0; e < elements.length; e++) {\n try {\n var role = elements[e].role();\n if (["AXGroup", "AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1 && contains(elements[e], x, y)) {\n return processNames[p] + ":" + role + ":" + bounds(elements[e]);\n }\n } catch (_) {}\n }\n }\n }\n return "";\n}\' -- '$(printf %q "$x")' '$(printf %q "$y") + occlusion=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$occlusion_command")") || die "protected click could not verify notification occlusion" + if [[ "$occlusion" == *":dialog:"* ]]; then + read -r -d '' occlusion_qualification_script <<'JXA' || true +ObjC.import("AppKit"); +function contains(element, x, y) { + try { + var position = element.position(); + var size = element.size(); + return size[0] > 1 && size[1] > 1 && x >= position[0] && x <= position[0] + size[0] && y >= position[1] && y <= position[1] + size[1]; + } catch (_) { return false; } +} +function descendants(element) { + var result = []; + try { + var children = element.uiElements(); + for (var i = 0; i < children.length; i++) { + result.push(children[i]); + result = result.concat(descendants(children[i])); + } + } catch (_) {} + return result; +} +function run(argv) { + var x = Number(argv[0]) / Number($.NSScreen.mainScreen.backingScaleFactor); + var y = Number(argv[1]) / Number($.NSScreen.mainScreen.backingScaleFactor); + var events = Application("System Events"); + var processNames = ["NotificationCenter", "UserNotificationCenter"]; + for (var p = 0; p < processNames.length; p++) { + var matches = events.processes.whose({name: processNames[p]})(); + if (matches.length === 0) continue; + var windows = matches[0].windows(); + for (var w = 0; w < windows.length; w++) { + try { + var dialogText = windows[w].staticTexts().map(function(item) { return item.value() || ""; }).join(" ").toLowerCase(); + var captureConsent = dialogText.indexOf("screen") !== -1 && (dialogText.indexOf("record") !== -1 || dialogText.indexOf("capture") !== -1 || dialogText.indexOf("share") !== -1); + if (captureConsent || dialogText.indexOf("private window picker") !== -1) return processNames[p] + ":consent-dialog"; + } catch (_) {} + var elements = descendants(windows[w]); + for (var e = 0; e < elements.length; e++) { + try { + var role = elements[e].role(); + var boundedVisibleRole = ["AXButton", "AXStaticText", "AXImage"].indexOf(role) !== -1; + if (role === "AXGroup") { + var elementSize = elements[e].size(); + var windowSize = windows[w].size(); + boundedVisibleRole = elementSize[0] * elementSize[1] < windowSize[0] * windowSize[1] * 0.5; + } + if (boundedVisibleRole && contains(elements[e], x, y)) return processNames[p] + ":" + role; + } catch (_) {} + } + } + } + return ""; +} +JXA + occlusion_command="/usr/bin/osascript -l JavaScript -e $(printf %q "$occlusion_qualification_script") -- $(printf %q "$x") $(printf %q "$y")" + occlusion=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$occlusion_command")") || die "protected click could not qualify notification occlusion" + fi + fi + [[ -z "$occlusion" ]] || { + record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --x "$x" --y "$y" + die "protected click target is occluded by a notification ($occlusion)" + } + if [[ "$count" == "2" ]]; then "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$x" --y "$y" --count 2 >/dev/null else @@ -1014,15 +1537,20 @@ protected_click() { sleep "${KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS:-1}" if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then after=${KEYPATH_LAB_TEST_WINDOW_AFTER:-$expected_after} + after_frontmost=${KEYPATH_LAB_TEST_FRONTMOST_AFTER:-true} else - after=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")") + IFS=$'\t' read -r after_frontmost after <<< "$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")")" fi if [[ "$after" != "$expected_after" && "$expected_before" == "__ANY__" ]]; then sleep "${KEYPATH_LAB_INITIAL_SETTINGS_RETRY_SECONDS:-5}" "$CRABBOX" desktop click --provider tart --target macos --id "$resource" --x "$x" --y "$y" >/dev/null sleep "${KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS:-1}" - after=$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")") + IFS=$'\t' read -r after_frontmost after <<< "$("$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")")" fi + [[ "$after_frontmost" == "true" ]] || { + record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --after-window "$expected_after" --x "$x" --y "$y" + die "protected click postcondition failed: '$app' is no longer frontmost" + } [[ "$after" == "$expected_after" ]] || { record_command "$lease" failed protected-click --app "$app" --window "$expected_before" --after-window "$expected_after" --x "$x" --y "$y" die "protected click postcondition failed: expected window '$expected_after', found '${after:-unknown}'" @@ -1038,6 +1566,111 @@ protected_click() { fi } +input_monitoring_rows() { + local lease=$1 manifest resource key ip guest_script guest_command open_command + manifest=$(owned_manifest "$lease") + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + if [[ -n "${KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS:-}" ]]; then + print -r -- "$KEYPATH_LAB_TEST_INPUT_MONITORING_ROWS" + else + printf 'Kanata Engine\t0\t402\t247\nkanata-launcher\t0\t402\t289\nKeyPath\t0\t402\t331\n' + fi + return + fi + + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Za-z0-9._-]+$' && "$resource" != "unknown" ]] || die "invalid Tart resource id" + key="$HOME/Library/Application Support/crabbox/testboxes/$lease/id_ed25519" + [[ -f "$key" && ! -L "$key" && -O "$key" ]] || die "owned CrabBox SSH key not found for lease" + if [[ "${USER:-}" == "clawd" ]]; then export TART_HOME="$LAB_ROOT/TartHome-clawd"; else export TART_HOME="$LAB_ROOT/TartHome"; fi + export PATH="$LAB_ROOT/CompatTools/bin:$LAB_ROOT/SharedTools/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ip=$($TART ip "$resource") + [[ "$ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Tart returned an invalid guest address" + + read -r -d '' guest_script <<'JXA' || true +function safe(callback, fallback) { + try { return callback(); } catch (_) { return fallback; } +} +function descendants(element) { + var result = []; + safe(function() { + element.uiElements().forEach(function(child) { + result.push(child); + result = result.concat(descendants(child)); + }); + }, null); + return result; +} +function run() { + Application("System Settings").activate(); + var process = Application("System Events").processes.byName("System Settings"); + for (var attempt = 0; attempt < 20 && (!process.exists() || process.windows().length === 0); attempt++) delay(0.25); + if (!process.exists() || process.windows().length === 0) throw new Error("System Settings is unavailable"); + var window = process.windows[0]; + if (window.name() !== "Input Monitoring") throw new Error("Input Monitoring page did not open"); + window.position = [154, 330]; + delay(0.5); + var targets = ["Kanata Engine", "kanata-launcher", "KeyPath"]; + var checkboxes = descendants(window).filter(function(element) { + return safe(function() { return element.role() === "AXCheckBox"; }, false); + }); + return targets.map(function(target) { + var matches = checkboxes.filter(function(element) { + return safe(function() { return element.name() === target; }, false); + }); + if (matches.length !== 1) throw new Error("Expected one Input Monitoring row for " + target); + var checkbox = matches[0]; + var position = checkbox.position(); + var size = checkbox.size(); + return target + "\t" + checkbox.value() + "\t" + + Math.round(position[0] + size[0] / 2) + "\t" + + Math.round(position[1] + size[1] / 2); + }).join("\n"); +} +JXA + guest_command="/usr/bin/killall -TERM KeyPath >/dev/null 2>&1 || true" + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" + open_command="/usr/bin/open 'x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent'" + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$open_command")" >/dev/null + guest_command="/usr/bin/osascript -l JavaScript -e $(printf %q "$guest_script")" + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i "$key" "admin@$ip" "/bin/zsh -lc $(printf %q "$guest_command")" +} + +approve_input_monitoring() { + local lease=$1 manifest macos lane rows row target value x y verified + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + lane=$(field "$manifest" test_lane) + [[ "$macos" == "15" ]] || die "Input Monitoring approval currently supports only the Tart macOS 15 lane" + [[ "$lane" == "managed-functional" ]] || die "Input Monitoring approval requires managed-functional policy" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "Input Monitoring approval requires a desktop-enabled lease" + + rows=$(input_monitoring_rows "$lease") || die "could not inspect Input Monitoring rows" + for target in "Kanata Engine" "kanata-launcher" "KeyPath"; do + row=$(print -r -- "$rows" | awk -F '\t' -v target="$target" '$1 == target {print; exit}') + [[ -n "$row" ]] || die "Input Monitoring row is missing: $target" + IFS=$'\t' read -r _ value x y <<< "$row" + [[ "$value" == "0" || "$value" == "1" ]] || die "Input Monitoring row has an invalid state: $target" + [[ "$x" == <-> && "$y" == <-> ]] || die "Input Monitoring row has invalid geometry: $target" + if [[ "$value" == "1" ]]; then + print "input_monitoring_row\t$target\talready-enabled" + continue + fi + + protected_click "$lease" "System Settings" "Input Monitoring" "Input Monitoring" ax "$x" "$y" 1 >/dev/null + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + verified=${KEYPATH_LAB_TEST_INPUT_MONITORING_VERIFY:-1} + else + rows=$(input_monitoring_rows "$lease") || die "could not refresh Input Monitoring rows" + verified=$(print -r -- "$rows" | awk -F '\t' -v target="$target" '$1 == target {print $2; exit}') + fi + [[ "$verified" == "1" ]] || die "Input Monitoring toggle did not remain enabled: $target" + print "input_monitoring_row\t$target\tenabled" + done + record_command "$lease" passed approve-input-monitoring + print "approve_input_monitoring\tpassed" +} + desktop_type() { local lease=$1 text=$2 manifest macos resource manifest=$(owned_manifest "$lease") @@ -1075,7 +1708,7 @@ list_leases() { } collect_artifacts() { - local lease=$1 manifest output exit_code macos repo archive provider_resource parallels_cli + local lease=$1 manifest output exit_code macos repo archive provider_resource parallels_cli guest_repo local parallels_resource_pattern='^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' local nameplate_restore=0 nameplate_hide_status=not-needed nameplate_restore_status=not-needed manifest=$(owned_manifest "$lease") @@ -1100,9 +1733,20 @@ collect_artifacts() { fi fi archive="$output/scenario-output.tar.gz" + if [[ "${KEYPATH_LAB_TESTING:-0}" != "1" && "$macos" != "15" ]]; then + provider_resource=$(field "$manifest" provider_resource) + [[ "$provider_resource" =~ $parallels_resource_pattern ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + guest_repo="/Users/keypathqa/crabbox/$lease/repo" + "$parallels_cli" exec "$provider_resource" /usr/bin/env \ + KEYPATH_CAPTURE_USER_HOME=/Users/keypathqa KEYPATH_CAPTURE_OWNER=keypathqa \ + /bin/zsh "$guest_repo/Scripts/lab/capture-controller-state" \ + > "$output/privileged-controller-capture.log" 2>&1 || true + fi set +e (cd "$repo" && run_with_download "$macos" "$lease" ".keypath-lab/scenario-output.tar.gz" "$archive" \ - /bin/zsh -lc 'set -e; out=.keypath-lab/scenario-output/controller-capture; mkdir -p "$out/logs"; sw_vers > "$out/sw-vers.txt"; date -u +%Y-%m-%dT%H:%M:%SZ > "$out/captured-at.txt"; cp -R "$HOME/Library/Logs/KeyPath/." "$out/logs/" 2>/dev/null || true; /Applications/KeyPath.app/Contents/MacOS/keypath-cli system inspect --json > "$out/system-inspect.json" 2>/dev/null || true; tar -czf .keypath-lab/scenario-output.tar.gz -C .keypath-lab scenario-output') > "$output/download.log" 2>&1 + /bin/zsh -lc 'set -e; /bin/zsh Scripts/lab/capture-controller-state; tar -czf .keypath-lab/scenario-output.tar.gz -C .keypath-lab scenario-output') > "$output/download.log" 2>&1 exit_code=$? set -e if (( exit_code == 0 )); then @@ -1152,18 +1796,29 @@ collect_artifacts() { } scenario() { - local lease=$1 name=$2 manifest repo scenario_script lane + local lease=$1 name=$2 manifest repo scenario_script lane runtime_status exit_code manifest=$(owned_manifest "$lease") repo=$(field "$manifest" worktree) lane=$(field "$manifest" test_lane) prepare_worktree "$repo" scenario_script="Scripts/lab/scenarios/installer-scenario" [[ -x "$repo/$scenario_script" ]] || die "scenario runner missing from archived commit" - run_command "$lease" "/bin/zsh" "$scenario_script" "$name" "$lane" + set +e + (run_command "$lease" "/bin/zsh" "$scenario_script" "$name" "$lane") + exit_code=$? + set -e + if ((exit_code == 0)) && [[ "$name" == "uninstall" ]]; then + runtime_status=$(field "$manifest" install_runtime_status) + if [[ -n "$runtime_status" ]]; then + set_field "$manifest" install_runtime_status uninstalled + set_field "$manifest" uninstall_at "$(utc_now)" + fi + fi + return "$exit_code" } desktop_bootstrap() { - local lease=$1 install_tools=$2 manifest macos repo output guest_output command + local lease=$1 install_tools=$2 manifest macos repo output command exit_code approval_output attempt manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "desktop bootstrap requires a desktop-enabled lease" @@ -1172,11 +1827,59 @@ desktop_bootstrap() { output=".keypath-lab/scenario-output/bootstrap" command=(/bin/zsh Scripts/lab/desktop-bootstrap --output "$output") [[ "$install_tools" == "1" ]] && command+=(--install-tools) - run_command "$lease" "${command[@]}" + if [[ "$macos" == "15" ]]; then + # Clear a consent prompt left by an interrupted bootstrap before asking + # Peekaboo to capture again. + approve_peekaboo_capture "$lease" + fi + exit_code=1 + for attempt in {1..3}; do + set +e + (run_command "$lease" "${command[@]}") + exit_code=$? + set -e + ((exit_code == 0)) && break + [[ "$macos" == "15" ]] || return "$exit_code" + approval_output=$(approve_peekaboo_capture "$lease") + print -r -- "$approval_output" + print -r -- "$approval_output" | grep -Fq $'peekaboo_capture_approval\tpassed' || return "$exit_code" + # Installing tools re-signs the dedicated Peekaboo host. Do that once; + # re-signing on every consent retry creates a fresh Screen Recording + # request and prevents the bootstrap from ever reaching its postcondition. + command=(/bin/zsh Scripts/lab/desktop-bootstrap --output "$output") + done + ((exit_code == 0)) || return "$exit_code" set_field "$manifest" desktop_bootstrap_at "$(utc_now)" set_field "$manifest" desktop_bootstrap_status passed } +verify_console_login() { + local lease=$1 manifest macos resource parallels_cli console_user + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "26" || "$macos" == "27" ]] || die "inherited console-login verification requires a macOS 26 or 27 lane" + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "inherited console-login verification requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "inherited console-login verification requires a desktop-enabled lease" + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + console_user=$("$parallels_cli" exec "$resource" /usr/bin/stat -f %Su /dev/console 2>/dev/null || true) + if [[ "$console_user" != "keypathqa" ]]; then + set_field "$manifest" console_login_status postcondition-failed + set_field "$manifest" console_login_method inherited-base + record_command "$lease" failed verify-console-login + die "fresh desktop-base clone did not inherit the keypathqa console session" + fi + set_field "$manifest" console_login_status passed + set_field "$manifest" console_login_method inherited-base + set_field "$manifest" console_login_at "$(utc_now)" + record_command "$lease" passed verify-console-login + print "console_login\tpassed" + print "console_login_method\tinherited-base" + print "console_user\t$console_user" +} + console_login() { local lease=$1 manifest macos resource parallels_cli secret_file exit_code console_user attempt guest_command autologin_status guest_control_ready configure_stage local guest_ip key known_hosts known_hosts_option fifo status_file configure_pid fifo_ready stream_exit credential_timeout @@ -1341,7 +2044,7 @@ console_login() { } secure_console_submit() { - local lease=$1 manifest macos resource parallels_cli secret_file + local lease=$1 manifest macos resource parallels_cli secret_file key_events key_event manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) [[ "$macos" == "26" || "$macos" == "27" ]] || die "secure console submit requires a macOS 26 or 27 Parallels lane" @@ -1363,19 +2066,38 @@ secure_console_submit() { fi [[ -s "$secret_file" ]] || die "secure console secret is empty" - # Convert the credential to Parallels key codes in a pipe. The plaintext is + # Convert the credential to explicit, paced Parallels event pairs. The + # plaintext is # read only from the owner-only temp file and never enters argv, logs, the - # guest pasteboard, or an artifact. Keep the accepted alphabet deliberately - # narrow; expanding it requires an explicit key-map review. - python3 -c 'import json,sys + # guest pasteboard, or an artifact. One short-lived prlctl process per + # explicit press/release pair prevents macOS from dropping a large burst + # without relying on Parallels' ambiguous default key event. + # Keep the accepted alphabet deliberately narrow; expanding it requires an + # explicit key-map review. + key_events=$(python3 -c 'import json,sys codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} value=open(sys.argv[1],"r",encoding="utf-8").read() if not value or any(ch not in codes for ch in value): raise SystemExit(64) -for ch in value: print(json.dumps([{"key":codes[ch]}],separators=(",",":")))' "$secret_file" 3<&- | \ - while IFS= read -r key_event <&3; do - printf '%s\n' "$key_event" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || exit 1 - sleep "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" - done 3<&0 || die "failed to deliver the guest credential through Parallels key events" +delay=max(0,round(float(sys.argv[2])*1000)) +replace_events=[ + {"key":115,"event":"press","delay":delay}, + {"key":38,"event":"press","delay":delay}, + {"key":38,"event":"release","delay":delay}, + {"key":115,"event":"release","delay":delay}, + {"key":22,"event":"press","delay":delay}, + {"key":22,"event":"release","delay":delay}, +] +print(json.dumps(replace_events,separators=(",",":"))) +for ch in value: + print(json.dumps(({"key":codes[ch],"event":"press","delay":delay},{"key":codes[ch],"event":"release","delay":delay}),separators=(",",":")))' "$secret_file" "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" 3<&-) || \ + die "failed to encode the guest credential as Parallels key events" + [[ -n "$key_events" ]] || die "failed to encode the guest credential as Parallels key events" + while IFS= read -r key_event; do + printf '%s\n' "$key_event" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || \ + die "failed to deliver the guest credential through Parallels key events" + sleep "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" + done <<< "$key_events" + key_events= sleep "${KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS:-0.25}" if [[ "$macos" == "26" ]]; then # SecurityAgent accepts password text from the Parallels console but @@ -1391,9 +2113,37 @@ for ch in value: print(json.dumps([{"key":codes[ch]}],separators=(",",":")))' "$ KEYPATH_LAB_SECURE_TEMP= trap - EXIT fi - record_command "$lease" passed secure-console-submit - print "secure_console_submit\tpassed" - print "credential_transport\tparallels-key-events" + record_command "$lease" delivered secure-console-submit + print "secure_console_submit\tdelivered" + print "credential_field\treplaced-focused-value" + print "credential_transport\tparallels-explicit-pairs-paced" + print "credential_postcondition\tunverified" +} + +console_key() { + local lease=$1 key_code=$2 modifier_code=${3:-0} manifest macos resource parallels_cli + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "26" || "$macos" == "27" ]] || die "console key requires a macOS 26 or 27 Parallels lane" + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "console key requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "console key requires a desktop-enabled lease" + [[ "$key_code" == <-> && "$key_code" -ge 1 && "$key_code" -le 255 ]] || die "invalid Parallels key code" + [[ "$modifier_code" == <-> && "$modifier_code" -ge 0 && "$modifier_code" -le 255 ]] || die "invalid Parallels modifier key code" + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + if (( modifier_code > 0 )); then + "$parallels_cli" send-key-event "$resource" --key "$modifier_code" --event press >/dev/null + fi + "$parallels_cli" send-key-event "$resource" --key "$key_code" >/dev/null + if (( modifier_code > 0 )); then + "$parallels_cli" send-key-event "$resource" --key "$modifier_code" --event release >/dev/null + fi + record_command "$lease" passed console-key --key "$key_code" --modifier "$modifier_code" + print "console_key\tpassed" + print "parallels_key_code\t$key_code" + print "parallels_modifier_code\t$modifier_code" } reset_guest_password() { @@ -1464,6 +2214,144 @@ reset_guest_password() { print "enrollment_account\t$enrollment_account" } +reset_desktop_keychain() { + local lease=$1 manifest macos resource parallels_cli secret_file key known_hosts known_hosts_option guest_ip + local fifo marker guest_command reset_pid fifo_ready attempt stream_exit reset_exit backup + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$macos" == "27" ]] || die "desktop keychain reset requires the macOS 27 lane" + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "desktop keychain reset requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "desktop keychain reset requires a desktop-enabled lease" + [[ "$(field "$manifest" console_login_status)" == "passed" ]] || die "desktop keychain reset requires a verified console login" + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + + key="$HOME/Library/Application Support/crabbox/testboxes/$lease/id_ed25519" + [[ -f "$key" && ! -L "$key" && -O "$key" ]] || die "owned CrabBox SSH key not found for lease" + known_hosts="$HOME/Library/Application Support/crabbox/testboxes/$lease/known_hosts" + [[ -f "$known_hosts" && ! -L "$known_hosts" && -O "$known_hosts" ]] || die "owned CrabBox known-hosts file not found for lease" + known_hosts_option=${known_hosts// /\\ } + guest_ip=$("$parallels_cli" list -i -f -j "$resource" | python3 -c 'import json,sys; rows=json.load(sys.stdin); addresses=rows[0].get("Network",{}).get("ipAddresses",[]) if rows else []; print(next((item.get("ip","") for item in addresses if item.get("type")=="ipv4"),""),end="")') + [[ "$guest_ip" =~ '^[0-9A-Fa-f:.]+$' ]] || die "Parallels returned an invalid guest address" + + secret_file=$(mktemp "$STATE_ROOT/.desktop-keychain-reset.XXXXXXXX") + chmod 600 "$secret_file" + typeset -g KEYPATH_LAB_SECURE_TEMP="$secret_file" + trap '[[ -z ${KEYPATH_LAB_SECURE_TEMP:-} ]] || rm -f "$KEYPATH_LAB_SECURE_TEMP"' EXIT + /opt/homebrew/bin/sops -d "$HOME/dotfiles/secrets.env" | awk -F= '$1 == "KEYPATH_LAB_GUEST_PASSWORD" {sub(/^[^=]*=/, ""); printf "%s", $0; found=1} END {if (!found) exit 1}' > "$secret_file" || die "KEYPATH_LAB_GUEST_PASSWORD is unavailable" + [[ -s "$secret_file" ]] || die "desktop keychain reset secret is empty" + + fifo="/tmp/keypath-keychain-reset-$lease-$$.fifo" + marker="/tmp/keypath-keychain-reset-$lease-$$.marker" + guest_command="set -euo pipefail; fifo=$(printf %q "$fifo"); marker=$(printf %q "$marker"); home=/Users/keypathqa; keychain=\"\$home/Library/Keychains/login.keychain-db\"; rm -f \"\$fifo\" \"\$marker\"; /usr/bin/mkfifo \"\$fifo\"; /usr/sbin/chown keypathqa:staff \"\$fifo\"; /bin/chmod 600 \"\$fifo\"; trap 'rm -f \"\$fifo\"' EXIT; password=; IFS= read -r password < \"\$fifo\"; backup=none; if [[ -e \"\$keychain\" ]]; then backup=\"\${keychain}.before-desktop-base-$(date -u +%Y%m%dT%H%M%SZ)\"; /bin/mv \"\$keychain\" \"\$backup\"; fi; /usr/sbin/chown -R keypathqa:staff \"\$home/Library/Keychains\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security create-keychain -p \"\$password\" \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security list-keychains -d user -s \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security default-keychain -d user -s \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security unlock-keychain -p \"\$password\" \"\$keychain\"; /usr/bin/sudo -u keypathqa /usr/bin/env HOME=\"\$home\" /usr/bin/security set-keychain-settings -lut 21600 \"\$keychain\"; printf '%s\n' \"\$backup\" > \"\$marker\"; /bin/chmod 600 \"\$marker\"; unset password" + set +e + "$parallels_cli" exec "$resource" /bin/zsh -lc "$(printf %q "$guest_command")" >/dev/null 2>&1 & + reset_pid=$! + fifo_ready=0 + for attempt in {1..300}; do + if "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_option" -i "$key" "keypathqa@$guest_ip" \ + "/bin/test -p $(printf %q "$fifo")" /dev/null 2>&1; then + fifo_ready=1 + break + fi + sleep 0.1 + done + if (( fifo_ready == 1 )); then + { /bin/cat "$secret_file"; printf '\n'; } | \ + "$GUEST_SSH" -o BatchMode=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_option" -i "$key" "keypathqa@$guest_ip" \ + "/bin/zsh -c $(printf %q "/bin/cat > $(printf %q "$fifo")")" >/dev/null 2>&1 + stream_exit=$? + else + stream_exit=76 + kill "$reset_pid" 2>/dev/null || true + fi + wait "$reset_pid" + reset_exit=$? + set -e + "$parallels_cli" exec "$resource" /bin/rm -f "$fifo" >/dev/null 2>&1 || true + backup=$("$parallels_cli" exec "$resource" /bin/cat "$marker" 2>/dev/null | tail -1 || true) + "$parallels_cli" exec "$resource" /bin/rm -f "$marker" >/dev/null 2>&1 || true + rm -f "$secret_file" + KEYPATH_LAB_SECURE_TEMP= + trap - EXIT + (( stream_exit == 0 && reset_exit == 0 )) || die "failed to establish a fresh disposable desktop keychain" + [[ "$backup" == none || "$backup" == /Users/keypathqa/Library/Keychains/login.keychain-db.before-desktop-base-* ]] || + die "desktop keychain reset returned an invalid backup path" + + set_field "$manifest" desktop_keychain_status passed + set_field "$manifest" desktop_keychain_at "$(utc_now)" + record_command "$lease" passed reset-desktop-keychain + print "desktop_keychain_reset\tpassed" + print "previous_keychain\t$backup" +} + +reboot_guest() { + local lease=$1 manifest provider macos repo launcher resource parallels_cli state attempt ready=0 request_exit=0 poll_exit=0 + manifest=$(owned_manifest "$lease") + provider=$(field "$manifest" provider) + case "$provider" in + parallels) + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + "$parallels_cli" restart "$resource" >/dev/null + sleep 3 + for attempt in {1..600}; do + state=$("$parallels_cli" list -i -f -j "$resource" 2>/dev/null | + python3 -c 'import json,sys; rows=json.load(sys.stdin); print(rows[0].get("State","") if rows else "",end="")' 2>/dev/null || true) + if [[ "$state" == "running" ]] && + "$parallels_cli" exec "$resource" /usr/bin/true >/dev/null 2>&1; then + ready=1 + break + fi + sleep 0.1 + done + (( ready == 1 )) || die "Parallels guest control did not recover after reboot" + ;; + tart) + macos=$(field "$manifest" macos) + [[ "$macos" == "15" ]] || die "unexpected Tart macOS lane: $macos" + repo=$(field "$manifest" worktree) + prepare_worktree "$repo" + launcher=$(launcher_for "$macos") + set +e + (cd "$repo" && "$launcher" run "$lease" -- /bin/zsh -lc 'sudo -n /sbin/shutdown -r now') \ + > "$LOGS/$lease/reboot-request.log" 2>&1 + request_exit=$? + set -e + if (( request_exit != 0 )) && /usr/bin/grep -Eqi 'sudo:|not permitted|permission denied' "$LOGS/$lease/reboot-request.log"; then + cat "$LOGS/$lease/reboot-request.log" + die "Tart guest rejected the reboot request" + fi + sleep "${KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS:-3}" + for attempt in {1..120}; do + set +e + (cd "$repo" && "$launcher" run "$lease" -- /usr/bin/true) \ + > "$LOGS/$lease/reboot-readiness.log" 2>&1 + poll_exit=$? + set -e + if (( poll_exit == 0 )); then + ready=1 + break + fi + sleep "${KEYPATH_LAB_TART_REBOOT_POLL_SECONDS:-1}" + done + if (( ready != 1 )); then + cat "$LOGS/$lease/reboot-readiness.log" + die "Tart guest SSH did not recover after reboot" + fi + ;; + *) die "unsupported reboot provider: $provider" ;; + esac + set_field "$manifest" guest_reboot_at "$(utc_now)" + record_command "$lease" passed reboot-guest + print "guest_reboot\tpassed" + print "provider\t$provider" +} + rfb_pointer_probe() { local lease=$1 x=$2 y=$3 manifest macos resource parallels_cli key known_hosts known_hosts_option guest_ip cursor_command before after manifest=$(owned_manifest "$lease") @@ -1615,13 +2503,21 @@ action=${1:-} shift || true case "$action" in preflight) [[ $# -eq 0 ]] || die "preflight takes no arguments"; preflight ;; + archive-status) [[ $# -eq 4 ]] || die "archive-status requires key, commit, checksum, and name"; archive_status "$@" ;; prepare-upload) [[ $# -eq 1 ]] || die "prepare-upload requires archive key"; prepare_upload "$1" ;; install-archive) [[ $# -eq 5 ]] || die "install-archive requires ticket, key, commit, checksum, and name"; install_archive "$@" ;; + derive-archive) [[ $# -eq 7 ]] || die "derive-archive requires ticket, source key, destination key, commit, checksum, name, and harness commit"; derive_archive "$@" ;; + find-fixture-archive) [[ $# -eq 2 ]] || die "find-fixture-archive requires fixture checksum and name"; find_fixture_archive "$@" ;; + derive-fixture-archive) [[ $# -eq 9 ]] || die "derive-fixture-archive requires source key, fixture source key, destination key, commit, installer checksum, installer name, harness commit, fixture checksum, and fixture name"; derive_fixture_archive "$@" ;; create) [[ $# -eq 8 || $# -eq 9 ]] || die "create requires macOS, test lane, archive, commit, checksum, name, ttl, desktop, and optional Tart USB passthrough"; create_lease "$@" ;; install-app) [[ $# -eq 1 ]] || die "install-app requires lease"; install_app "$1" ;; + install-runtime) [[ $# -eq 1 ]] || die "install-runtime requires lease"; install_runtime "$1" ;; + install-upgrade-runtime) [[ $# -eq 1 ]] || die "install-upgrade-runtime requires lease"; install_upgrade_runtime "$1" ;; + install-fixture) [[ $# -eq 1 ]] || die "install-fixture requires lease"; install_fixture "$1" ;; secure-dialog-input) [[ $# -eq 5 ]] || die "secure-dialog-input requires lease, app, field, optional submit value, and focus mode"; secure_dialog_input "$@" ;; resume-managed-policy) [[ $# -eq 1 ]] || die "resume-managed-policy requires a lease"; resume_managed_policy "$1" ;; protected-click) [[ $# -eq 7 || $# -eq 8 ]] || die "protected-click requires lease, app, before window, after window, coordinate space, x, y, and optional count"; protected_click "$@" ;; + approve-input-monitoring) [[ $# -eq 1 ]] || die "approve-input-monitoring requires lease"; approve_input_monitoring "$1" ;; desktop-type) [[ $# -eq 2 ]] || die "desktop-type requires lease and text"; desktop_type "$@" ;; run) [[ $# -ge 2 ]] || die "run requires lease and command"; run_command "$@" ;; status) [[ $# -eq 1 ]] || die "status requires lease"; print_status "$1" ;; @@ -1630,8 +2526,12 @@ case "$action" in scenario) [[ $# -eq 2 ]] || die "scenario requires lease and name"; scenario "$1" "$2" ;; desktop-bootstrap) [[ $# -eq 2 ]] || die "desktop-bootstrap requires lease and install-tools flag"; desktop_bootstrap "$@" ;; console-login) [[ $# -eq 1 ]] || die "console-login requires lease"; console_login "$1" ;; + verify-console-login) [[ $# -eq 1 ]] || die "verify-console-login requires lease"; verify_console_login "$1" ;; reset-guest-password) [[ $# -eq 1 ]] || die "reset-guest-password requires lease"; reset_guest_password "$1" ;; + reset-desktop-keychain) [[ $# -eq 1 ]] || die "reset-desktop-keychain requires lease"; reset_desktop_keychain "$1" ;; + reboot-guest) [[ $# -eq 1 ]] || die "reboot-guest requires lease"; reboot_guest "$1" ;; secure-console-submit) [[ $# -eq 1 ]] || die "secure-console-submit requires lease"; secure_console_submit "$1" ;; + console-key) [[ $# -eq 3 ]] || die "console-key requires lease, Parallels key code, and modifier code"; console_key "$1" "$2" "$3" ;; rfb-pointer-probe) [[ $# -eq 3 ]] || die "rfb-pointer-probe requires lease, x, and y"; rfb_pointer_probe "$@" ;; nameplate) [[ $# -eq 2 ]] || die "nameplate requires lease and action"; nameplate_control "$@" ;; destroy) [[ $# -eq 1 ]] || die "destroy requires lease"; destroy_lease "$1" ;; diff --git a/Scripts/lab/scenario-matrix b/Scripts/lab/scenario-matrix new file mode 100755 index 000000000..b39969d24 --- /dev/null +++ b/Scripts/lab/scenario-matrix @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Build deterministic, capacity- and TTL-safe KeyPath lab matrix plans.""" + +from __future__ import annotations + +import argparse +import datetime +import itertools +import json +import pathlib +import sys +from collections import defaultdict + + +SCHEMA_VERSION = 1 +FACTOR_KEYS = ("platform", "lane", "family", "boundary", "evidence") + + +class MatrixError(ValueError): + pass + + +def load_catalog(path: pathlib.Path) -> dict: + try: + catalog = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise MatrixError(f"cannot read catalog: {error}") from error + if catalog.get("schemaVersion") != SCHEMA_VERSION: + raise MatrixError("catalog requires schemaVersion 1") + cases = catalog.get("cases") + if not isinstance(cases, list) or not cases: + raise MatrixError("catalog requires non-empty cases") + seen: set[str] = set() + for case in cases: + case_id = case.get("id") + if not isinstance(case_id, str) or not case_id or case_id in seen: + raise MatrixError("case ids must be unique non-empty strings") + seen.add(case_id) + if case.get("provider") not in ("tart", "parallels", "local"): + raise MatrixError(f"{case_id}: invalid provider") + if case.get("automation") not in ("unattended", "operator", "physical"): + raise MatrixError(f"{case_id}: invalid automation level") + estimate = case.get("estimatedMinutes") + if not isinstance(estimate, int) or estimate < 1: + raise MatrixError(f"{case_id}: estimatedMinutes must be positive") + factors = case.get("factors") + if not isinstance(factors, dict) or any(not factors.get(key) for key in FACTOR_KEYS): + raise MatrixError(f"{case_id}: all matrix factors are required") + steps = case.get("steps") + if not isinstance(steps, list) or not steps or not all(isinstance(step, str) and step for step in steps): + raise MatrixError(f"{case_id}: steps must be non-empty strings") + if case["provider"] != "local" and case.get("cleanup") != "destroy-owned-lease": + raise MatrixError(f"{case_id}: VM cases require destroy-owned-lease cleanup") + if case.get("identityScope") == "shared" and case["provider"] != "parallels": + raise MatrixError(f"{case_id}: shared identities are only valid on Parallels") + return catalog + + +def factor_pairs(case: dict) -> set[tuple[str, str, str, str]]: + factors = case["factors"] + pairs = set() + for left, right in itertools.combinations(FACTOR_KEYS, 2): + pairs.add((left, str(factors[left]), right, str(factors[right]))) + return pairs + + +def weekly_pairwise(candidates: list[dict], required_ids: set[str]) -> list[dict]: + """Greedy deterministic set cover over all compatible pairs in the catalog.""" + universe = set().union(*(factor_pairs(case) for case in candidates)) + selected = [case for case in candidates if case["id"] in required_ids] + covered = set().union(*(factor_pairs(case) for case in selected)) if selected else set() + remaining = [case for case in candidates if case["id"] not in required_ids] + while covered != universe: + ranked = sorted( + remaining, + key=lambda case: (-len(factor_pairs(case) - covered), case["estimatedMinutes"], case["id"]), + ) + if not ranked or not (factor_pairs(ranked[0]) - covered): + raise MatrixError("pairwise catalog contains pairs that cannot be covered") + chosen = ranked[0] + selected.append(chosen) + covered |= factor_pairs(chosen) + remaining.remove(chosen) + return selected + + +def schedule(cases: list[dict], capacities: dict[str, int]) -> list[list[str]]: + """Create deterministic waves without exceeding provider or identity capacity.""" + pending = list(cases) + waves: list[list[str]] = [] + while pending: + used: dict[str, int] = defaultdict(int) + shared_identity = False + wave: list[dict] = [] + for case in list(pending): + provider = case["provider"] + if provider == "local": + wave.append(case) + pending.remove(case) + continue + if used[provider] >= capacities[provider]: + continue + if case.get("identityScope") == "shared" and shared_identity: + continue + wave.append(case) + pending.remove(case) + used[provider] += 1 + shared_identity = shared_identity or case.get("identityScope") == "shared" + if not wave: + raise MatrixError("capacity settings cannot schedule the remaining cases") + waves.append([case["id"] for case in wave]) + return waves + + +def build_plan( + catalog: dict, + cadence: str, + ttl_minutes: int, + capacities: dict[str, int], + include_operator: bool, + include_physical: bool, +) -> dict: + eligible = [ + case for case in catalog["cases"] + if case["automation"] == "unattended" + or (case["automation"] == "operator" and include_operator) + or (case["automation"] == "physical" and include_physical) + ] + nightly = [case for case in eligible if case.get("nightlyCore")] + if cadence == "nightly": + selected = nightly + else: + selected = weekly_pairwise(eligible, {case["id"] for case in nightly}) + if not selected: + raise MatrixError("no eligible cases selected") + + over_budget = [case for case in selected if case["estimatedMinutes"] > ttl_minutes] + if over_budget: + detail = ", ".join(f"{case['id']}={case['estimatedMinutes']}m" for case in over_budget) + raise MatrixError(f"lease TTL {ttl_minutes}m is too short: {detail}") + for provider in ("tart", "parallels"): + if capacities.get(provider, 0) < 1: + raise MatrixError(f"{provider} capacity must be at least 1") + + covered = set().union(*(factor_pairs(case) for case in selected)) + eligible_pairs = set().union(*(factor_pairs(case) for case in eligible)) + exclusions = [case for case in catalog["cases"] if case not in eligible] + jobs = [] + for case in selected: + jobs.append({ + "id": case["id"], + "title": case["title"], + "provider": case["provider"], + "macOS": case.get("macOS"), + "lane": case.get("lane"), + "identityScope": case.get("identityScope", "none"), + "automation": case["automation"], + "estimatedMinutes": case["estimatedMinutes"], + "ttlMinutes": ttl_minutes if case["provider"] != "local" else None, + "steps": case["steps"], + "finalizer": case.get("cleanup"), + "factors": case["factors"], + }) + return { + "schemaVersion": SCHEMA_VERSION, + "cadence": cadence, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "policy": { + "ttlMinutes": ttl_minutes, + "capacity": capacities, + "operatorCasesIncluded": include_operator, + "physicalCasesIncluded": include_physical, + "cleanupRequired": True, + }, + "summary": { + "jobs": len(jobs), + "waves": len(schedule(selected, capacities)), + "pairwisePairsCovered": len(covered), + "eligiblePairs": len(eligible_pairs), + "pairwiseComplete": covered == eligible_pairs if cadence == "weekly" else None, + }, + "waves": schedule(selected, capacities), + "jobs": jobs, + "excluded": [ + { + "id": case["id"], "title": case["title"], + "automation": case["automation"], "reason": "explicit opt-in required", + } + for case in exclusions + ], + } + + +def parse_capacity(values: list[str]) -> dict[str, int]: + capacity = {"tart": 1, "parallels": 1} + for value in values: + if "=" not in value: + raise MatrixError("capacity must use provider=count") + provider, raw_count = value.split("=", 1) + if provider not in capacity: + raise MatrixError(f"unsupported provider capacity: {provider}") + try: + capacity[provider] = int(raw_count) + except ValueError as error: + raise MatrixError(f"invalid capacity: {value}") from error + return capacity + + +def main() -> None: + default_catalog = pathlib.Path(__file__).with_name("scenarios") / "matrix-catalog.json" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--catalog", type=pathlib.Path, default=default_catalog) + parser.add_argument("--cadence", choices=("nightly", "weekly"), required=True) + parser.add_argument("--ttl-minutes", type=int, default=120) + parser.add_argument("--capacity", action="append", default=[]) + parser.add_argument("--include-operator", action="store_true") + parser.add_argument("--include-physical", action="store_true") + parser.add_argument("--output", type=pathlib.Path) + args = parser.parse_args() + try: + if not 1 <= args.ttl_minutes <= 120: + raise MatrixError("ttl-minutes must be between 1 and 120") + plan = build_plan( + load_catalog(args.catalog), args.cadence, args.ttl_minutes, + parse_capacity(args.capacity), args.include_operator, args.include_physical, + ) + payload = json.dumps(plan, indent=2) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(payload) + else: + print(payload, end="") + except MatrixError as error: + print(f"scenario-matrix: {error}", file=sys.stderr) + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/Scripts/lab/scenario-matrix-runner b/Scripts/lab/scenario-matrix-runner new file mode 100755 index 000000000..6ce8bc532 --- /dev/null +++ b/Scripts/lab/scenario-matrix-runner @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 +"""Execute a KeyPath scenario-matrix plan with resumable, fail-closed checkpoints.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import datetime +import hashlib +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile +import threading +from typing import Any + + +TERMINAL = {"passed", "failed", "blocked"} +CHECKPOINT_PREFIXES = ("operator-", "physical-") +LEASE_PATTERN = re.compile(r"^lease_id\t([A-Za-z0-9._-]+)$", re.MULTILINE) +LEASE_CANDIDATE_PATTERN = re.compile(r"\b(cbx_[A-Za-z0-9._-]+)\b") +KNOWN_STEPS = { + "run-lab-contract-tests", "run-failure-ownership-self-test", "verify-report-contract", + "create-fresh-lease", "create-fresh-lease-with-fixture", "create-desktop-lease", "create-usb-lease", + "install-exact-artifact", "install-upgrade-artifact", "install-fixture", "desktop-bootstrap", "reboot-guest", "artifact-capture", + "managed-capabilities", "repair-kanata-service", "upgrade-beta3-to-current", + "upgrade-beta3-before", "upgrade-beta3-after", + "reboot-persistence-before", "reboot-persistence-after", "uninstall", "reinstall", + "launch", "helper-daemon-health", "cancellation-recovery-before", "cancellation-recovery-after", + "macos-26-selector-scenario", "macos-27-selector-scenario", + "physical-remap-session-prepare", "physical-remap-session-observe", +} + + +class CampaignError(ValueError): + pass + + +def now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def digest_json(value: dict[str, Any]) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def sha256(path: pathlib.Path) -> str: + result = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + result.update(chunk) + return result.hexdigest() + + +def atomic_json(path: pathlib.Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(descriptor, "w") as handle: + json.dump(value, handle, indent=2) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def validate_plan(plan: Any) -> dict[str, Any]: + if not isinstance(plan, dict) or plan.get("schemaVersion") != 1: + raise CampaignError("plan requires schemaVersion 1") + jobs = plan.get("jobs") + waves = plan.get("waves") + if not isinstance(jobs, list) or not jobs or not isinstance(waves, list) or not waves: + raise CampaignError("plan requires non-empty jobs and waves") + ids = [job.get("id") for job in jobs] + if any(not isinstance(job_id, str) or not job_id for job_id in ids) or len(ids) != len(set(ids)): + raise CampaignError("job ids must be unique non-empty strings") + scheduled = [job_id for wave in waves for job_id in wave] + if sorted(scheduled) != sorted(ids) or len(scheduled) != len(set(scheduled)): + raise CampaignError("waves must schedule every job exactly once") + for job in jobs: + if job.get("provider") not in ("local", "tart", "parallels"): + raise CampaignError(f"{job['id']}: unsupported provider") + if not isinstance(job.get("steps"), list) or not job["steps"]: + raise CampaignError(f"{job['id']}: steps are required") + unknown_steps = [ + step for step in job["steps"] + if step not in KNOWN_STEPS and not step.startswith(CHECKPOINT_PREFIXES) + ] + if unknown_steps: + raise CampaignError(f"{job['id']}: unsupported steps: {', '.join(unknown_steps)}") + if job["provider"] != "local" and job.get("finalizer") != "destroy-owned-lease": + raise CampaignError(f"{job['id']}: owned lease cleanup is required") + return plan + + +def unique_steps(steps: list[str]) -> list[dict[str, str]]: + counts: dict[str, int] = {} + result = [] + for command_step in steps: + counts[command_step] = counts.get(command_step, 0) + 1 + suffix = "" if counts[command_step] == 1 else f"-{counts[command_step]}" + result.append({"id": command_step + suffix, "commandStep": command_step, "status": "queued"}) + return result + + +def initial_state(plan: dict[str, Any], plan_digest: str, commit: str, installer: pathlib.Path) -> dict[str, Any]: + return { + "schemaVersion": 1, + "campaignId": f"{plan.get('cadence', 'matrix')}-{datetime.datetime.now().strftime('%Y%m%d-%H%M%S')}", + "planDigest": plan_digest, + "commit": commit, + "installer": str(installer.resolve()), + "installerSHA256": sha256(installer), + "dashboardInitialized": False, + "status": "queued", + "createdAt": now(), + "updatedAt": now(), + "jobs": [ + { + "id": job["id"], "status": "queued", "leaseId": None, + "steps": unique_steps(job["steps"]), "cleanupStatus": "not-required" if job["provider"] == "local" else "pending", + } + for job in plan["jobs"] + ], + } + + +class MatrixRunner: + def __init__(self, args: argparse.Namespace, plan: dict[str, Any], state: dict[str, Any]) -> None: + self.args = args + self.plan = plan + self.state = state + self.lock = threading.RLock() + self.jobs = {job["id"]: job for job in plan["jobs"]} + self.job_states = {job["id"]: job for job in state["jobs"]} + self.acknowledged = set(args.ack_checkpoint) + self.selected = set(args.only_job) if args.only_job else set(self.jobs) + self.presentation_enabled = args.hid_presentation + + def save(self) -> None: + with self.lock: + self.state["updatedAt"] = now() + atomic_json(self.args.state, self.state) + + def dashboard(self, *arguments: str) -> None: + if not self.args.dashboard_updater: + return + command = [str(self.args.dashboard_updater)] + if self.args.dashboard_state: + command += ["--state", str(self.args.dashboard_state)] + command += list(arguments) + with self.lock: + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode: + raise CampaignError(f"dashboard update failed: {result.stderr.strip() or result.stdout.strip()}") + + def present(self, phase: str, *, result: str = "none", progress: int = 0, + title: str = "", detail: str = "", reports_expected: int = 0, + reports_observed: int = 0) -> None: + """Update the optional physical display without making it a campaign dependency.""" + if not self.presentation_enabled: + return + command = [str(self.args.hid_fixture_client), "--host", self.args.hid_fixture_host, + "--timeout", "2.0", "present", + "--phase", phase, "--result", result, "--progress", str(max(0, min(progress, 1000))), + "--title", title[:32], "--detail", detail[:48], + "--reports-expected", str(reports_expected), + "--reports-observed", str(reports_observed)] + with self.lock: + response = subprocess.run(command, cwd=self.args.repo, text=True, capture_output=True) + if response.returncode: + log = self.args.artifacts / "hid-fixture-presentation.log" + log.parent.mkdir(parents=True, exist_ok=True) + with log.open("a") as handle: + handle.write(f"{now()} {response.stderr.strip() or response.stdout.strip()}\n") + + def update_job(self, job_state: dict[str, Any], *, status: str | None = None, + step: dict[str, str] | None = None, step_status: str | None = None, + message: str | None = None, event: str | None = None, tone: str = "info") -> None: + with self.lock: + if status: + job_state["status"] = status + if step: + job_state["currentStep"] = step["id"] + if step_status: + step["status"] = step_status + completed = sum(item["status"] == "passed" for item in job_state["steps"]) + progress = round(100 * completed / len(job_state["steps"])) + self.save() + arguments = ["job", "--id", job_state["id"], "--progress", str(progress)] + if status: + arguments += ["--status", status] + if step: + arguments += ["--step", step["id"]] + if step_status: + arguments += ["--step-status", step_status] + if job_state.get("leaseId"): + arguments += ["--lease-id", job_state["leaseId"]] + if message: + arguments += ["--message", message] + if event: + arguments += ["--event-title", event, "--event-tone", tone] + self.dashboard(*arguments) + if status in ("failed", "blocked"): + self.present("result", result="fail" if status == "failed" else "inconclusive", + progress=progress * 10, title=job_state["id"], detail=message or "Evidence incomplete") + elif status == "waiting": + self.present("next", progress=progress * 10, title=job_state["id"], detail=message or "Action needed") + elif status == "passed": + self.present("resolving", progress=1000, title=job_state["id"], detail="Scenario evidence verified") + elif step_status == "running": + self.present("testing", progress=progress * 10, title=job_state["id"], + detail=(step or {}).get("commandStep", "Running scenario")) + elif step_status == "passed": + self.present("observing", progress=progress * 10, title=job_state["id"], + detail=(step or {}).get("commandStep", "Evidence captured")) + + def lab_command(self, *parts: str) -> list[str]: + command = [str(self.args.lab)] + if self.args.host: + command += ["--host", self.args.host] + return command + list(parts) + + def execute(self, job_id: str, step_id: str, command: list[str]) -> subprocess.CompletedProcess[str]: + log = self.args.artifacts / job_id / f"{step_id}.log" + log.parent.mkdir(parents=True, exist_ok=True) + result = subprocess.run(command, cwd=self.args.repo, text=True, capture_output=True) + with log.open("a") as handle: + handle.write(f"$ {' '.join(command)}\n") + handle.write(result.stdout + result.stderr) + return result + + def adopt_create_lease(self, job: dict[str, Any], state: dict[str, Any], + step: dict[str, str], output: str, *, required: bool) -> bool: + exact = LEASE_PATTERN.findall(output) + candidates = exact or LEASE_CANDIDATE_PATTERN.findall(output) + unique = list(dict.fromkeys(candidates)) + if not unique: + if required: + raise CampaignError(f"{job['id']}: create succeeded without a lease_id") + return False + if len(unique) != 1: + raise CampaignError(f"{job['id']}: create output named multiple lease candidates") + lease = unique[0] + status = subprocess.run(self.lab_command("status", lease), text=True, capture_output=True) + if status.returncode != 0: + raise CampaignError(f"{job['id']}: controller did not verify ownership of {lease}") + state["leaseId"] = lease + self.save() + return True + + def create_command(self, job: dict[str, Any], step_name: str) -> list[str]: + command = self.lab_command( + "create", "--macos", str(job["macOS"]), "--lane", job["lane"], + "--commit", self.args.commit, "--installer", str(self.args.installer), + "--ttl", f"{job.get('ttlMinutes', 120)}m", + ) + if step_name == "create-fresh-lease-with-fixture": + if not self.args.fixture: + raise CampaignError(f"{job['id']}: --fixture is required") + command += ["--fixture", str(self.args.fixture)] + if step_name == "create-desktop-lease": + command += ["--desktop"] + if step_name == "create-usb-lease": + command += ["--tart-usb-passthrough"] + return command + + def command_for(self, job: dict[str, Any], state: dict[str, Any], step_name: str) -> list[list[str]]: + lease = state.get("leaseId") + if step_name.startswith("create-"): + return [self.create_command(job, step_name)] + if job["provider"] == "local": + if step_name == "run-lab-contract-tests": + return [[str(self.args.repo / "Scripts/lab/tests/keypath-lab-tests.sh")]] + if step_name == "run-failure-ownership-self-test": + output = self.args.artifacts / job["id"] / "failure-ownership-self-test.json" + verify = ( + "import json,sys; value=json.load(open(sys.argv[1])); " + "raise SystemExit(0 if value.get('status') == 'failed' and " + "value.get('failure', {}).get('classification') == 'harness-selector-failure' else 1)" + ) + return [ + [str(self.args.repo / "Scripts/lab/scenario-result"), "selector-self-test", "--output", str(output)], + [sys.executable, "-c", verify, str(output)], + ] + if step_name == "verify-report-contract": + return [[sys.executable, str(self.args.repo / "Scripts/lab/tests/scenario-report-tests.py")]] + if not lease: + raise CampaignError(f"{job['id']}: {step_name} requires a recorded lease") + if step_name == "install-exact-artifact": + return [self.lab_command("install-runtime", lease)] + if step_name == "install-upgrade-artifact": + return [self.lab_command("install-upgrade-runtime", lease)] + if step_name == "install-fixture": + return [self.lab_command("install-fixture", lease)] + if step_name == "reboot-guest": + return [self.lab_command("reboot-guest", lease)] + if step_name == "desktop-bootstrap": + commands = [] + if job["provider"] == "parallels": + commands.append(self.lab_command("verify-console-login", lease)) + commands.append(self.lab_command("desktop-bootstrap", lease, "--install-tools")) + return commands + if step_name == "artifact-capture": + collected = self.args.artifacts / job["id"] / "collected" + return [ + self.lab_command("scenario", lease, "artifact-capture"), + self.lab_command("artifacts", lease, "--output", str(collected)), + ] + return [self.lab_command("scenario", lease, step_name)] + + def recover_inflight(self, job: dict[str, Any], state: dict[str, Any], step: dict[str, str]) -> bool: + log = self.args.artifacts / job["id"] / f"{step['id']}.log" + if step["commandStep"].startswith("create-") and log.exists(): + match = LEASE_PATTERN.search(log.read_text()) + if match: + lease = match.group(1) + status = subprocess.run(self.lab_command("status", lease), text=True, capture_output=True) + if status.returncode == 0: + state["leaseId"] = lease + step["status"] = "passed" + self.update_job(state, status="running", step=step, step_status="passed", + message="Recovered the created lease from durable controller evidence.") + return True + if step["commandStep"] in {"install-exact-artifact", "install-upgrade-artifact"} and state.get("leaseId"): + status = subprocess.run( + self.lab_command("status", state["leaseId"]), text=True, capture_output=True, + ) + if status.returncode == 0 and re.search( + r"^install_runtime_status\tawaiting-approval$", status.stdout, re.MULTILINE, + ): + checkpoint = f"{job['id']}:{step['commandStep']}" + state["waitingCheckpoint"] = checkpoint + self.update_job( + state, status="waiting", step=step, step_status="waiting", + message="Recovered the completed installer mutation at its durable macOS approval boundary.", + event=f"{job['title']} needs approval", tone="warning", + ) + return True + state["blocker"] = f"Interrupted mutation at {step['id']} has no verified postcondition; refusing to repeat it." + self.update_job(state, status="blocked", step=step, step_status="blocked", + message=state["blocker"], event=f"{job['title']} blocked", tone="danger") + return False + + def finalizer(self, job: dict[str, Any], state: dict[str, Any]) -> bool: + lease = state.get("leaseId") + if job["provider"] == "local" or not lease: + return True + result = self.execute(job["id"], "destroy-owned-lease", self.lab_command("destroy", lease)) + state["cleanupStatus"] = "passed" if result.returncode == 0 else "failed" + self.save() + return result.returncode == 0 + + def emergency_artifacts(self, job: dict[str, Any], state: dict[str, Any]) -> None: + lease = state.get("leaseId") + if not lease: + return + collected = self.args.artifacts / job["id"] / "collected" + result = self.execute( + job["id"], "failure-artifact-capture", + self.lab_command("artifacts", lease, "--output", str(collected)), + ) + state["failureArtifactStatus"] = "passed" if result.returncode == 0 else "failed" + self.save() + + def write_reports(self) -> None: + self.args.artifacts.mkdir(parents=True, exist_ok=True) + scenario_report = self.args.artifacts / "scenario-report.json" + result = subprocess.run( + [str(self.args.repo / "Scripts/lab/scenario-report"), "--input", str(self.args.artifacts), + "--output", str(scenario_report)], text=True, capture_output=True, + ) + if result.returncode != 0: + (self.args.artifacts / "scenario-report-error.log").write_text(result.stdout + result.stderr) + matrix_report = { + "schemaVersion": 1, + "campaignId": self.state["campaignId"], + "generatedAt": now(), + "planDigest": self.state["planDigest"], + "commit": self.state["commit"], + "installerSHA256": self.state["installerSHA256"], + "status": self.state["status"], + "jobs": [ + { + "id": job["id"], "status": job["status"], "leaseId": job.get("leaseId"), + "cleanupStatus": job.get("cleanupStatus"), "blocker": job.get("blocker"), + "artifactDirectory": job["id"], + } + for job in self.state["jobs"] + ], + "scenarioReport": "scenario-report.json" if result.returncode == 0 else None, + } + atomic_json(self.args.artifacts / "matrix-report.json", matrix_report) + + def run_job(self, job_id: str) -> str: + job = self.jobs[job_id] + state = self.job_states[job_id] + if state["status"] in TERMINAL: + return state["status"] + self.update_job(state, status="running", message="Job admitted to its provider-safe wave.", + event=f"{job['title']} started") + preserve_lease = False + try: + for step in state["steps"]: + if step["status"] == "passed": + continue + checkpoint = f"{job_id}:{step['commandStep']}" + if step["commandStep"].startswith(CHECKPOINT_PREFIXES) and checkpoint not in self.acknowledged: + state["waitingCheckpoint"] = checkpoint + preserve_lease = True + self.update_job(state, status="waiting", step=step, step_status="waiting", + message=f"Waiting for explicit completion of {step['commandStep']}.", + event=f"{job['title']} needs attention", tone="warning") + return "waiting" + if step["status"] == "running" and not self.recover_inflight(job, state, step): + return "blocked" + if state["status"] == "waiting": + preserve_lease = True + return "waiting" + if step["status"] == "passed": + continue + if checkpoint in self.acknowledged: + state.pop("waitingCheckpoint", None) + if step["commandStep"].startswith(CHECKPOINT_PREFIXES): + step["status"] = "passed" + self.update_job(state, status="running", step=step, step_status="passed", + message="Operator checkpoint acknowledged; continuing from the next step.") + continue + step["status"] = "queued" + self.update_job(state, status="running", step=step, step_status="queued", + message="Operator action acknowledged; verifying the product postconditions.") + self.update_job(state, status="running", step=step, step_status="running", + message=f"Running {step['commandStep']}.") + for command in self.command_for(job, state, step["commandStep"]): + result = self.execute(job_id, step["id"], command) + if step["commandStep"].startswith("create-"): + self.adopt_create_lease( + job, state, step, result.stdout + result.stderr, + required=result.returncode == 0, + ) + if ( + result.returncode == 4 + and "install_runtime\twaiting" in (result.stdout + result.stderr) + and step["commandStep"] == "install-exact-artifact" + and job.get("macOS") == 15 + and job.get("lane") == "managed-functional" + ): + self.update_job( + state, status="running", step=step, step_status="running", + message="Driving the genuine managed Input Monitoring approval and verifying each row.", + ) + approval = self.execute( + job_id, step["id"], + self.lab_command("approve-input-monitoring", state["leaseId"]), + ) + if approval.returncode == 0: + result = self.execute( + job_id, step["id"], + self.lab_command("install-runtime", state["leaseId"]), + ) + if result.returncode == 4 and "install_runtime\twaiting" in (result.stdout + result.stderr): + state["waitingCheckpoint"] = checkpoint + preserve_lease = True + self.update_job( + state, status="waiting", step=step, step_status="waiting", + message="KeyPath is waiting for visible macOS approval; resume after completing it.", + event=f"{job['title']} needs approval", tone="warning", + ) + return "waiting" + if result.returncode != 0: + state["blocker"] = f"{step['commandStep']} exited {result.returncode}; see {job_id}/{step['id']}.log" + self.update_job(state, status="failed", step=step, step_status="failed", + message=state["blocker"], event=f"{job['title']} failed", tone="danger") + self.emergency_artifacts(job, state) + return "failed" + step["status"] = "passed" + self.update_job(state, status="running", step=step, step_status="passed", + message=f"Verified {step['commandStep']}.") + if state.get("leaseId") and not self.finalizer(job, state): + state["blocker"] = "Owned lease cleanup failed; manual reconciliation is required." + self.update_job(state, status="blocked", message=state["blocker"], + event=f"{job['title']} cleanup failed", tone="danger") + return "blocked" + self.update_job(state, status="passed", message="All scenario checkpoints and owned cleanup passed.", + event=f"{job['title']} passed", tone="success") + return "passed" + except CampaignError as error: + state["blocker"] = str(error) + self.update_job(state, status="blocked", message=str(error), event=f"{job['title']} blocked", tone="danger") + return "blocked" + finally: + if not preserve_lease and state.get("leaseId") and state.get("cleanupStatus") == "pending": + if not self.finalizer(job, state): + state["blocker"] = "Owned lease cleanup failed; manual reconciliation is required." + self.update_job(state, status="blocked", message=state["blocker"], + event=f"{job['title']} cleanup failed", tone="danger") + + def run(self) -> int: + self.state["status"] = "running" + self.save() + self.dashboard("campaign", "--status", "running", "--message", "Campaign executor preflight passed; wave execution started.", + "--next-action", "Watch the active wave and intervene only if a checkpoint requests attention.", + "--event-title", "Matrix campaign started") + self.present("preparing", title="MATRIX CAMPAIGN", detail="Admitting provider-safe waves") + waiting_providers: set[str] = set() + saw_failure = False + last_wave = 0 + for wave_number, wave in enumerate(self.plan["waves"], 1): + eligible = [ + job_id for job_id in wave + if job_id in self.selected + and self.job_states[job_id]["status"] not in TERMINAL + and self.jobs[job_id]["provider"] not in waiting_providers + ] + if not eligible: + continue + last_wave = wave_number + with concurrent.futures.ThreadPoolExecutor(max_workers=len(eligible)) as pool: + outcomes = list(pool.map(self.run_job, eligible)) + saw_failure = saw_failure or any(outcome in ("failed", "blocked") for outcome in outcomes) + for job_id, outcome in zip(eligible, outcomes): + if outcome == "waiting": + waiting_providers.add(self.jobs[job_id]["provider"]) + self.write_reports() + if waiting_providers: + self.state["status"] = "waiting" + self.save() + self.write_reports() + self.dashboard("campaign", "--status", "waiting", + "--message", f"Wave {last_wave} reached an explicit checkpoint; independent providers ran as far as capacity allowed.", + "--next-action", "Complete the visible checkpoint, acknowledge it, and resume the same campaign state file.", + "--event-title", "Matrix campaign needs attention", "--event-tone", "warning") + self.present("next", result="inconclusive", title="ACTION NEEDED", + detail="A visible checkpoint is waiting") + return 4 + if saw_failure: + self.state["status"] = "blocked" + self.save() + self.write_reports() + self.dashboard("campaign", "--status", "blocked", + "--message", "All runnable waves completed, but one or more jobs have classified failures.", + "--next-action", "Review failed evidence and repair the owning layer before a new campaign.", + "--event-title", "Matrix campaign found failures", "--event-tone", "danger") + self.present("result", result="fail", progress=1000, title="MATRIX FAILED", + detail="Review classified failure evidence") + return 1 + selected_states = [self.job_states[job_id]["status"] for job_id in self.selected] + complete = all(status == "passed" for status in selected_states) + partial = self.selected != set(self.jobs) + self.state["status"] = "queued" if complete and partial else ("passed" if complete else "blocked") + self.save() + self.write_reports() + self.dashboard("campaign", "--status", self.state["status"], + "--message", "Selected jobs passed; remaining matrix jobs are still queued." if partial else ("All matrix jobs passed with owned cleanup verified." if complete else "Campaign ended without complete evidence."), + "--next-action", "Run the remaining queued jobs." if partial else ("Review the consolidated evidence report." if complete else "Review blocked jobs before resuming."), + "--event-title", "Selected matrix jobs passed" if partial else ("Matrix campaign passed" if complete else "Matrix campaign incomplete"), + "--event-tone", "success" if complete else "danger") + if complete and not partial: + self.present("result", result="pass", progress=1000, title="MATRIX PASSED", + detail="Every selected scenario verified", reports_expected=len(selected_states), + reports_observed=len(selected_states)) + elif complete: + self.present("next", progress=1000, title="WAVE PASSED", detail="More matrix jobs remain") + else: + self.present("result", result="inconclusive", progress=1000, + title="MATRIX INCOMPLETE", detail="Evidence remains unresolved") + return 0 if complete else 1 + + +def load_or_create_state(args: argparse.Namespace, plan: dict[str, Any]) -> dict[str, Any]: + plan_digest = digest_json(plan) + if args.state.exists(): + state = json.loads(args.state.read_text()) + if state.get("schemaVersion") != 1 or state.get("planDigest") != plan_digest: + raise CampaignError("state does not belong to this exact matrix plan") + if state.get("commit") != args.commit or state.get("installerSHA256") != sha256(args.installer): + raise CampaignError("commit or installer changed after campaign checkpoints were recorded") + return state + state = initial_state(plan, plan_digest, args.commit, args.installer) + atomic_json(args.state, state) + return state + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", required=True, type=pathlib.Path) + parser.add_argument("--state", required=True, type=pathlib.Path) + parser.add_argument("--artifacts", required=True, type=pathlib.Path) + parser.add_argument("--commit", required=True) + parser.add_argument("--installer", required=True, type=pathlib.Path) + parser.add_argument("--fixture", type=pathlib.Path) + parser.add_argument("--host") + parser.add_argument("--lab", type=pathlib.Path) + parser.add_argument("--repo", type=pathlib.Path) + parser.add_argument("--dashboard-updater", type=pathlib.Path) + parser.add_argument("--dashboard-state", type=pathlib.Path, default=pathlib.Path("/tmp/keypath-matrix-state.json")) + parser.add_argument("--hid-fixture-client", type=pathlib.Path) + parser.add_argument("--hid-fixture-host", default=os.environ.get("KEYPATH_FIXTURE_HOST", "keypath-hid-fixture.local")) + parser.add_argument("--hid-presentation", action="store_true", + help="mirror campaign phases and results to the physical HID fixture display") + parser.add_argument("--ack-checkpoint", action="append", default=[]) + parser.add_argument("--only-job", action="append", default=[]) + args = parser.parse_args() + root = pathlib.Path(__file__).resolve().parents[2] + args.repo = (args.repo or root).resolve() + args.lab = (args.lab or root / "Scripts/lab/keypath-lab").resolve() + args.hid_fixture_client = (args.hid_fixture_client or root / "Scripts/lab/pico-hid-fixture-client").resolve() + try: + plan = validate_plan(json.loads(args.plan.read_text())) + if not re.fullmatch(r"[0-9a-fA-F]{40}", args.commit): + raise CampaignError("--commit must be a full 40-character SHA") + if not args.installer.is_file(): + raise CampaignError("installer does not exist") + selected_ids = set(args.only_job) if args.only_job else {job["id"] for job in plan["jobs"]} + if any( + job["id"] in selected_ids and "create-fresh-lease-with-fixture" in job["steps"] + for job in plan["jobs"] + ): + if not args.fixture or not args.fixture.is_file(): + raise CampaignError("this plan requires an existing --fixture archive") + unknown = set(args.only_job) - {job["id"] for job in plan["jobs"]} + if unknown: + raise CampaignError("unknown --only-job: " + ", ".join(sorted(unknown))) + if args.dashboard_updater and not args.dashboard_updater.is_file(): + raise CampaignError("dashboard updater does not exist") + if args.hid_presentation: + if not os.environ.get("KEYPATH_FIXTURE_TOKEN"): + raise CampaignError("--hid-presentation requires KEYPATH_FIXTURE_TOKEN") + if not args.hid_fixture_client.is_file(): + raise CampaignError("HID fixture client does not exist") + state = load_or_create_state(args, plan) + waiting = {job.get("waitingCheckpoint") for job in state["jobs"] if job.get("waitingCheckpoint")} + invalid_acknowledgements = set(args.ack_checkpoint) - waiting + if invalid_acknowledgements: + raise CampaignError( + "checkpoint acknowledgement is not currently waiting: " + + ", ".join(sorted(invalid_acknowledgements)) + ) + runner = MatrixRunner(args, plan, state) + if args.dashboard_updater and not state.get("dashboardInitialized"): + runner.dashboard("initialize", "--plan", str(args.plan), "--campaign-id", state["campaignId"]) + state["dashboardInitialized"] = True + runner.save() + raise SystemExit(runner.run()) + except (OSError, json.JSONDecodeError, CampaignError) as error: + print(f"scenario-matrix-runner: {error}", file=sys.stderr) + raise SystemExit(2) + + +if __name__ == "__main__": + main() diff --git a/Scripts/lab/scenarios/installer-scenario b/Scripts/lab/scenarios/installer-scenario index 467e21760..33213b3de 100755 --- a/Scripts/lab/scenarios/installer-scenario +++ b/Scripts/lab/scenarios/installer-scenario @@ -28,6 +28,71 @@ record_system() { installed_cli="/Applications/KeyPath.app/Contents/MacOS/keypath-cli" record_system +assert_ready() { + if [[ "$lane" == "managed-functional" ]]; then + Scripts/lab/assert-runtime-state ready \ + --managed-policy-manifest /Library/KeyPathLab/managed-policy/manifest.json + else + Scripts/lab/assert-runtime-state ready + fi +} + +wait_for_runtime_ready() { + local ready_output=$1 attempts_output=$2 diagnostics_output=$3 + local timeout=${KEYPATH_LAB_REBOOT_READY_TIMEOUT_SECONDS:-60} + local interval=${KEYPATH_LAB_REBOOT_READY_INTERVAL_SECONDS:-2} + local started=$SECONDS attempt=0 elapsed=0 attempt_stderr + [[ "$timeout" == <-> && "$timeout" -gt 0 ]] || { + print -u2 "invalid reboot readiness timeout: $timeout" + return 2 + } + [[ "$interval" == <-> && "$interval" -gt 0 ]] || { + print -u2 "invalid reboot readiness interval: $interval" + return 2 + } + attempt_stderr="$artifact_dir/ready-attempt.stderr" + : > "$attempts_output" + : > "$diagnostics_output" + while true; do + (( attempt += 1 )) + if assert_ready > "$ready_output" 2> "$attempt_stderr"; then + elapsed=$(( SECONDS - started )) + print "attempt\t$attempt\telapsed_seconds\t$elapsed\tstatus\tpassed" >> "$attempts_output" + [[ ! -s "$attempt_stderr" ]] || cat "$attempt_stderr" >> "$diagnostics_output" + rm -f "$attempt_stderr" + return 0 + fi + elapsed=$(( SECONDS - started )) + print "attempt\t$attempt\telapsed_seconds\t$elapsed\tstatus\twarming" >> "$attempts_output" + { + print "attempt=$attempt elapsed_seconds=$elapsed" + cat "$attempt_stderr" + } >> "$diagnostics_output" + if (( elapsed >= timeout )); then + rm -f "$attempt_stderr" + return 1 + fi + sleep "$interval" + done +} + +record_scenario_result() { + local scenario_status=$1 summary=$2 + shift 2 + "$result_tool" record --output "$artifact_dir/result.json" --scenario "$name" \ + --status "$scenario_status" --summary "$summary" "$@" +} + +capture_app_identity() { + local output=$1 app=/Applications/KeyPath.app + { + /usr/bin/defaults read "$app/Contents/Info" CFBundleIdentifier + /usr/bin/defaults read "$app/Contents/Info" CFBundleShortVersionString + /usr/bin/codesign -dv --verbose=4 "$app" 2>&1 | \ + /usr/bin/awk -F= '/^(Identifier|TeamIdentifier|CDHash)=/ { print }' + } > "$output" +} + case "$name" in clean-install) find "$installer_dir" -maxdepth 1 -type f -print > "$artifact_dir/installer-files.txt" @@ -42,6 +107,11 @@ case "$name" in require_installed_cli "$installed_cli" system inspect --json | tee "$artifact_dir/system-inspect.json" Scripts/lab/probe-kanata-tcp 2> "$artifact_dir/tcp-readiness.stderr" | tee "$artifact_dir/tcp-readiness.json" + assert_ready | tee "$artifact_dir/ready.tsv" + record_scenario_result passed \ + "The installed helper, managed services, Kanata process, and TCP control plane are independently healthy." \ + --evidence system-inspect.json --evidence tcp-readiness.json \ + --evidence tcp-readiness.stderr --evidence ready.tsv ;; managed-capabilities) if [[ "$lane" != "managed-functional" ]]; then @@ -52,36 +122,318 @@ case "$name" in exit 4 fi require_installed_cli - Scripts/lab/mdm/probe-managed-capabilities --output "$artifact_dir" + Scripts/lab/mdm/probe-managed-capabilities --output "$artifact_dir" \ + --managed-policy-manifest /Library/KeyPathLab/managed-policy/manifest.json + record_scenario_result passed \ + "The managed macOS runtime exposed every required KeyPath capability, policy assertion, system extension, launchd service, and TCP control-plane check." \ + --evidence service-status.json --evidence managed-policy-verification.tsv \ + --evidence system-extensions.txt --evidence kanata-launchd.txt \ + --evidence tcp-readiness.json --evidence result.tsv ;; launch) open -a KeyPath sleep 3 pgrep -fl '/Applications/KeyPath.app/Contents/MacOS/KeyPath' | tee "$artifact_dir/processes.txt" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + record_scenario_result passed \ + "The exact signed KeyPath application launched and remained running in the disposable guest." \ + --evidence processes.txt --evidence app-identity.txt ;; repair-reinstall) require_installed_cli "$installed_cli" system repair --json | tee "$artifact_dir/repair.json" "$installed_cli" system inspect --json | tee "$artifact_dir/post-repair.json" ;; + repair-kanata-service) + require_installed_cli + state="$artifact_dir/run-state.json" + runner_result="$artifact_dir/runner-result.json" + plan="$PWD/Scripts/lab/scenarios/plans/repair-kanata-service.json" + if [[ ! -f "$state" ]]; then + assert_ready | tee "$artifact_dir/pre-damage-ready.tsv" + "$installed_cli" service status --json > "$artifact_dir/pre-damage-status.json" + Scripts/lab/damage-kanata-service | tee "$artifact_dir/damage.tsv" + "$installed_cli" service status --json > "$artifact_dir/damaged-status.json" || true + Scripts/lab/assert-runtime-state degraded | tee "$artifact_dir/damaged-state.tsv" + fi + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$runner_result" + "$installed_cli" service status --json > "$artifact_dir/post-repair-status.json" + assert_ready | tee "$artifact_dir/post-repair-ready.tsv" + record_scenario_result passed \ + "A deliberate Kanata service failure was detected and repaired once to an independently ready runtime." \ + --evidence pre-damage-ready.tsv --evidence pre-damage-status.json \ + --evidence damage.tsv --evidence damaged-status.json --evidence damaged-state.tsv \ + --evidence run-state.json \ + --evidence post-repair-status.json --evidence post-repair-ready.tsv + ;; + upgrade-beta3-to-current) + plan="$PWD/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json" + state="$artifact_dir/run-state.json" + result="$artifact_dir/result.json" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" + Scripts/lab/assert-app-version 1.0.0 | tee "$artifact_dir/final-version.tsv" + /usr/bin/codesign -dvvv /Applications/KeyPath.app > "$artifact_dir/final-signature.txt" 2>&1 + ;; + upgrade-beta3-before) + Scripts/lab/assert-app-version 1.0.0-beta3 | tee "$artifact_dir/beta-version.tsv" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/beta-app-identity.txt" + record_scenario_result passed \ + "The signed beta-3 fixture is installed before the controller-owned upgrade." \ + --evidence beta-version.tsv --evidence beta-app-identity.txt + ;; + upgrade-beta3-after) + require_installed_cli + Scripts/lab/assert-app-version 1.0.0 | tee "$artifact_dir/current-version.tsv" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/current-app-identity.txt" + "$installed_cli" system inspect --json > "$artifact_dir/current-inspect.json" + install_state="$PWD/.keypath-lab/scenario-output/install-runtime/state.tsv" + install_boundary="$PWD/.keypath-lab/scenario-output/install-runtime/assert-state.json" + install_status=$(/usr/bin/awk -F '\t' '$1 == "status" {value=$2} END {print value}' "$install_state") + if [[ "$install_status" == "environment-limited-no-input-device" ]]; then + [[ -s "$install_boundary" ]] || { + record_scenario_result failed \ + "The no-input upgrade boundary lacked its independent controller evidence." \ + --classification harness-transport-failure --step no-input-boundary + exit 1 + } + /bin/cp "$install_boundary" "$artifact_dir/install-boundary.json" + record_scenario_result passed \ + "The exact current artifact upgraded beta-3 with preserved identity; runtime startup reached the independently proven Parallels no-input-device boundary." \ + --evidence current-version.tsv --evidence current-app-identity.txt \ + --evidence current-inspect.json --evidence install-boundary.json + else + assert_ready | tee "$artifact_dir/current-ready.tsv" + record_scenario_result passed \ + "The exact current artifact upgraded beta-3 and converged to an independently ready runtime." \ + --evidence current-version.tsv --evidence current-app-identity.txt \ + --evidence current-ready.tsv --evidence current-inspect.json + fi + ;; reboot-persistence-before) require_installed_cli + if ! assert_ready | tee "$artifact_dir/ready.tsv"; then + record_scenario_result blocked \ + "Reboot persistence requires an independently ready runtime baseline." \ + --classification environment-precondition-failure --step pre-reboot-ready + exit 4 + fi + "$installed_cli" service status --json > "$artifact_dir/service-status.json" "$installed_cli" system inspect --json > "$artifact_dir/before-reboot.json" + /usr/sbin/sysctl -n kern.boottime > "$artifact_dir/boot-time.txt" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + record_scenario_result passed \ + "Independent runtime, app identity, and boot marker captured before reboot." \ + --evidence ready.tsv --evidence service-status.json \ + --evidence before-reboot.json --evidence boot-time.txt \ + --evidence app-identity.txt print "Run the host-approved guest reboot, then execute reboot-persistence-after on the same lease." ;; reboot-persistence-after) require_installed_cli + before_dir="$PWD/.keypath-lab/scenario-output/reboot-persistence-before" + if [[ ! -s "$before_dir/boot-time.txt" || ! -s "$before_dir/app-identity.txt" ]]; then + record_scenario_result blocked \ + "The same lease must pass reboot-persistence-before before the post-reboot check." \ + --classification environment-precondition-failure --step pre-reboot-evidence + exit 4 + fi + /usr/sbin/sysctl -n kern.boottime > "$artifact_dir/boot-time.txt" + if /usr/bin/cmp -s "$before_dir/boot-time.txt" "$artifact_dir/boot-time.txt"; then + record_scenario_result blocked \ + "The boot marker did not change; no guest reboot was proven." \ + --classification environment-precondition-failure --step host-reboot + exit 4 + fi + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + if ! /usr/bin/cmp -s "$before_dir/app-identity.txt" "$artifact_dir/app-identity.txt"; then + record_scenario_result failed \ + "KeyPath app identity changed across the guest reboot." \ + --classification keypath-product-failure --step app-identity-persistence \ + --evidence app-identity.txt + exit 1 + fi + if ! wait_for_runtime_ready \ + "$artifact_dir/ready.tsv" \ + "$artifact_dir/ready-attempts.tsv" \ + "$artifact_dir/ready-attempts.log"; then + record_scenario_result failed \ + "The independently ready KeyPath runtime did not converge after the bounded reboot recovery window." \ + --classification keypath-product-failure --step post-reboot-ready \ + --evidence ready.tsv --evidence ready-attempts.tsv \ + --evidence ready-attempts.log + exit 1 + fi + "$installed_cli" service status --json > "$artifact_dir/service-status.json" "$installed_cli" system inspect --json | tee "$artifact_dir/after-reboot.json" Scripts/lab/probe-kanata-tcp 2> "$artifact_dir/tcp-readiness.stderr" | tee "$artifact_dir/tcp-readiness.json" + record_scenario_result passed \ + "App identity and independently ready runtime persisted across a proven guest reboot." \ + --evidence boot-time.txt --evidence app-identity.txt \ + --evidence ready.tsv --evidence ready-attempts.tsv \ + --evidence ready-attempts.log --evidence service-status.json \ + --evidence after-reboot.json --evidence tcp-readiness.json ;; uninstall) require_installed_cli + if ! assert_ready | tee "$artifact_dir/pre-uninstall-ready.tsv"; then + record_scenario_result blocked \ + "Uninstall requires an independently ready runtime baseline." \ + --classification environment-precondition-failure --step pre-uninstall-ready + exit 4 + fi + "$installed_cli" service status --json > "$artifact_dir/pre-uninstall-status.json" + capture_app_identity "$artifact_dir/pre-uninstall-app-identity.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + mkdir -p "${config_sentinel:h}" + print -n 'keypath-lab-preserve-v1' > "$config_sentinel" + /usr/bin/shasum -a 256 "$config_sentinel" > "$artifact_dir/config-sentinel.sha256" + expected_config_sha=$(/usr/bin/awk '{print $1}' "$artifact_dir/config-sentinel.sha256") "$installed_cli" system uninstall --json | tee "$artifact_dir/uninstall.json" - test ! -e /Applications/KeyPath.app + Scripts/lab/assert-uninstalled-state \ + --preserved-config "$config_sentinel" \ + --expected-sha256 "$expected_config_sha" | tee "$artifact_dir/uninstalled-state.tsv" + record_scenario_result passed \ + "KeyPath-owned app, helper, services, and transient user state were removed while configuration and the shared VirtualHID driver were preserved." \ + --evidence pre-uninstall-ready.tsv --evidence pre-uninstall-status.json \ + --evidence pre-uninstall-app-identity.txt --evidence uninstall.json \ + --evidence config-sentinel.sha256 --evidence uninstalled-state.tsv ;; - cancellation-failure) - print "Use a disposable lease to cancel one approval flow and inject one non-destructive failure." - print "Record the visible recovery action, CLI result, and KeyPath logs; do not corrupt the base image." + reinstall) + uninstall_dir="$PWD/.keypath-lab/scenario-output/uninstall" + uninstall_result="$uninstall_dir/result.json" + if [[ ! -s "$uninstall_result" ]] || \ + ! /usr/bin/python3 -c 'import json,sys; raise SystemExit(0 if json.load(open(sys.argv[1])).get("status") == "passed" else 1)' "$uninstall_result"; then + record_scenario_result blocked \ + "Reinstall requires a passed M05 uninstall result from this same lease." \ + --classification environment-precondition-failure --step same-lease-uninstall + exit 4 + fi + require_installed_cli + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/reinstalled-app-identity.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + expected_config_sha=$(/usr/bin/awk '{print $1}' "$uninstall_dir/config-sentinel.sha256") + actual_config_sha=$(/usr/bin/shasum -a 256 "$config_sentinel" | /usr/bin/awk '{print $1}') + if [[ "$actual_config_sha" != "$expected_config_sha" ]]; then + record_scenario_result failed \ + "Preserved configuration did not survive the reinstall boundary." \ + --classification keypath-product-failure --step configuration-preservation \ + --evidence reinstalled-app-identity.txt + exit 1 + fi + state="$artifact_dir/run-state.json" + result="$artifact_dir/runner-result.json" + plan="$PWD/Scripts/lab/scenarios/plans/reinstall-runtime.json" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$result" + "$installed_cli" service status --json > "$artifact_dir/post-reinstall-status.json" + assert_ready | tee "$artifact_dir/post-reinstall-ready.tsv" + record_scenario_result passed \ + "The exact candidate reinstalled after verified uninstall and converged once to an independently ready runtime without changing preserved configuration." \ + --evidence reinstalled-app-identity.txt --evidence run-state.json \ + --evidence post-reinstall-status.json --evidence post-reinstall-ready.tsv + ;; + cancellation-recovery-before) + require_installed_cli + if ! assert_ready | tee "$artifact_dir/ready.tsv"; then + record_scenario_result blocked \ + "Cancellation recovery requires an independently ready runtime baseline." \ + --classification environment-precondition-failure --step pre-cancellation-ready + exit 4 + fi + "$installed_cli" service status --json > "$artifact_dir/service-status.json" + "$installed_cli" system inspect --json > "$artifact_dir/system-inspect.json" + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + /usr/bin/open -a KeyPath + gui_ready=0 + for _ in {1..40}; do + if /usr/bin/pgrep -x KeyPath > "$artifact_dir/keypath-pid.txt"; then + gui_ready=1 + break + fi + sleep 0.25 + done + if (( gui_ready == 0 )); then + record_scenario_result failed \ + "The installed KeyPath GUI did not launch for the cancellation checkpoint." \ + --classification keypath-product-failure --step cancellation-gui-launch \ + --evidence ready.tsv --evidence app-identity.txt + exit 1 + fi + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + if [[ ! -f "$config_sentinel" ]]; then + mkdir -p "${config_sentinel:h}" + print -n 'keypath-lab-preserve-v1' > "$config_sentinel" + fi + /usr/bin/shasum -a 256 "$config_sentinel" > "$artifact_dir/config-sentinel.sha256" + record_scenario_result passed \ + "Ready runtime, running GUI, app identity, and preserved configuration captured before the operator cancellation checkpoint." \ + --evidence ready.tsv --evidence service-status.json \ + --evidence system-inspect.json --evidence app-identity.txt \ + --evidence keypath-pid.txt --evidence config-sentinel.sha256 + print "Open KeyPath's real uninstall confirmation, capture it, click Cancel through the lease-owned desktop, then run cancellation-recovery-after." + ;; + cancellation-recovery-after) + before_dir="$PWD/.keypath-lab/scenario-output/cancellation-recovery-before" + before_result="$before_dir/result.json" + if [[ ! -s "$before_result" ]] || \ + ! /usr/bin/python3 -c 'import json,sys; raise SystemExit(0 if json.load(open(sys.argv[1])).get("status") == "passed" else 1)' "$before_result"; then + record_scenario_result blocked \ + "The same lease must pass cancellation-recovery-before before post-cancellation verification." \ + --classification environment-precondition-failure --step cancellation-checkpoint + exit 4 + fi + require_installed_cli + /usr/bin/codesign --verify --deep --strict /Applications/KeyPath.app + capture_app_identity "$artifact_dir/app-identity.txt" + if ! /usr/bin/cmp -s "$before_dir/app-identity.txt" "$artifact_dir/app-identity.txt"; then + record_scenario_result failed \ + "KeyPath app identity changed across the cancellation checkpoint." \ + --classification keypath-product-failure --step cancellation-preserved-app \ + --evidence app-identity.txt + exit 1 + fi + /usr/bin/pgrep -x KeyPath > "$artifact_dir/keypath-pid.txt" + config_sentinel="$HOME/.config/keypath/.keypath-lab-preserve" + expected_config_sha=$(/usr/bin/awk '{print $1}' "$before_dir/config-sentinel.sha256") + actual_config_sha=$(/usr/bin/shasum -a 256 "$config_sentinel" | /usr/bin/awk '{print $1}') + if [[ "$actual_config_sha" != "$expected_config_sha" ]]; then + record_scenario_result failed \ + "Preserved configuration changed across the cancellation checkpoint." \ + --classification keypath-product-failure --step cancellation-preserved-configuration + exit 1 + fi + assert_ready | tee "$artifact_dir/post-cancel-ready.tsv" + "$installed_cli" service status --json > "$artifact_dir/post-cancel-status.json" + Scripts/lab/damage-kanata-service | tee "$artifact_dir/damage.tsv" + "$installed_cli" service status --json > "$artifact_dir/damaged-status.json" || true + Scripts/lab/assert-runtime-state degraded | tee "$artifact_dir/damaged-state.tsv" + state="$artifact_dir/run-state.json" + runner_result="$artifact_dir/runner-result.json" + plan="$PWD/Scripts/lab/scenarios/plans/repair-kanata-service.json" + Scripts/lab/scenario-runner --plan "$plan" --state "$state" --result "$runner_result" + "$installed_cli" service status --json > "$artifact_dir/post-repair-status.json" + assert_ready | tee "$artifact_dir/post-repair-ready.tsv" + Scripts/lab/probe-kanata-tcp 2> "$artifact_dir/tcp-readiness.stderr" | tee "$artifact_dir/tcp-readiness.json" + final_config_sha=$(/usr/bin/shasum -a 256 "$config_sentinel" | /usr/bin/awk '{print $1}') + if [[ "$final_config_sha" != "$expected_config_sha" ]]; then + record_scenario_result failed \ + "Preserved configuration changed during recovery." \ + --classification keypath-product-failure --step recovery-preserved-configuration + exit 1 + fi + record_scenario_result passed \ + "The real uninstall confirmation was cancelled without changing the app or configuration, then a non-destructive Kanata failure recovered once to an independently ready runtime." \ + --evidence app-identity.txt --evidence keypath-pid.txt \ + --evidence post-cancel-ready.tsv --evidence post-cancel-status.json \ + --evidence damage.tsv --evidence damaged-state.tsv \ + --evidence run-state.json --evidence post-repair-status.json \ + --evidence post-repair-ready.tsv --evidence tcp-readiness.json ;; failure-ownership-self-test) "$result_tool" selector-self-test --output "$artifact_dir/result.json" --scenario "$name" @@ -92,6 +444,12 @@ case "$name" in "$installed_cli" system inspect --json > "$artifact_dir/system-inspect.json" 2>/dev/null || true print "Scenario artifacts written to $artifact_dir; follow with keypath-lab artifacts ." ;; + macos-26-selector-scenario) + Scripts/lab/macos-26-selector-scenario --output "$artifact_dir" + ;; + macos-27-selector-scenario) + Scripts/lab/macos-27-selector-scenario --output "$artifact_dir" + ;; macos-27-regression) require_installed_cli product_major=$(sw_vers -productVersion | cut -d. -f1) @@ -101,7 +459,7 @@ case "$name" in ;; *) print -u2 "Unknown scenario: $name" - print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall reboot-persistence-before reboot-persistence-after uninstall cancellation-failure failure-ownership-self-test artifact-capture macos-27-regression" + print -u2 "Available: clean-install approvals helper-daemon-health managed-capabilities launch repair-reinstall repair-kanata-service upgrade-beta3-to-current upgrade-beta3-before upgrade-beta3-after reboot-persistence-before reboot-persistence-after uninstall reinstall cancellation-recovery-before cancellation-recovery-after failure-ownership-self-test artifact-capture macos-26-selector-scenario macos-27-selector-scenario macos-27-regression" exit 2 ;; esac diff --git a/Scripts/lab/scenarios/matrix-catalog.json b/Scripts/lab/scenarios/matrix-catalog.json new file mode 100644 index 000000000..6ac6494cd --- /dev/null +++ b/Scripts/lab/scenarios/matrix-catalog.json @@ -0,0 +1,152 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "local-contracts", + "title": "Harness contracts and failure ownership", + "provider": "local", + "automation": "unattended", + "estimatedMinutes": 5, + "nightlyCore": true, + "steps": ["run-lab-contract-tests", "run-failure-ownership-self-test", "verify-report-contract"], + "factors": {"platform": "local", "lane": "none", "family": "contracts", "boundary": "none", "evidence": "report"} + }, + { + "id": "macos15-clean-install", + "title": "macOS 15 managed clean install", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 42, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "managed-capabilities", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "install", "boundary": "fresh", "evidence": "runtime"} + }, + { + "id": "macos15-repair", + "title": "macOS 15 Kanata damage and one-pass repair", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 48, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "repair-kanata-service", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "repair", "boundary": "degraded-service", "evidence": "runtime"} + }, + { + "id": "macos15-reboot", + "title": "macOS 15 reboot persistence", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 55, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "reboot-persistence-before", "reboot-guest", "reboot-persistence-after", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "persistence", "boundary": "reboot", "evidence": "runtime"} + }, + { + "id": "macos15-uninstall-reinstall", + "title": "macOS 15 uninstall and same-lease reinstall", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 58, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "uninstall", "install-exact-artifact", "reinstall", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "lifecycle", "boundary": "uninstall", "evidence": "runtime"} + }, + { + "id": "macos26-upgrade", + "title": "macOS 26 beta-to-current upgrade", + "provider": "parallels", + "macOS": 26, + "lane": "managed-functional", + "identityScope": "shared", + "automation": "operator", + "estimatedMinutes": 50, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", "install-upgrade-artifact", "upgrade-beta3-after", "artifact-capture"], + "factors": {"platform": "macos26", "lane": "managed", "family": "upgrade", "boundary": "version", "evidence": "signature"} + }, + { + "id": "macos26-selectors", + "title": "macOS 26 fail-closed selector capture", + "provider": "parallels", + "macOS": 26, + "lane": "unmanaged-ui", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 24, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-26-selector-scenario", "artifact-capture"], + "factors": {"platform": "macos26", "lane": "unmanaged", "family": "selectors", "boundary": "approval-ui", "evidence": "semantic-ui"} + }, + { + "id": "macos27-selectors", + "title": "macOS 27 fail-closed selector capture", + "provider": "parallels", + "macOS": 27, + "lane": "unmanaged-ui", + "identityScope": "unique-clone", + "automation": "unattended", + "estimatedMinutes": 26, + "nightlyCore": true, + "cleanup": "destroy-owned-lease", + "steps": ["create-desktop-lease", "desktop-bootstrap", "macos-27-selector-scenario", "artifact-capture"], + "factors": {"platform": "macos27", "lane": "unmanaged", "family": "selectors", "boundary": "approval-ui", "evidence": "semantic-ui"} + }, + { + "id": "macos15-launch", + "title": "macOS 15 installed-app launch", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 38, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "launch", "helper-daemon-health", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "launch", "boundary": "fresh", "evidence": "process"} + }, + { + "id": "macos15-cancellation-recovery", + "title": "macOS 15 visible cancellation and recovery", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "operator", + "estimatedMinutes": 55, + "cleanup": "destroy-owned-lease", + "steps": ["create-fresh-lease", "install-exact-artifact", "cancellation-recovery-before", "operator-cancel-visible-dialog", "cancellation-recovery-after", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "recovery", "boundary": "user-cancel", "evidence": "visual-runtime"} + }, + { + "id": "macos15-physical-remap", + "title": "macOS 15 physical q-to-w remap", + "provider": "tart", + "macOS": 15, + "lane": "managed-functional", + "identityScope": "unique-clone", + "automation": "physical", + "estimatedMinutes": 50, + "cleanup": "destroy-owned-lease", + "steps": ["create-usb-lease", "install-exact-artifact", "physical-remap-session-prepare", "operator-physical-keypress", "physical-remap-session-observe", "artifact-capture"], + "factors": {"platform": "macos15", "lane": "managed", "family": "input-output", "boundary": "physical-hid", "evidence": "visual-runtime"} + } + ] +} diff --git a/Scripts/lab/scenarios/plans/reinstall-runtime.json b/Scripts/lab/scenarios/plans/reinstall-runtime.json new file mode 100644 index 000000000..3a357a9b0 --- /dev/null +++ b/Scripts/lab/scenarios/plans/reinstall-runtime.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "scenario": "reinstall", + "steps": [ + { + "id": "converge-reinstalled-runtime", + "action": [ + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", + "system", + "repair", + "--json" + ], + "verify": [ + "../../../../Scripts/lab/assert-runtime-state", + "ready", + "--managed-policy-manifest", + "/Library/KeyPathLab/managed-policy/manifest.json" + ] + } + ] +} diff --git a/Scripts/lab/scenarios/plans/repair-kanata-service.json b/Scripts/lab/scenarios/plans/repair-kanata-service.json new file mode 100644 index 000000000..14c5d1098 --- /dev/null +++ b/Scripts/lab/scenarios/plans/repair-kanata-service.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "scenario": "repair-kanata-service", + "steps": [ + { + "id": "repair-kanata-service", + "action": [ + "/Applications/KeyPath.app/Contents/MacOS/keypath-cli", + "system", + "repair", + "--json" + ], + "verify": [ + "../../../../Scripts/lab/assert-runtime-state", + "ready", + "--managed-policy-manifest", + "/Library/KeyPathLab/managed-policy/manifest.json" + ] + } + ] +} diff --git a/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json b/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json new file mode 100644 index 000000000..0866f5375 --- /dev/null +++ b/Scripts/lab/scenarios/plans/upgrade-beta3-to-current.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "scenario": "upgrade-beta3-to-current", + "steps": [ + { + "id": "install-beta3", + "action": [ + "../../../../Scripts/lab/install-keypath-artifact", + "../../../../.keypath-lab/fixtures/KeyPath-1.0.0-beta3.zip", + "1.0.0-beta3" + ], + "verify": [ + "../../../../Scripts/lab/assert-app-version", + "1.0.0-beta3" + ] + }, + { + "id": "upgrade-current", + "action": [ + "../../../../Scripts/lab/install-keypath-artifact", + "../../../../.keypath-lab/installer/KeyPath.zip", + "1.0.0" + ], + "verify": [ + "../../../../Scripts/lab/assert-app-version", + "1.0.0" + ] + } + ] +} diff --git a/Scripts/lab/tests/fixtures/showroom-demo.txt b/Scripts/lab/tests/fixtures/showroom-demo.txt new file mode 100644 index 000000000..2c79e395b --- /dev/null +++ b/Scripts/lab/tests/fixtures/showroom-demo.txt @@ -0,0 +1 @@ +KeyPath demo OK diff --git a/Scripts/lab/tests/hid-capture-jig-client-tests.py b/Scripts/lab/tests/hid-capture-jig-client-tests.py new file mode 100755 index 000000000..364eff279 --- /dev/null +++ b/Scripts/lab/tests/hid-capture-jig-client-tests.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import threading +import time +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +CLIENT = ROOT / "lab/hid-capture-jig-client" + + +class FakeJig: + def __init__(self, directory: pathlib.Path, response_delay: float = 0): + self.directory = directory + self.response_delay = response_delay + self.commands: list[dict[str, object]] = [] + self.stop = False + self.thread = threading.Thread(target=self.run, daemon=True) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *_): + self.stop = True + self.thread.join(timeout=1) + + def run(self): + last_id = None + while not self.stop: + try: + command = json.loads((self.directory / "command.json").read_text()) + except (FileNotFoundError, json.JSONDecodeError): + time.sleep(0.005) + continue + if command["id"] == last_id: + time.sleep(0.005) + continue + last_id = command["id"] + self.commands.append(command) + time.sleep(self.response_delay) + snapshot = { + "state": "armed" if command["action"] == "arm" else "idle", + "expected": command.get("expected", ""), + "received": "", + } + response = { + "id": command["id"], "ok": True, "message": "fake response", + "processID": 42, "snapshot": snapshot, + } + temporary = self.directory / ".response.tmp" + temporary.write_text(json.dumps(response)) + os.replace(temporary, self.directory / "response.json") + + +class HIDCaptureJigClientTests(unittest.TestCase): + def run_client(self, directory: pathlib.Path, *arguments: str, timeout: str = "1"): + environment = os.environ.copy() + environment["KEYPATH_CAPTURE_JIG_STATE_DIR"] = str(directory) + environment["KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT"] = timeout + return subprocess.run( + [str(CLIENT), *arguments], text=True, capture_output=True, + env=environment, timeout=5, + ) + + def test_arm_transports_expected_text_by_file(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + expected = directory / "expected.txt" + expected.write_text("q becomes w") + with FakeJig(directory) as jig: + result = self.run_client( + directory, "arm", "--run-id", "physical-1", "--expected", str(expected), + "--timeout-ms", "7000", "--settle-ms", "300", + "--demo-mode", + "--instruction", "UNPLUG USB-C NOW", + ) + self.assertEqual(result.returncode, 0, result.stderr) + response = json.loads(result.stdout) + self.assertEqual(response["snapshot"]["expected"], "q becomes w") + self.assertEqual(jig.commands[0]["runID"], "physical-1") + self.assertEqual(jig.commands[0]["timeoutMs"], 7000) + self.assertEqual(jig.commands[0]["settleMs"], 300) + self.assertEqual(jig.commands[0]["instruction"], "UNPLUG USB-C NOW") + self.assertTrue(jig.commands[0]["demoMode"]) + + def test_focus_uses_a_distinct_control_action(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + with FakeJig(directory) as jig: + result = self.run_client(directory, "focus") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(jig.commands[0]["action"], "focus") + + def test_bear_monitor_transports_schedule_without_claiming_capture(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + scheduled = directory / "scheduled.txt" + scheduled.write_text("teh teh \n") + with FakeJig(directory) as jig: + begin = self.run_client( + directory, "bear-begin", "--run-id", "typover-1", + "--case-index", "2", "--case-count", "4", + "--interval-ms", "60", "--word-count", "2", + "--scheduled-text", str(scheduled), "--start-delay-ms", "1500", + "--bear-focused", + ) + update = self.run_client( + directory, "bear-update", "--phase", "safeMisses", + "--corrected-words", "1", "--missed-words", "1", + "--message", "one safe miss", "--bear-focused", + ) + self.assertEqual(begin.returncode, 0, begin.stderr) + self.assertEqual(update.returncode, 0, update.stderr) + self.assertEqual(jig.commands[0]["action"], "bear-monitor-begin") + self.assertEqual(jig.commands[0]["scheduledText"], "teh teh \n") + self.assertEqual(jig.commands[0]["intervalMs"], 60) + self.assertEqual(jig.commands[1]["action"], "bear-monitor-update") + self.assertEqual(jig.commands[1]["phase"], "safeMisses") + self.assertEqual(jig.commands[1]["correctedWords"], 1) + + def test_concurrent_clients_are_serialized_across_the_single_command_slot(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + environment = os.environ.copy() + environment["KEYPATH_CAPTURE_JIG_STATE_DIR"] = str(directory) + environment["KEYPATH_CAPTURE_JIG_COMMAND_TIMEOUT"] = "1" + with FakeJig(directory, response_delay=0.1) as jig: + clients = [ + subprocess.Popen( + [str(CLIENT), command], text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=environment, + ) + for command in ("status", "focus") + ] + results = [client.communicate(timeout=3) for client in clients] + + self.assertEqual([client.returncode for client in clients], [0, 0], results) + self.assertEqual(sorted(command["action"] for command in jig.commands), ["focus", "status"]) + + def test_missing_app_fails_with_actionable_message(self): + with tempfile.TemporaryDirectory() as temporary: + result = self.run_client(pathlib.Path(temporary), "status", timeout="0.1") + self.assertEqual(result.returncode, 2) + self.assertIn("hid-capture-jig-tool open", result.stderr) + + def test_missing_expected_file_is_rejected_before_command(self): + with tempfile.TemporaryDirectory() as temporary: + directory = pathlib.Path(temporary) + result = self.run_client( + directory, "arm", "--run-id", "missing", "--expected", str(directory / "none"), + ) + self.assertEqual(result.returncode, 2) + self.assertIn("does not exist", result.stderr) + self.assertFalse((directory / "command.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/hid-capture-jig-tool-tests.py b/Scripts/lab/tests/hid-capture-jig-tool-tests.py new file mode 100644 index 000000000..539d52434 --- /dev/null +++ b/Scripts/lab/tests/hid-capture-jig-tool-tests.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Contract tests for the HID Capture Jig launcher.""" + +from __future__ import annotations + +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +TOOL = ROOT / "Scripts/lab/hid-capture-jig-tool" + + +class HIDCaptureJigToolTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.fake_bin = self.directory / "bin" + self.fake_bin.mkdir() + self.state = self.directory / "running" + self.log = self.directory / "calls.log" + self.app = self.directory / "KeyPath HID Capture Jig.app" + binary = self.app / "Contents/MacOS/HIDCaptureJig" + binary.parent.mkdir(parents=True) + binary.write_text("current jig binary\n") + binary.chmod(0o755) + self.state.write_text("old process\n") + + self.build_app = self.directory / "build-app" + self._write_executable(self.build_app, "#!/bin/bash\nexit 0\n") + self.client = self.directory / "client" + self._write_executable(self.client, textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf 'client %s\\n' "$*" >> {str(self.log)!r} + case "$1" in + quit) rm -f {str(self.state)!r}; printf '{{"ok":true}}\\n' ;; + status) + [[ -f {str(self.state)!r} ]] || exit 2 + printf '{{"ok":true,"snapshot":{{"state":"idle"}}}}\\n' + ;; + focus) printf '{{"ok":true}}\\n' ;; + *) printf '{{"ok":true}}\\n' ;; + esac + """)) + self.runner = self.directory / "runner" + self._write_executable(self.runner, textwrap.dedent(f"""\ + #!/bin/bash + printf 'runner %s\\n' "$*" >> {str(self.log)!r} + printf '{{"status":"passed"}}\\n' + """)) + self._write_executable(self.fake_bin / "pgrep", textwrap.dedent(f"""\ + #!/bin/bash + [[ -f {str(self.state)!r} ]] + """)) + self._write_executable(self.fake_bin / "open", textwrap.dedent(f"""\ + #!/bin/bash + printf 'open %s\\n' "$*" >> {str(self.log)!r} + printf 'new process\\n' > {str(self.state)!r} + """)) + + self.environment = os.environ.copy() + self.environment.update({ + "HOME": str(self.directory), + "PATH": f"{self.fake_bin}:{self.environment['PATH']}", + "KEYPATH_CAPTURE_JIG_APP": str(self.app), + "KEYPATH_CAPTURE_JIG_BUILD_APP": str(self.build_app), + "KEYPATH_CAPTURE_JIG_CLIENT": str(self.client), + "KEYPATH_PHYSICAL_HID_RUNNER": str(self.runner), + }) + + def tearDown(self) -> None: + self.temporary.cleanup() + + @staticmethod + def _write_executable(path: pathlib.Path, contents: str) -> None: + path.write_text(contents) + path.chmod(0o755) + + def run_tool(self, command: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(TOOL), command], text=True, capture_output=True, env=self.environment + ) + + def test_showroom_restarts_a_healthy_but_potentially_stale_process(self) -> None: + result = self.run_tool("showroom") + + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + self.assertIn("client quit", calls) + self.assertTrue(any(call.startswith("open ") for call in calls)) + self.assertFalse(any(call == "client focus" for call in calls)) + runner_call = next(call for call in calls if call.startswith("runner ")) + self.assertIn("--demo-mode", runner_call) + self.assertIn("--repeat 14", runner_call) + self.assertIn("--cycle-gap-ms 469", runner_call) + self.assertLess(calls.index("client quit"), next( + index for index, call in enumerate(calls) if call.startswith("open ") + )) + + def test_jig_uses_its_own_icon_and_does_not_bundle_the_keypath_logo(self) -> None: + source = ( + ROOT / "Scripts/lab/hid-capture-jig/Sources/HIDCaptureJig/main.swift" + ).read_text() + build_script = ( + ROOT / "Scripts/lab/hid-capture-jig/build-app.sh" + ).read_text() + + self.assertIn('forResource: "AppIcon"', source) + self.assertIn("drawJigLogo", source) + self.assertIn("application.applicationIconImage = image", source) + self.assertNotIn("KeyPathLogo", source) + self.assertIn( + 'rm -f "$app_path/Contents/Resources/KeyPathLogo.icns"', + build_script, + ) + self.assertEqual(build_script.count("KeyPathLogo"), 1) + self.assertNotIn("Sources/KeyPathApp/Resources/AppIcon.icns", build_script) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/install-runtime-tests.py b/Scripts/lab/tests/install-runtime-tests.py new file mode 100644 index 000000000..9ecf28c46 --- /dev/null +++ b/Scripts/lab/tests/install-runtime-tests.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +import json +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +INSTALL_RUNTIME = ROOT / "Scripts/lab/install-runtime" +PLAN_ID = "11111111-1111-4111-8111-111111111111" +SNAPSHOT_ID = "22222222-2222-4222-8222-222222222222" +RUN_ID = "33333333-3333-4333-8333-333333333333" +INSTALL_PLAN_ID = "44444444-4444-4444-8444-444444444444" +AFTER_ID = "55555555-5555-4555-8555-555555555555" + + +class InstallRuntimeTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.approved = self.directory / "approved" + self.install_count = self.directory / "install-count" + self.open_count = self.directory / "open-count" + self.config = self.directory / "config/keypath.kbd" + self.app_open = self.directory / "open-keypath" + self.app_open.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + mkdir -p {str(self.config.parent)!r} + print '(defcfg)' > {str(self.config)!r} + count=0 + [[ -f {str(self.open_count)!r} ]] && count=$(cat {str(self.open_count)!r}) + print $((count + 1)) > {str(self.open_count)!r} + """)) + self.app_open.chmod(0o755) + self.app_quit = self.directory / "quit-keypath" + self.app_quit.write_text("#!/bin/zsh\nexit 0\n") + self.app_quit.chmod(0o755) + self.cli = self.directory / "keypath-cli" + self.cli.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + if [[ "$1 $2" == "system inspect" ]]; then + operational=false + [[ -f {str(self.approved)!r} ]] && operational=true + print '{{"apiVersion":1,"data":{{"planID":"{PLAN_ID}","snapshotID":"{SNAPSHOT_ID}","isOperational":'$operational'}}}}' + exit 0 + fi + if [[ "$1 $2" == "system install" ]]; then + [[ -s {str(self.config)!r} ]] || {{ print -u2 'config missing before install'; exit 9 }} + count=0 + [[ -f {str(self.install_count)!r} ]] && count=$(cat {str(self.install_count)!r}) + print $((count + 1)) > {str(self.install_count)!r} + if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "success" ]]; then + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":false,"success":true}}}}' + exit 0 + fi + if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "verification-failed" ]]; then + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"verification-failed","userActionRequired":true,"success":false,"failureReason":"Kanata did not become running + TCP responsive within readiness timeout","failedPostconditions":["runtime-ready-or-approval-pending"]}}}}' + exit 1 + fi + if [[ "${{KEYPATH_TEST_INSTALL_MODE:-waiting}}" == "completed-waiting" ]]; then + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"completed","userActionRequired":true,"success":false}}}}' + exit 1 + fi + print '{{"apiVersion":1,"data":{{"runID":"{RUN_ID}","planID":"{INSTALL_PLAN_ID}","beforeSnapshotID":"{SNAPSHOT_ID}","afterSnapshotID":"{AFTER_ID}","completionState":"awaiting-approval","userActionRequired":true,"success":false}}}}' + exit 1 + fi + exit 2 + """)) + self.cli.chmod(0o755) + self.assert_runtime = self.directory / "assert-runtime" + self.assert_runtime.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + [[ -f {str(self.approved)!r} ]] || exit 1 + print 'runtime\tready' + """)) + self.assert_runtime.chmod(0o755) + self.kanata = self.directory / "kanata" + self.kanata.write_text("#!/bin/zsh\nprint 'No devices found. Ensure a physical device is attached.'\n") + self.kanata.chmod(0o755) + self.launchctl = self.directory / "launchctl" + self.launchctl.write_text(textwrap.dedent("""\ + #!/bin/zsh + print 'managed_by = com.apple.xpc.ServiceManagement' + print 'last exit code = 1' + """)) + self.launchctl.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def invoke(self, *, mode: str = "waiting", allow_no_input: bool = False) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "KEYPATH_INSTALL_RUNTIME_CLI": str(self.cli), + "KEYPATH_INSTALL_RUNTIME_ASSERT": str(self.assert_runtime), + "KEYPATH_INSTALL_RUNTIME_OPEN": str(self.app_open), + "KEYPATH_INSTALL_RUNTIME_QUIT": str(self.app_quit), + "KEYPATH_INSTALL_RUNTIME_CONFIG": str(self.config), + "KEYPATH_INSTALL_RUNTIME_BOOTSTRAP_TIMEOUT": "2", + "KEYPATH_INSTALL_RUNTIME_KANATA_BINARY": str(self.kanata), + "KEYPATH_INSTALL_RUNTIME_LAUNCHCTL": str(self.launchctl), + "KEYPATH_INSTALL_RUNTIME_ALLOW_NO_INPUT_DEVICE": "1" if allow_no_input else "0", + "KEYPATH_TEST_INSTALL_MODE": mode, + } + return subprocess.run( + [str(INSTALL_RUNTIME), "unmanaged-ui"], cwd=self.directory, + env=env, text=True, capture_output=True, + ) + + def test_waiting_install_is_not_repeated_after_operator_approval(self) -> None: + first = self.invoke() + self.assertEqual(first.returncode, 4, first.stderr) + self.assertIn("install_runtime\twaiting", first.stdout) + self.assertEqual(self.install_count.read_text().strip(), "1") + self.assertEqual(self.open_count.read_text().strip(), "1") + + self.approved.touch() + second = self.invoke() + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(self.install_count.read_text().strip(), "1") + self.assertEqual(self.open_count.read_text().strip(), "1") + evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) + self.assertTrue(evidence["runtimeReady"]["agreement"]) + self.assertEqual(evidence["plan"]["runID"], RUN_ID) + + def test_claimed_success_without_independent_ready_state_is_a_failure(self) -> None: + result = self.invoke(mode="success") + self.assertEqual(result.returncode, 1) + self.assertIn("independent runtime postconditions were absent", result.stderr) + evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) + self.assertFalse(evidence["runtimeReady"]["agreement"]) + + def test_first_launch_creates_default_config_before_install(self) -> None: + result = self.invoke() + self.assertEqual(result.returncode, 4, result.stderr) + self.assertTrue(self.config.is_file()) + self.assertEqual(self.open_count.read_text().strip(), "1") + self.assertEqual(self.install_count.read_text().strip(), "1") + + def test_verification_failure_with_user_action_is_not_approval_wait(self) -> None: + result = self.invoke(mode="verification-failed") + self.assertEqual(result.returncode, 1) + self.assertIn("KeyPath's installer reported failure", result.stderr) + self.assertNotIn("install_runtime\twaiting", result.stdout) + state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() + self.assertIn("status\tfailed", state) + + def test_upgrade_can_record_exact_no_input_provider_boundary(self) -> None: + result = self.invoke(mode="verification-failed", allow_no_input=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("install_runtime\tenvironment-limited-no-input-device", result.stdout) + state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() + self.assertIn("status\tenvironment-limited-no-input-device", state) + evidence = json.loads((self.directory / ".keypath-lab/scenario-output/install-runtime/assert-state.json").read_text()) + self.assertEqual(evidence["boundary"]["inputDevices"], 0) + + def test_upgrade_boundary_fails_closed_without_zero_device_evidence(self) -> None: + self.kanata.write_text("#!/bin/zsh\nprint 'Available keyboard devices: physical keyboard'\n") + result = self.invoke(mode="verification-failed", allow_no_input=True) + self.assertEqual(result.returncode, 1) + self.assertIn("KeyPath's installer reported failure", result.stderr) + + def test_completed_install_requiring_permission_is_resumable(self) -> None: + result = self.invoke(mode="completed-waiting") + self.assertEqual(result.returncode, 4, result.stderr) + self.assertIn("install_runtime\twaiting", result.stdout) + state = (self.directory / ".keypath-lab/scenario-output/install-runtime/state.tsv").read_text() + self.assertIn("status\tawaiting-approval", state) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 9c630a063..aa4395ad9 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -24,6 +24,11 @@ case "\$1" in echo $'nameplate_version\t0.2.5' echo $'nameplate_sha256\t96d1b6c58167b4a8f3713a61a7e216f8a24c2adad36c9027db974f852d543a3d' [[ " \$* " == *" hide "* ]] && echo $'nameplate_state\thidden' || echo $'nameplate_state\tvisible' + elif [[ "\$*" == *"preflight-inspect.json"* ]]; then + [[ \${KEYPATH_LAB_TEST_MUTATION_EVIDENCE:-0} == 1 ]] && exit 9 + exit 0 + elif [[ "\$*" == *"Scripts/lab/install-runtime"* ]]; then + exit "\${KEYPATH_LAB_TEST_INSTALL_RUNTIME_EXIT:-0}" elif [[ "\$*" == *"IOPlatformUUID"* ]]; then [[ "\$*" == *"cbx_test15b"* ]] && echo 15151515-1515-1515-1515-151515151517 || echo 15151515-1515-1515-1515-151515151516 else @@ -77,6 +82,14 @@ EOF cat > "$ROOT/bin/crabbox" <> "$CALLS" +if [[ \$1 == desktop && \$2 == type && " \$* " != *" --text "* ]]; then + cat > "$TMP/crabbox-desktop-type-stdin" + [[ \${KEYPATH_LAB_TEST_CRABBOX_TYPE_FAIL:-0} == 1 ]] && exit 42 + exit 0 +fi +if [[ \$1 == desktop && \$2 == click && \${KEYPATH_LAB_TEST_CRABBOX_CLICK_FAIL:-0} == 1 ]]; then + exit 43 +fi if [[ \$1 == warmup ]]; then if [[ " \$* " == *" --provider tart "* ]]; then echo 'leased cbx_stale instance=stale-resource' @@ -122,6 +135,27 @@ EOF cat > "$ROOT/bin/guest-ssh" < "$TMP/guest-ssh-args" +printf '%s\n' "\$*" >> "$TMP/guest-ssh-calls" +if [[ " \$* " == *"fileHandleWithStandardInput"* ]]; then + cat > "$TMP/system-settings-secure-stdin" + exit 0 +fi +if [[ " \$* " == *"field.focused"* ]]; then + printf 'focused,400,220' + exit 0 +fi +if [[ " \$* " == *"button.position"* ]]; then + printf '400,300' + exit 0 +fi +if [[ " \$* " == *"AXPress"* ]]; then + printf pressed + exit 0 +fi +if [[ " \$* " == *"AXSecureTextField"* ]]; then + printf closed + exit 0 +fi if [[ " \$* " == *" /bin/test -p /tmp/keypath-console-login-"* ]]; then cat >/dev/null else @@ -191,23 +225,78 @@ chmod +x "$ROOT/bin/prlctl" echo test-private-key > "$TMP/id_ed25519" printf 'fixture-password-that-must-not-leak' > "$TMP/secure-input" grep -Fq 'exec 3<> \"\$fifo\"; KEYPATH_GUEST_PASSWORD=; IFS= read -r -t $credential_timeout -u 3 KEYPATH_GUEST_PASSWORD || [[ -n \"\$KEYPATH_GUEST_PASSWORD\" ]]' "$REMOTE" -grep -Fq 'IFS= read -r secret_value || [[ -n "$secret_value" ]]' "$REMOTE" +grep -Fq 'fileHandleWithStandardInput.readDataToEndOfFile' "$REMOTE" +if grep -Fq 'events.keystroke(secret)' "$REMOTE"; then + echo "SecurityAgent secure input still uses synthetic Accessibility typing" >&2 + exit 1 +fi grep -Fq 'managed_clone_enrollment\talready-enrolled' "$REMOTE" grep -Fq 'window.subrole() === "AXSystemDialog"' "$REMOTE" +grep -Fq 'occlusion_qualification_script' "$REMOTE" +grep -Fq 'captureConsent' "$REMOTE" +grep -Fq 'private window picker' "$REMOTE" +grep -Fq 'windowSize[0] * windowSize[1] * 0.5' "$REMOTE" grep -Fq 'usb_prefix="$TART_USB_TOOL_ROOT/bin:"' "$REMOTE" if grep -Fq 'peekaboo see --app "System Settings"' "$REMOTE"; then echo "capture prompt guard must not create a new capture request" >&2 exit 1 fi grep -q 'resume-managed-policy)' "$LAB_DIR/keypath-lab" +grep -q 'approve-input-monitoring)' "$LAB_DIR/keypath-lab" +grep -Fq 'ServerAliveInterval=15' "$LAB_DIR/keypath-lab" +grep -Fq 'ServerAliveCountMax=4' "$LAB_DIR/keypath-lab" grep -Fq '/usr/bin/mktemp /etc/kcpassword.XXXXXXXX' "$REMOTE" grep -Fq "Automatic login user: keypathqa" "$REMOTE" /bin/bash -n "$LAB_DIR/../qa-macos-27-regression.sh" /bin/zsh -n "$LAB_DIR/desktop-bootstrap" +grep -Fq 'com.keypath.lab.peekaboo-host' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'Peekaboo Lab Host.app' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'peekaboo-lab-host-arm64' "$LAB_DIR/desktop-bootstrap" +grep -Fq '"--mode"' "$LAB_DIR/assets/peekaboo-lab-host.c" +grep -Fq '"manual"' "$LAB_DIR/assets/peekaboo-lab-host.c" +grep -Fq 'NSScreenCaptureUsageDescription' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'NSAccessibilityUsageDescription' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'fullMessage.indexOf("screen")' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'mentionsScreenCapture' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'reset-desktop-keychain)' "$LAB_DIR/keypath-lab" +grep -Fq 'reboot-guest)' "$LAB_DIR/keypath-lab" +grep -Fq 'desktop keychain reset requires a verified console login' "$REMOTE" +grep -Fq 'Tart guest SSH did not recover after reboot' "$REMOTE" /bin/zsh -n "$LAB_DIR/mdm/enroll-clone-ui" /bin/zsh -n "$LAB_DIR/nameplate-instrumentation" /bin/zsh -n "$LAB_DIR/scenarios/kanata-vhid-two-clients" +/bin/zsh -n "$LAB_DIR/scenarios/installer-scenario" +/bin/zsh -n "$LAB_DIR/install-runtime" +/bin/zsh -n "$LAB_DIR/capture-controller-state" +python3 "$LAB_DIR/tests/scenario-matrix-tests.py" +python3 "$LAB_DIR/tests/scenario-matrix-runner-tests.py" +python3 "$LAB_DIR/tests/install-runtime-tests.py" +python3 "$LAB_DIR/tests/macos-26-selector-scenario-tests.py" +python3 "$LAB_DIR/tests/macos-27-selector-scenario-tests.py" +python3 "$LAB_DIR/tests/physical-remap-session-tests.py" +python3 "$LAB_DIR/tests/pico-hid-fixture-client-tests.py" +python3 "$LAB_DIR/tests/pico-hid-fixture-tool-tests.py" +"$LAB_DIR/pico-hid-fixture/tests/run-tests.sh" +if grep -Eq 'local[[:space:]]+status=' "$LAB_DIR/scenarios/installer-scenario"; then + echo "installer scenario must not shadow zsh's read-only status parameter" >&2 + exit 1 +fi +grep -Fq 'Reboot persistence requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The boot marker did not change; no guest reboot was proven.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'KeyPath app identity changed across the guest reboot.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'KEYPATH_LAB_REBOOT_READY_TIMEOUT_SECONDS' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'run_bounded 10 /usr/bin/osascript' "$LAB_DIR/desktop-bootstrap" +grep -Fq 'ready-attempts.tsv' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'did not converge after the bounded reboot recovery window.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'Cancellation recovery requires an independently ready runtime baseline.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The same lease must pass cancellation-recovery-before before post-cancellation verification.' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'Scripts/lab/damage-kanata-service' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'cancellation-recovery-before)' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'cancellation-recovery-after)' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'The managed macOS runtime exposed every required KeyPath capability' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'runner-result.json' "$LAB_DIR/scenarios/installer-scenario" +grep -Fq 'A deliberate Kanata service failure was detected and repaired once' "$LAB_DIR/scenarios/installer-scenario" grep -q 'macos-27-regression)' "$LAB_DIR/scenarios/installer-scenario" run_remote() { @@ -271,20 +360,45 @@ publish_checksum=$(shasum -a 256 "$LAB_DIR/scenarios/installer-scenario" | awk ' publish_key="$publish_commit-$publish_checksum" mkdir -p "$TMP/upload/repo/.keypath-lab/installer" cp "$LAB_DIR/scenarios/installer-scenario" "$TMP/upload/repo/.keypath-lab/installer/installer.zip" +printf 'keypath_commit\t%s\ninstaller_name\tinstaller.zip\ninstaller_sha256\t%s\n' \ + "$publish_commit" "$publish_checksum" > "$TMP/upload/repo/.keypath-lab/source.tsv" +printf '.keypath-lab/\n' > "$TMP/upload/repo/.gitignore" for pass in 1 2; do ticket=$(run_remote prepare-upload "$publish_key") tar -czf "$ticket" -C "$TMP/upload" repo published=$(run_remote install-archive "$ticket" "$publish_key" "$publish_commit" "$publish_checksum" installer.zip) if [[ $pass == 1 ]]; then assert_contains "$published" $'archive\tcreated'; else assert_contains "$published" $'archive\treused'; fi done +git -C "$ROOT/KeyPathInstallerLab/archives/$publish_key/repo" ls-files --error-unmatch .keypath-lab/installer/installer.zip >/dev/null if find "$ROOT/KeyPathInstallerLab/archives" -maxdepth 1 -name ".staging-$publish_key-*" | grep -q .; then echo "archive publish left a staging directory" >&2 exit 1 fi +harness_commit=$(printf 'd%.0s' {1..40}) +derived_key="$publish_key-h$harness_commit" +mkdir -p "$TMP/overlay/Scripts/lab" "$TMP/overlay/.keypath-lab/managed-policy" +echo hardened > "$TMP/overlay/Scripts/lab/harness-version" +echo managed > "$TMP/overlay/.keypath-lab/managed-policy/manifest.json" +for pass in 1 2; do + ticket=$(run_remote prepare-upload "$derived_key") + tar -czf "$ticket" -C "$TMP/overlay" . + derived=$(run_remote derive-archive "$ticket" "$publish_key" "$derived_key" "$publish_commit" "$publish_checksum" installer.zip "$harness_commit") + if [[ $pass == 1 ]]; then assert_contains "$derived" $'archive\tderived'; else assert_contains "$derived" $'archive\treused'; fi +done +derived_root="$ROOT/KeyPathInstallerLab/archives/$derived_key" +grep -q '^hardened$' "$derived_root/repo/Scripts/lab/harness-version" +grep -q $'^harness_commit\t'"$harness_commit" "$derived_root/ready.tsv" +grep -q $'^derived_from\t'"$publish_key" "$derived_root/ready.tsv" +[[ $(shasum -a 256 "$derived_root/repo/.keypath-lab/installer/installer.zip" | awk '{print $1}') == "$publish_checksum" ]] +if find "$ROOT/KeyPathInstallerLab/archives" -maxdepth 1 -name ".staging-$derived_key-*" | grep -q .; then + echo "derived archive publish left a staging directory" >&2 + exit 1 +fi + archive_key="$(printf 'a%.0s' {1..40})-$(printf 'b%.0s' {1..64})" repo="$ROOT/KeyPathInstallerLab/archives/$archive_key/repo" -mkdir -p "$repo/.keypath-lab/installer" "$repo/Scripts/lab/scenarios" "$repo/Scripts/lab/mdm" +mkdir -p "$repo/.keypath-lab/installer" "$repo/.keypath-lab/fixtures" "$repo/Scripts/lab/scenarios" "$repo/Scripts/lab/mdm" cp "$LAB_DIR/scenarios/installer-scenario" "$repo/Scripts/lab/scenarios/installer-scenario" cp "$LAB_DIR/nameplate-instrumentation" "$repo/Scripts/lab/nameplate-instrumentation" cat > "$repo/Scripts/lab/mdm/publish-managed-profiles" <<'EOF' @@ -304,6 +418,9 @@ chmod +x "$repo/Scripts/lab/scenarios/installer-scenario" chmod +x "$repo/Scripts/lab/nameplate-instrumentation" chmod +x "$repo/Scripts/lab/mdm/publish-managed-profiles" echo installer > "$repo/.keypath-lab/installer/KeyPath.zip" +echo older-installer > "$repo/.keypath-lab/fixtures/KeyPath-beta3.zip" +fixture_checksum=$(shasum -a 256 "$repo/.keypath-lab/fixtures/KeyPath-beta3.zip" | awk '{print $1}') +printf 'fixture_name\tKeyPath-beta3.zip\nfixture_sha256\t%s\n' "$fixture_checksum" > "$repo/.keypath-lab/source.tsv" mkdir -p "$ROOT/KeyPathInstallerLab/managed-identities" printf '%s\n' '15151515-1515-1515-1515-151515151515' \ > "$ROOT/KeyPathInstallerLab/managed-identities/keypath-macos-15-managed.enrollment-id" @@ -318,6 +435,19 @@ cat > "$ROOT/KeyPathInstallerLab/archives/$archive_key/ready.tsv" </dev/null + commit=$(printf 'a%.0s' {1..40}) checksum=$(printf 'b%.0s' {1..64}) set +e @@ -362,10 +492,56 @@ assert_contains "$capacity_output" $'active_lease\tcbx_test15' run_remote run cbx_test15 echo hello >/dev/null grep -q 'echo hello' "$ROOT/KeyPathInstallerLab/leases/cbx_test15/commands.tsv" +grep -q 'launcher15 run cbx_test15 -- /usr/bin/env PATH=/Users/admin/.local/bin:' "$CALLS" run_remote scenario cbx_test15 clean-install >/dev/null grep -q 'installer-scenario clean-install' "$ROOT/KeyPathInstallerLab/leases/cbx_test15/commands.tsv" run_remote install-app cbx_test15 >/dev/null grep -q 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log" +run_remote install-runtime cbx_test15 >/dev/null +grep -q 'Scripts/lab/install-runtime unmanaged-ui' "$CALLS" +grep -q $'install_runtime_status\tpassed' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="mutation-started"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +recovered_runtime=$(run_remote install-runtime cbx_test15) +assert_contains "$recovered_runtime" $'install_runtime_recovery\tpreflight-only' +grep -q $'install_runtime_status\tpassed' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="mutation-started"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +set +e +uncertain_runtime=$(KEYPATH_LAB_TEST_MUTATION_EVIDENCE=1 run_remote install-runtime cbx_test15 2>&1) +uncertain_runtime_exit=$? +set -e +[[ $uncertain_runtime_exit -ne 0 ]] || { echo "uncertain prior runtime mutation unexpectedly retried" >&2; exit 1; } +assert_contains "$uncertain_runtime" 'install-runtime has an uncertain prior mutation' +grep -q $'install_runtime_status\tmutation-started' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="staged"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +set +e +waiting_runtime=$(KEYPATH_LAB_TEST_INSTALL_RUNTIME_EXIT=4 run_remote install-runtime cbx_test15 2>&1) +waiting_runtime_exit=$? +set -e +[[ $waiting_runtime_exit -eq 4 ]] || { echo "approval wait returned $waiting_runtime_exit instead of 4" >&2; exit 1; } +grep -q $'install_runtime_status\tawaiting-approval' "$manifest" +awk -F '\t' 'BEGIN {OFS="\t"} $1 == "install_runtime_status" {$2="passed"} {print}' "$manifest" > "$manifest.tmp" +mv "$manifest.tmp" "$manifest" +run_remote scenario cbx_test15 uninstall >/dev/null +grep -q $'install_runtime_status\tuninstalled' "$manifest" +install_app_count_before=$(grep -c 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log") +run_remote install-runtime cbx_test15 >/dev/null +install_app_count_after=$(grep -c 'install-app 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-app.log") +[[ $install_app_count_after -eq $((install_app_count_before + 1)) ]] || { + echo "reinstall did not stage the app after a successful uninstall" >&2 + exit 1 +} +grep -q 'install-runtime-before-reinstall' "$CALLS" +grep -q $'install_runtime_status\tpassed' "$manifest" +run_remote install-fixture cbx_test15 >/dev/null +grep -q 'install-fixture 15 cbx_test15' "$ROOT/KeyPathInstallerLab/logs/cbx_test15/install-fixture.log" +tart_reboot=$(KEYPATH_LAB_TART_REBOOT_SETTLE_SECONDS=0 KEYPATH_LAB_TART_REBOOT_POLL_SECONDS=0 run_remote reboot-guest cbx_test15) +assert_contains "$tart_reboot" $'guest_reboot\tpassed' +assert_contains "$tart_reboot" $'provider\ttart' +grep -q 'launcher15 run cbx_test15 -- /bin/zsh -lc sudo -n /sbin/shutdown -r now' "$CALLS" +grep -q $'guest_reboot_at\t' "$manifest" artifacts=$(run_remote artifacts cbx_test15) assert_contains "$artifacts" $'download_status\t0' @@ -435,6 +611,14 @@ assert_contains "$managed_busy_output" $'managed_identity_busy\tactive_lease=cbx assert_contains "$managed_busy_output" $'scope=shared:26262626-2626-2626-2626-262626262626' run_remote destroy cbx_test26 >/dev/null +managed_desktop_create=$(run_remote create 26 managed-functional "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) +assert_contains "$managed_desktop_create" $'lease_id\tcbx_desktop26' +managed_desktop_manifest="$ROOT/KeyPathInstallerLab/leases/cbx_desktop26/manifest.tsv" +grep -q $'base_name\tkeypath-macos-26-managed' "$managed_desktop_manifest" +grep -q $'desktop_enabled\ttrue' "$managed_desktop_manifest" +grep -q -- '--parallels-template keypath-macos-26-managed' "$CALLS" +run_remote destroy cbx_desktop26 >/dev/null + set +e lock_output=$(KEYPATH_LAB_ADMISSION_WAIT_ATTEMPTS=1 run_remote create 15 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 0 2>&1) lock_exit=$? @@ -555,8 +739,18 @@ assert_contains "$artifacts26" $'download_status\t0' grep -q 'crabbox run --provider parallels --target macos --id cbx_test26 --stop-after never --download' "$CALLS" run_remote destroy cbx_test26 >/dev/null +desktop26_create=$(run_remote create 26 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) +assert_contains "$desktop26_create" $'lease_id\tcbx_desktop26' +grep -q -- '--parallels-template keypath-macos-26-desktop' "$CALLS" +grep -q $'base_name\tkeypath-macos-26-desktop' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop26/manifest.tsv" +inherited_console26=$(run_remote verify-console-login cbx_desktop26) +assert_contains "$inherited_console26" $'console_login\tpassed' +assert_contains "$inherited_console26" $'console_login_method\tinherited-base' +run_remote destroy cbx_desktop26 >/dev/null + create27=$(run_remote create 27 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 0) assert_contains "$create27" $'lease_id\tcbx_test27' +grep -q $'base_name\tkeypath-macos-27' "$ROOT/KeyPathInstallerLab/leases/cbx_test27/manifest.tsv" artifacts27=$(run_remote artifacts cbx_test27) assert_contains "$artifacts27" $'download_status\t0' grep -q 'crabbox run --provider parallels --target macos --id cbx_test27 --stop-after never --download' "$CALLS" @@ -565,6 +759,20 @@ grep -q 'stop-27 cbx_test27' "$CALLS" desktop27_create=$(run_remote create 27 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) assert_contains "$desktop27_create" $'lease_id\tcbx_desktop27' +grep -q -- '--parallels-template keypath-macos-27-desktop' "$CALLS" +grep -q $'base_name\tkeypath-macos-27-desktop' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" +set +e +inherited_console_failure=$(KEYPATH_LAB_TEST_CONSOLE_USER_FAIL=1 run_remote verify-console-login cbx_desktop27 2>&1) +inherited_console_failure_status=$? +set -e +[[ $inherited_console_failure_status -ne 0 ]] +assert_contains "$inherited_console_failure" 'fresh desktop-base clone did not inherit the keypathqa console session' +grep -q $'console_login_status\tpostcondition-failed' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" +inherited_console=$(run_remote verify-console-login cbx_desktop27) +assert_contains "$inherited_console" $'console_login\tpassed' +assert_contains "$inherited_console" $'console_login_method\tinherited-base' +assert_contains "$inherited_console" $'console_user\tkeypathqa' +grep -q $'console_login_method\tinherited-base' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" test_known_hosts="$TMP/known hosts/known_hosts" mkdir -p "$(dirname "$test_known_hosts")" touch "$test_known_hosts" @@ -576,15 +784,35 @@ assert_contains "$console_login" $'console_user\tkeypathqa' escaped_test_known_hosts=${test_known_hosts// /\\ } grep -Fq "UserKnownHostsFile=$escaped_test_known_hosts" "$TMP/guest-ssh-args" secure_console_submit=$(KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS=0 KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS=0 run_remote secure-console-submit cbx_desktop27) -assert_contains "$secure_console_submit" $'secure_console_submit\tpassed' -assert_contains "$secure_console_submit" $'credential_transport\tparallels-key-events' +assert_contains "$secure_console_submit" $'secure_console_submit\tdelivered' +assert_contains "$secure_console_submit" $'credential_field\treplaced-focused-value' +assert_contains "$secure_console_submit" $'credential_transport\tparallels-explicit-pairs-paced' +assert_contains "$secure_console_submit" $'credential_postcondition\tunverified' python3 -c 'import json,sys codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} events=[json.loads(line) for line in open(sys.argv[1]) if line.strip()] -expected=[[{"key":codes[ch]}] for ch in open(sys.argv[2]).read()] +expected=[ + [ + {"key":115,"event":"press","delay":0}, + {"key":38,"event":"press","delay":0}, + {"key":38,"event":"release","delay":0}, + {"key":115,"event":"release","delay":0}, + {"key":22,"event":"press","delay":0}, + {"key":22,"event":"release","delay":0}, + ] +] +for ch in open(sys.argv[2]).read(): + expected.append([{"key":codes[ch],"event":"press","delay":0},{"key":codes[ch],"event":"release","delay":0}]) assert events == expected' "$TMP/secure-console-key-events.jsonl" "$TMP/secure-input" ! grep -Fq 'fixture-password-that-must-not-leak' "$TMP/secure-console-key-events.jsonl" grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 36' "$CALLS" +console_key=$(run_remote console-key cbx_desktop27 73 37) +assert_contains "$console_key" $'console_key\tpassed' +assert_contains "$console_key" $'parallels_key_code\t73' +assert_contains "$console_key" $'parallels_modifier_code\t37' +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 37 --event press' "$CALLS" +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 73' "$CALLS" +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 37 --event release' "$CALLS" if grep -R -F 'fixture-password-that-must-not-leak' "$ROOT/KeyPathInstallerLab" "$CALLS" "$TMP/guest-ssh-args"; then echo "secure console submit leaked its secret into controller logs or arguments" >&2 exit 1 @@ -598,7 +826,7 @@ secure_console_rejected_status=$? set -e mv "$TMP/secure-input.valid" "$TMP/secure-input" [[ $secure_console_rejected_status -ne 0 ]] || { echo "unsupported secure console credential unexpectedly passed" >&2; exit 1; } -assert_contains "$secure_console_rejected" 'failed to deliver the guest credential through Parallels key events' +assert_contains "$secure_console_rejected" 'failed to encode the guest credential as Parallels key events' if tail -n "+$((secure_console_calls_before + 1))" "$CALLS" | grep -q 'send-key-event'; then echo "unsupported secure console credential sent a key event" >&2 exit 1 @@ -693,7 +921,7 @@ assert_contains "$invalid_resource_output" 'invalid Parallels resource id' [[ $(grep -c '^prlctl capture ' "$CALLS") -eq $prlctl_calls_before ]] run_remote destroy cbx_desktop26 >/dev/null -desktop_create=$(run_remote create 15 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) +desktop_create=$(run_remote create 15 managed-functional "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1) assert_contains "$desktop_create" $'lease_id\tcbx_desktop15' grep -q $'status\tprovisioning' "$ROOT/KeyPathInstallerLab/leases/cbx_stale/manifest.tsv" run_remote destroy cbx_stale >/dev/null @@ -764,21 +992,34 @@ if grep -R -F 'fixture-password-that-must-not-leak' "$ROOT/KeyPathInstallerLab" fi secure_agent_result=$(run_remote secure-dialog-input cbx_desktop15 SecurityAgent AXSecureTextField Allow 0) assert_contains "$secure_agent_result" $'secure_dialog_input\tpassed' -grep -q 'keypath-secure-input' "$TMP/guest-ssh-args" -grep -q 'button.*position.*size' "$TMP/guest-ssh-args" -grep -q 'peekaboo.*click.*--coords.*button_coords.*--global-coords' "$TMP/guest-ssh-args" -grep -q -- '--foreground.*--input-strategy.*synthOnly' "$TMP/guest-ssh-args" -grep -q 'SecurityAgent.*closed' "$TMP/guest-ssh-args" -if grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard' "$TMP/guest-ssh-args"; then +assert_contains "$secure_agent_result" $'secure_dialog_focus_transport\taccessibility-and-native-pointer' +assert_contains "$secure_agent_result" $'secure_dialog_submit_transport\tnative-pointer' +grep -Fq 'field.focused' "$TMP/guest-ssh-calls" +grep -Fq 'button.position' "$TMP/guest-ssh-calls" +grep -Fq 'closed' "$TMP/guest-ssh-calls" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 400 --y 300' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 400 --y 220' "$CALLS" +grep -Fq 'fileHandleWithStandardInput' "$TMP/guest-ssh-calls" +cmp -s "$TMP/secure-input" "$TMP/system-settings-secure-stdin" +if grep -q -- '--text' "$CALLS" || grep -q '/usr/bin/sudo\|pbcopy\|the\\ clipboard' "$TMP/guest-ssh-calls"; then echo "SecurityAgent secure input used an unsafe password path" >&2 exit 1 fi +secure_agent_fallback=$(KEYPATH_LAB_TEST_CRABBOX_CLICK_FAIL=1 run_remote secure-dialog-input cbx_desktop15 SecurityAgent AXSecureTextField Enroll 0) +assert_contains "$secure_agent_fallback" $'secure_dialog_input\tpassed' +assert_contains "$secure_agent_fallback" $'secure_dialog_focus_transport\taccessibility-only' +assert_contains "$secure_agent_fallback" $'secure_dialog_submit_transport\taccessibility-press-fallback' +grep -Fq 'AXPress' "$TMP/guest-ssh-calls" secure_settings_result=$(run_remote secure-dialog-input cbx_desktop15 'System Settings' AXSecureTextField 'Modify Settings' 0) assert_contains "$secure_settings_result" $'secure_dialog_input\tpassed' -grep -q 'processes.byName.*appName' "$TMP/guest-ssh-args" -grep -q 'System.*Settings.*Modify.*Settings' "$TMP/guest-ssh-args" -grep -q 'AXSecureTextField' "$TMP/guest-ssh-args" -grep -q 'return.*open.*closed' "$TMP/guest-ssh-args" +grep -q 'System.*Settings.*Modify.*Settings' "$TMP/guest-ssh-calls" +grep -q 'AXSecureTextField' "$TMP/guest-ssh-calls" +grep -q 'fileHandleWithStandardInput' "$TMP/guest-ssh-calls" +cmp -s "$TMP/secure-input" "$TMP/system-settings-secure-stdin" +if grep -Fq 'fixture-password-that-must-not-leak' "$TMP/guest-ssh-calls"; then + echo "System Settings secure input leaked its secret into guest arguments" >&2 + exit 1 +fi secure_focused_result=$(run_remote secure-dialog-input cbx_desktop15 SecurityAgent Password '' 1) assert_contains "$secure_focused_result" $'secure_dialog_input\tpassed' if grep -q 'peekaboo.*see\|peekaboo.*click' "$TMP/guest-ssh-args"; then @@ -800,9 +1041,29 @@ protected_wrong_page_exit=$? set -e [[ $protected_wrong_page_exit -ne 0 ]] || { echo "protected click accepted the wrong destination page" >&2; exit 1; } assert_contains "$protected_wrong_page" "protected click postcondition failed" +set +e +protected_occluded=$(KEYPATH_LAB_TEST_OCCLUSION='NotificationCenter:700,0,324,140' KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility native 804 120 2>&1) +protected_occluded_exit=$? +set -e +[[ $protected_occluded_exit -ne 0 ]] || { echo "protected click accepted a notification-occluded target" >&2; exit 1; } +assert_contains "$protected_occluded" "protected click target is occluded by a notification" +set +e +protected_background=$(KEYPATH_LAB_TEST_FRONTMOST_BEFORE=false KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility native 402 247 2>&1) +protected_background_exit=$? +set -e +[[ $protected_background_exit -ne 0 ]] || { echo "protected click accepted a background application" >&2; exit 1; } +assert_contains "$protected_background" "is not frontmost" protected_ax_result=$(KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote protected-click cbx_desktop15 'System Settings' Accessibility Accessibility ax 402 247) assert_contains "$protected_ax_result" $'display_scale\t2' grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 494' "$CALLS" +approval_result=$(KEYPATH_LAB_PROTECTED_CLICK_SETTLE_SECONDS=0 run_remote approve-input-monitoring cbx_desktop15) +assert_contains "$approval_result" $'input_monitoring_row\tKanata Engine\tenabled' +assert_contains "$approval_result" $'input_monitoring_row\tkanata-launcher\tenabled' +assert_contains "$approval_result" $'input_monitoring_row\tKeyPath\tenabled' +assert_contains "$approval_result" $'approve_input_monitoring\tpassed' +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 494' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 578' "$CALLS" +grep -q 'crabbox desktop click --provider tart --target macos --id test-resource --x 804 --y 662' "$CALLS" run_remote desktop-type cbx_desktop15 q >/dev/null grep -q 'crabbox desktop type --provider tart --target macos --id test-resource --text q' "$CALLS" run_remote destroy cbx_desktop15 >/dev/null @@ -840,16 +1101,48 @@ run_remote cleanup >/dev/null mkdir -p "$TMP/fake-bin" cat > "$TMP/fake-bin/ssh" < "$TMP/ssh-args" +echo "\$*" >> "$TMP/ssh-args" cat >/dev/null -echo controller-preflight +case "\$*" in + *prepare-upload*) echo /tmp/keypath-lab.test-upload ;; + *install-archive*) echo archive-installed ;; + *" create "*) echo \$'lease_id\tcbx_controller_test' ;; + *" artifacts "*) echo \$'artifact_dir\t/Volumes/KeyPath Lab/CrabBox/KeyPathInstallerLab/artifacts/cbx_controller_test/20260725T000000Z' ;; + *) echo controller-preflight ;; +esac +EOF +cat > "$TMP/fake-bin/scp" <> "$TMP/scp-args" +exit 0 EOF -chmod +x "$TMP/fake-bin/ssh" +chmod +x "$TMP/fake-bin/ssh" "$TMP/fake-bin/scp" controller=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host "$LAB_DIR/keypath-lab" preflight) assert_contains "$controller" controller-preflight grep -q 'tester@test-host' "$TMP/ssh-args" echo fake-installer > "$TMP/KeyPath.zip" +echo older-installer > "$TMP/KeyPath-beta3.zip" +controller_commit=$(git -C "$LAB_DIR/../.." rev-parse HEAD) +controller_installer_sha=$(shasum -a 256 "$TMP/KeyPath.zip" | awk '{print $1}') +controller_fixture_sha=$(shasum -a 256 "$TMP/KeyPath-beta3.zip" | awk '{print $1}') +controller_archive_key="${controller_commit}-${controller_installer_sha}-h${controller_commit}-${controller_fixture_sha}" +controller_create=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host KEYPATH_LAB_ALLOW_DIRTY_HARNESS=1 "$LAB_DIR/keypath-lab" create \ + --macos 15 \ + --lane unmanaged-ui \ + --commit "$controller_commit" \ + --installer "$TMP/KeyPath.zip" \ + --fixture "$TMP/KeyPath-beta3.zip") +assert_contains "$controller_create" $'lease_id\tcbx_controller_test' +grep -Fq "$controller_archive_key" "$TMP/ssh-args" || { + echo "controller archive key did not include harness commit and fixture checksum" >&2 + exit 1 +} +controller_artifacts=$(PATH="$TMP/fake-bin:$PATH" KEYPATH_LAB_HOST=tester@test-host "$LAB_DIR/keypath-lab" artifacts \ + cbx_controller_test --output "$TMP/controller-artifacts") +assert_contains "$controller_artifacts" $'local_artifact_dir\t' +[[ -d "$TMP/controller-artifacts" ]] +grep -Fq 'tester@test-host:/Volumes/KeyPath Lab/CrabBox/KeyPathInstallerLab/artifacts/cbx_controller_test/20260725T000000Z/.' "$TMP/scp-args" if PATH="$TMP/fake-bin:$PATH" "$LAB_DIR/keypath-lab" create --macos 15 --lane unmanaged-ui --commit abc --installer "$TMP/KeyPath.zip" >/dev/null 2>&1; then echo "controller accepted a non-explicit commit SHA" >&2 exit 1 diff --git a/Scripts/lab/tests/macos-26-selector-driver-tests.py b/Scripts/lab/tests/macos-26-selector-driver-tests.py index 14cb75f2c..571c9ca8d 100755 --- a/Scripts/lab/tests/macos-26-selector-driver-tests.py +++ b/Scripts/lab/tests/macos-26-selector-driver-tests.py @@ -28,6 +28,9 @@ def test_accepts_fresh_macos_26_selector_evidence(self): result = self.call("--expect", "InputMonitoring", "--expect", "KeyPath") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads((self.output / "selector-contract.json").read_text())["macOSMajor"], 26) + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["status"], "passed") + self.assertEqual(outcome["evidence"], ["system-settings-ax.json", "selector-contract.json"]) def test_rejects_another_macos_version_without_snapshot(self): result = self.call("--expect", "KeyPath", version="15.7.7") diff --git a/Scripts/lab/tests/macos-26-selector-scenario-tests.py b/Scripts/lab/tests/macos-26-selector-scenario-tests.py new file mode 100755 index 000000000..1c516a24e --- /dev/null +++ b/Scripts/lab/tests/macos-26-selector-scenario-tests.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-26-selector-scenario" +DRIVER = pathlib.Path(__file__).resolve().parents[1] / "macos-26-selector-driver" + + +class ScenarioTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.output = self.root / "out" + self.calls = self.root / "calls" + self.bin = self.root / "bin" + self.bin.mkdir() + for name in ("open", "osascript", "sleep"): + path = self.bin / name + path.write_text(f'#!/bin/sh\nprintf "%s\\n" "{name} $*" >> "$CALLS"\n') + path.chmod(0o755) + self.peekaboo = self.bin / "peekaboo" + self.peekaboo.write_text( + '#!/bin/sh\nout=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' + 'printf \'%s\\n\' \'{"identifier":"com.apple.Accessibility-Settings.extension","label":"Allow the applications below to control your computer.","row":"peekaboo_Title"}\' > "$out"\n' + ) + self.peekaboo.chmod(0o755) + self.driver = self.bin / "driver" + self.driver.write_text('#!/bin/sh\nprintf "%s\\n" "driver $*" >> "$CALLS"\n') + self.driver.chmod(0o755) + self.sw_vers = self.bin / "sw_vers" + self.sw_vers.write_text('#!/bin/sh\n[ "$1" = -productVersion ] && echo 26.5.2 || echo "ProductName: macOS"\n') + self.sw_vers.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def test_prepares_accessibility_pane_and_uses_macos26_contract(self): + env = os.environ | { + "CALLS": str(self.calls), + "KEYPATH_SELECTOR_DRIVER": str(self.driver), + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_OPEN": str(self.bin / "open"), + "KEYPATH_SELECTOR_OSASCRIPT": str(self.bin / "osascript"), + "KEYPATH_SELECTOR_SLEEP": str(self.bin / "sleep"), + } + result = subprocess.run([str(TOOL), "--output", str(self.output)], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.calls.read_text() + self.assertIn("Privacy_Accessibility", calls) + self.assertIn("--expect-any com.apple.settings.accessibility|com.apple.Accessibility-Settings.extension", calls) + self.assertIn("--expect Allow the applications below to control your computer.", calls) + self.assertIn("--expect-any Peekaboo Lab Host|peekaboo_Title", calls) + self.assertTrue((self.output / "accessibility-readiness.json").exists()) + + def test_driver_accepts_observed_semantic_selector_variants(self): + env = os.environ | { + "PATH": f"{self.bin}:{os.environ['PATH']}", + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_RESULT": "/usr/bin/true", + } + result = subprocess.run([ + str(DRIVER), "--output", str(self.output), + "--expect-any", "com.apple.settings.accessibility|com.apple.Accessibility-Settings.extension", + "--expect", "Allow the applications below to control your computer.", + "--expect-any", "Peekaboo Lab Host|peekaboo_Title", + ], env=env, text=True, capture_output=True) + + self.assertEqual(result.returncode, 0, result.stderr) + contract = json.loads((self.output / "selector-contract.json").read_text()) + self.assertEqual(contract["expectedAny"][0][1], "com.apple.Accessibility-Settings.extension") + self.assertEqual(contract["expectedAny"][1][1], "peekaboo_Title") + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/macos-27-selector-driver-tests.py b/Scripts/lab/tests/macos-27-selector-driver-tests.py new file mode 100755 index 000000000..7330e9ba8 --- /dev/null +++ b/Scripts/lab/tests/macos-27-selector-driver-tests.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-27-selector-driver" + + +class DriverTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.output = self.root / "out" + (self.bin / "sw_vers").write_text( + '#!/bin/sh\nversion=${MACOS_TEST_VERSION:-27.0}\n' + 'if [ "$1" = -productVersion ]; then echo "$version"; ' + 'elif [ "$1" = -buildVersion ]; then echo 26A5378j; ' + 'else echo "ProductVersion: $version"; echo "BuildVersion: 26A5378j"; fi\n' + ) + (self.bin / "sw_vers").chmod(0o755) + self.peekaboo = self.root / "peekaboo-ui" + self.peekaboo.write_text( + '#!/bin/sh\n' + 'if [ "$1" = preflight ]; then\n' + ' if [ -n "${PEEKABOO_TEST_PREFLIGHT:-}" ]; then printf \'%s\\n\' "$PEEKABOO_TEST_PREFLIGHT"; ' + ' else printf \'%s\\n\' \'{"data":{"permissions":[{"name":"Screen Recording","isRequired":true,"isGranted":true},{"name":"Accessibility","isRequired":true,"isGranted":true}]}}\'; fi\n' + ' exit 0\n' + 'fi\n' + 'out=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' + 'printf \'{"data":{"ui_elements":[{"identifier":"Privacy_Accessibility","label":"Accessibility"}]}}\' > "$out"\n' + ) + self.peekaboo.chmod(0o755) + self.result = self.root / "scenario-result" + self.result.write_text((TOOL.parent / "scenario-result").read_text()) + self.result.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def call(self, *extra, version="27.0", preflight=None): + env = os.environ | { + "PATH": str(self.bin) + ":" + os.environ["PATH"], + "MACOS_TEST_VERSION": version, + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_RESULT": str(self.result), + } + if preflight is not None: + env["PEEKABOO_TEST_PREFLIGHT"] = json.dumps(preflight, separators=(",", ":")) + return subprocess.run( + [str(TOOL), "--output", str(self.output), *extra], + env=env, + text=True, + capture_output=True, + ) + + def test_accepts_fresh_macos_27_selector_evidence(self): + result = self.call("--expect", "Privacy_Accessibility", "--expect", "Accessibility") + self.assertEqual(result.returncode, 0, result.stderr) + contract = json.loads((self.output / "selector-contract.json").read_text()) + self.assertEqual(contract["macOSMajor"], 27) + self.assertEqual(contract["macOSBuild"], "26A5378j") + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["status"], "passed") + self.assertEqual(outcome["evidence"], [ + "system-settings-ax.json", "selector-contract.json", "peekaboo-preflight.json" + ]) + + def test_requires_at_least_one_selector(self): + result = self.call() + self.assertEqual(result.returncode, 2) + + def test_rejects_another_macos_version_without_preflight(self): + result = self.call("--expect", "Accessibility", version="26.6") + self.assertEqual(result.returncode, 4) + self.assertEqual(json.loads((self.output / "result.json").read_text())["failure"]["classification"], "unsupported-os-selector") + self.assertFalse((self.output / "peekaboo-preflight.json").exists()) + + def test_missing_permission_is_environment_precondition_failure(self): + result = self.call("--expect", "Accessibility", preflight={ + "data": {"permissions": [ + {"name": "Screen Recording", "isRequired": True, "isGranted": False} + ]} + }) + self.assertEqual(result.returncode, 4) + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["failure"]["classification"], "environment-precondition-failure") + self.assertIn("Screen Recording", outcome["failure"]["message"]) + + def test_missing_selector_is_explicitly_unsupported(self): + result = self.call("--expect", "Missing Control") + self.assertEqual(result.returncode, 4) + outcome = json.loads((self.output / "result.json").read_text()) + self.assertEqual(outcome["failure"]["classification"], "unsupported-os-selector") + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/macos-27-selector-scenario-tests.py b/Scripts/lab/tests/macos-27-selector-scenario-tests.py new file mode 100755 index 000000000..a642b9e9b --- /dev/null +++ b/Scripts/lab/tests/macos-27-selector-scenario-tests.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import os +import pathlib +import subprocess +import tempfile +import unittest + + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "macos-27-selector-scenario" + + +class ScenarioTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.output = self.root / "out" + self.calls = self.root / "calls" + self.bin = self.root / "bin" + self.bin.mkdir() + for name in ("open", "osascript", "sleep"): + path = self.bin / name + path.write_text(f'#!/bin/sh\nprintf "%s\\n" "{name} $*" >> "$CALLS"\n') + path.chmod(0o755) + self.peekaboo = self.bin / "peekaboo" + self.peekaboo.write_text( + '#!/bin/sh\nout=\nwhile [ $# -gt 0 ]; do [ "$1" = --output ] && out=$2; shift; done\n' + 'printf \'%s\\n\' \'{"identifier":"com.apple.settings.accessibility","label":"Allow the applications below to control your computer.","row":"Peekaboo Lab Host"}\' > "$out"\n' + ) + self.peekaboo.chmod(0o755) + self.driver = self.bin / "driver" + self.driver.write_text('#!/bin/sh\nprintf "%s\\n" "driver $*" >> "$CALLS"\n') + self.driver.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def test_prepares_accessibility_pane_and_uses_semantic_contract(self): + env = os.environ | { + "CALLS": str(self.calls), + "KEYPATH_SELECTOR_DRIVER": str(self.driver), + "KEYPATH_SELECTOR_PEEKABOO": str(self.peekaboo), + "KEYPATH_SELECTOR_OPEN": str(self.bin / "open"), + "KEYPATH_SELECTOR_OSASCRIPT": str(self.bin / "osascript"), + "KEYPATH_SELECTOR_SLEEP": str(self.bin / "sleep"), + } + result = subprocess.run([str(TOOL), "--output", str(self.output)], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.calls.read_text() + self.assertIn("Privacy_Accessibility", calls) + self.assertIn("--expect com.apple.settings.accessibility", calls) + self.assertIn("--expect Allow the applications below to control your computer.", calls) + self.assertIn("--expect Peekaboo Lab Host", calls) + self.assertTrue((self.output / "accessibility-readiness.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-hid-capture-run-tests.py b/Scripts/lab/tests/physical-hid-capture-run-tests.py new file mode 100644 index 000000000..6d3a00f6b --- /dev/null +++ b/Scripts/lab/tests/physical-hid-capture-run-tests.py @@ -0,0 +1,718 @@ +#!/usr/bin/env python3 +"""Contract tests for the combined physical HID capture runner.""" + +from __future__ import annotations + +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import pathlib +import tempfile +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +RUNNER = ROOT / "Scripts/lab/physical-hid-capture-run" +SHOWROOM_TEXT = ROOT / "Scripts/lab/tests/fixtures/showroom-demo.txt" + +READY_PREFLIGHT = { + "canProceed": True, + "state": "ready", + "detail": "Resources stable", + "suggestions": [], +} + +BUSY_PREFLIGHT = { + "canProceed": False, + "state": "waiting", + "detail": "CPU 93% (limit 80%)", + "suggestions": ["Pause builds or VMs."], +} + +TRACE_BATCH = [ + {"runId": "test-run", "from": 0, "available": 2}, + {"sequence": 1, "modifiers": 0, "keys": [4, 0, 0, 0, 0, 0]}, + {"sequence": 2, "modifiers": 0, "keys": [0, 0, 0, 0, 0, 0]}, +] + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader("physical_hid_capture_run", str(RUNNER)) + spec = importlib.util.spec_from_loader(loader.name, loader) + assert spec is not None + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +def action(command: list[str]) -> tuple[str, str]: + if "hid-capture-jig-client" in command[0]: + return "capture", command[1] + fixture_actions = { + "status", "load-script", "arm", "start", "abort", "trace", "trace-all", "present", + } + return "fixture", next(value for value in command[1:] if value in fixture_actions) + + +class PhysicalHIDCaptureRunTests(unittest.TestCase): + def setUp(self) -> None: + self.runner = load_runner() + + def test_showroom_payload_is_short_fixed_and_return_terminated(self) -> None: + self.assertEqual(SHOWROOM_TEXT.read_bytes(), b"KeyPath demo OK\n") + + def test_presentation_brand_defaults_to_keypath_and_accepts_bear(self) -> None: + required = ["--run-id", "brand-test", "--text", str(RUNNER)] + self.assertEqual(self.runner.parser().parse_args(required).brand, "keypath") + self.assertEqual( + self.runner.parser().parse_args([*required, "--brand", "bear"]).brand, + "bear", + ) + + @staticmethod + def compile_script(command: list[str], environment: dict[str, str]) -> None: + del environment + output = pathlib.Path(command[command.index("--output") + 1]) + output.write_text("KPHID1 test-run 2 1 100000 00000000\n0 0 4 0 0 0 0 0\n50000 0 0 0 0 0 0 0\n") + + def test_demo_mode_records_busy_preflight_then_runs_with_explicit_bypass(self) -> None: + calls: list[tuple[str, str]] = [] + fixture_status_calls = 0 + capture_status_calls = 0 + capture_arm_command: list[str] | None = None + presentation_commands: list[list[str]] = [] + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls, capture_status_calls, capture_arm_command + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "present"): + presentation_commands.append(command) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + if fixture_status_calls == 2: + raise RuntimeError("transient fixture status timeout") + return { + "state": "complete", "reportsSubmitted": 2, + "lateReports": 1, "maximumLatenessUs": 2_000, + } + if (target, operation) == ("capture", "status"): + capture_status_calls += 1 + if capture_status_calls > 1: + return {"ok": True, "snapshot": { + "state": "passed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "issues": [], "events": [{}, {}], + }} + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": BUSY_PREFLIGHT, + } + if (target, operation) == ("capture", "arm"): + capture_arm_command = command + return {"ok": True} + if (target, operation) == ("capture", "wait"): + return {"snapshot": { + "state": "passed", "received": "a", "pressedKeyCodes": [], + "issues": [], "events": [{}, {}], + }} + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "test-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + "--demo-mode", + "--max-late-reports", "2", "--max-lateness-us", "3000", + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + + self.assertLess(calls.index(("capture", "status")), calls.index(("fixture", "load-script"))) + self.assertEqual( + calls.index(("capture", "focus")) + 1, + calls.index(("capture", "arm")), + ) + self.assertIsNotNone(capture_arm_command) + assert capture_arm_command is not None + self.assertIn("--demo-mode", capture_arm_command) + self.assertNotIn("--instruction", capture_arm_command) + self.assertEqual(capture_arm_command[capture_arm_command.index("--timeout-ms") + 1], "90000") + self.assertEqual(len(presentation_commands), 2) + self.assertTrue(all( + command[command.index("--brand") + 1] == "keypath" + for command in presentation_commands + )) + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["admissionMode"], "demo-bypass") + self.assertEqual(artifact["presentationBrand"], "keypath") + self.assertFalse(artifact["control"]["capturePreflight"]["systemReadiness"]["canProceed"]) + self.assertEqual(artifact["control"]["capturePreflight"]["snapshot"]["state"], "idle") + self.assertTrue(artifact["checks"]["latenessWithinBudget"]) + self.assertEqual(artifact["timingBudget"]["maxLateReports"], 2) + self.assertTrue(artifact["controlPlane"]["degraded"]) + self.assertIn("status timeout", artifact["controlPlane"]["errors"][0]["error"]) + + def test_busy_capture_preflight_stops_before_fixture_mutation(self) -> None: + calls: list[tuple[str, str]] = [] + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "status"): + return {"address": "10.0.0.47", "state": "idle"} + if (target, operation) == ("capture", "status"): + return { + "ok": True, + "snapshot": {"state": "idle"}, + "systemReadiness": { + "canProceed": False, + "detail": "CPU 93% (limit 80%)", + "suggestions": ["Pause builds or VMs."], + }, + } + raise AssertionError(f"unexpected mutation: {target} {operation}") + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "busy-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + ] + error = io.StringIO() + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error): + self.assertEqual(self.runner.main(), 2) + + artifact = json.loads(output_path.read_text()) + + self.assertIn("CPU 93%", error.getvalue()) + self.assertIn("Pause builds or VMs", error.getvalue()) + self.assertEqual(artifact["admissionMode"], "strict") + self.assertEqual(artifact["failure"]["classification"], "host-resource-admission") + self.assertNotIn(("fixture", "load-script"), calls) + self.assertNotIn(("fixture", "arm"), calls) + + def test_swift_compile_load_starts_after_confirmed_fixture_schedule_and_is_recorded(self) -> None: + timeline: list[tuple[str, str]] = [] + fixture_status_calls = 0 + capture_status_calls = 0 + + def snapshot(): + return { + "state": "passed", "received": "a", "focused": True, + "pressedKeyCodes": [], "activeModifiers": 0, "issues": [], + "events": [{}, {}], + } + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls, capture_status_calls + target, operation = action(command) + timeline.append((target, operation)) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return { + "state": "complete", "reportsSubmitted": 2, + "transfersCompleted": 2, "lateReports": 0, + "maximumLatenessUs": 0, + } + if (target, operation) == ("capture", "status"): + capture_status_calls += 1 + if capture_status_calls == 1: + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + return {"ok": True, "snapshot": snapshot()} + if (target, operation) == ("capture", "wait"): + return {"snapshot": snapshot()} + return {"ok": True} + + class FakeSwiftCompileLoad: + def start(self): + timeline.append(("load", "swift-start")) + + def stop(self): + timeline.append(("load", "swift-stop")) + return {"enabled": True, "sourceBytes": 12345, "durationMs": 500} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "swift-load-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--swift-compile-load", + "--output", str(output_path), + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner, "ControlledSwiftCompileLoad", FakeSwiftCompileLoad), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + + artifact = json.loads(output_path.read_text()) + + self.assertLess(timeline.index(("capture", "arm")), timeline.index(("load", "swift-start"))) + self.assertLess(timeline.index(("fixture", "start")), timeline.index(("load", "swift-start"))) + self.assertEqual(artifact["swiftCompileLoad"]["sourceBytes"], 12345) + self.assertEqual(artifact["scheduling"]["effectiveDelayMs"], 10000) + self.assertTrue(artifact["scheduling"]["loadStartedAfterStartConfirmation"]) + self.assertEqual(timeline.count(("load", "swift-stop")), 1) + + def test_swift_compile_cleanup_does_not_signal_an_exited_process_group(self) -> None: + load = self.runner.ControlledSwiftCompileLoad() + process = mock.Mock(pid=4242) + process.poll.return_value = 1 + load.process = process + + with mock.patch.object(self.runner.os, "killpg") as kill_group: + result = load.stop() + + kill_group.assert_not_called() + process.terminate.assert_not_called() + process.kill.assert_not_called() + self.assertEqual(result["returnCode"], 1) + self.assertIsNone(load.process) + + def test_swift_compile_load_uses_the_selected_macos_sdk(self) -> None: + load = self.runner.ControlledSwiftCompileLoad() + compiler = "/Toolchains/Test/usr/bin/swiftc" + sdk = "/Platforms/Test/MacOSX.sdk" + process = mock.Mock(pid=4242) + process.poll.return_value = None + lookups = [ + mock.Mock(stdout=f"{compiler}\n"), + mock.Mock(stdout=f"{sdk}\n"), + ] + + with mock.patch.object(self.runner.subprocess, "run", side_effect=lookups), \ + mock.patch.object(self.runner.subprocess, "Popen", return_value=process) as popen, \ + mock.patch.object(self.runner.time, "sleep"): + load.start() + + command = popen.call_args.args[0] + self.assertEqual(command[0:2], ["/bin/bash", "-c"]) + self.assertEqual(command[4:6], [compiler, sdk]) + self.assertIn('-sdk "$2"', command[2]) + self.assertEqual(load.sdk, sdk) + load.process = None + load.temporary.cleanup() + load.temporary = None + + def test_trace_collection_allows_the_firmware_wifi_recovery_window(self) -> None: + errors: list[dict] = [] + with mock.patch.object(self.runner, "run_json", return_value=TRACE_BATCH[1:]) as request: + trace = self.runner.fetch_fixture_trace("10.0.0.47", {}, errors) + + self.assertEqual(trace, TRACE_BATCH[1:]) + command = request.call_args.args[0] + self.assertIn("trace-all", command) + self.assertIn("60", command) + + def test_fixture_discovery_allows_the_firmware_wifi_recovery_window(self) -> None: + errors: list[dict] = [] + response = {"address": "10.0.0.47", "state": "idle"} + with mock.patch.object( + self.runner, "run_fixture_json_with_retry", return_value=response + ) as request: + host, status = self.runner.resolve_fixture_host("fixture.local", {}, errors) + + self.assertEqual(host, "10.0.0.47") + self.assertIs(status, response) + self.assertEqual(request.call_args.kwargs["retry_seconds"], 60) + self.assertIs(request.call_args.kwargs["errors"], errors) + + def test_lost_arm_response_is_confirmed_from_fixture_status(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("arm response timed out"), + {"state": "armed", "runId": "case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses): + result = self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "arm", "case", expected_states={"armed"}, + environment={}, retry_seconds=1, errors=errors, + ) + + self.assertTrue(result["confirmedByStatus"]) + self.assertEqual(result["status"]["state"], "armed") + self.assertEqual(errors[0]["action"], "arm") + + def test_lost_load_response_is_confirmed_without_passing_run_id_to_command(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("load response timed out"), + {"state": "loaded", "runId": "case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses) as request: + result = self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "load-script", "case", "/tmp/script.txt", + include_run_id_argument=False, expected_states={"loaded"}, + environment={}, retry_seconds=1, errors=errors, + ) + + self.assertTrue(result["confirmedByStatus"]) + load_command = request.call_args_list[0].args[0] + self.assertIn("load-script", load_command) + self.assertIn("/tmp/script.txt", load_command) + self.assertNotIn("case", load_command) + + def test_lost_abort_response_confirms_global_safe_state(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("abort response timed out"), + {"state": "aborted", "runId": "stale-case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses) as request: + result = self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "abort", "new-case", + include_run_id_argument=False, expected_states={"aborted"}, + environment={}, retry_seconds=1, errors=errors, + require_run_id_match=False, + ) + + self.assertTrue(result["confirmedByStatus"]) + abort_command = request.call_args_list[0].args[0] + self.assertIn("abort", abort_command) + self.assertNotIn("new-case", abort_command) + + def test_state_confirmation_rejects_a_different_run_id(self) -> None: + errors: list[dict] = [] + responses = [ + RuntimeError("start response timed out"), + {"state": "running", "runId": "other-case"}, + ] + with mock.patch.object(self.runner, "run_json", side_effect=responses), \ + mock.patch.object(self.runner.time, "monotonic", side_effect=[0.0, 2.0]): + with self.assertRaisesRegex(RuntimeError, "fixture start unavailable"): + self.runner.run_fixture_state_change_with_confirmation( + "10.0.0.47", "start", "case", "--delay-ms", "800", + expected_states={"running", "complete"}, environment={}, + retry_seconds=1, errors=errors, + ) + + def test_swift_compile_cleanup_falls_back_when_group_signal_is_denied(self) -> None: + load = self.runner.ControlledSwiftCompileLoad() + process = mock.Mock(pid=4242) + process.poll.side_effect = [None, None] + process.wait.return_value = -15 + load.process = process + + with mock.patch.object( + self.runner.os, "killpg", side_effect=PermissionError + ) as kill_group: + result = load.stop() + + kill_group.assert_called_once_with(4242, self.runner.signal.SIGTERM) + process.terminate.assert_called_once_with() + self.assertEqual(result["returnCode"], -15) + self.assertIsNone(load.process) + + def test_failed_capture_waits_for_fixture_and_persists_complete_evidence(self) -> None: + fixture_status_calls = 0 + capture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls, capture_status_calls + target, operation = action(command) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + if fixture_status_calls == 2: + return {"state": "running", "reportsSubmitted": 1} + return { + "state": "complete", "reportsSubmitted": 2, + "lateReports": 0, "maximumLatenessUs": 0, + } + if (target, operation) == ("capture", "status"): + capture_status_calls += 1 + if capture_status_calls == 1: + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + return {"ok": True, "snapshot": { + "state": "failed", "received": "ab", "pressedKeyCodes": [], + "activeModifiers": 0, "issues": ["mismatch"], + "events": [{}, {}, {}, {}], + }} + if (target, operation) == ("capture", "wait"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "issues": ["mismatch"], "events": [{}, {}], + }} + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("aa") + arguments = [ + str(RUNNER), "--run-id", "complete-failure", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.time, "sleep"), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 1) + + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["fixture"]["state"], "complete") + self.assertEqual(artifact["fixture"]["reportsSubmitted"], 2) + self.assertEqual(artifact["capture"]["received"], "ab") + self.assertEqual(len(artifact["capture"]["events"]), 4) + self.assertEqual( + artifact["control"]["captureInitialTerminal"]["snapshot"]["received"], "a" + ) + self.assertTrue(artifact["checks"]["fixtureCompleted"]) + self.assertTrue(artifact["checks"]["allReportsSubmitted"]) + + def test_capture_arm_failure_aborts_mutated_fixture(self) -> None: + calls: list[tuple[str, str]] = [] + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "status"): + return {"address": "10.0.0.47", "state": "idle"} + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "arm"): + raise RuntimeError("capture app exited") + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("a") + arguments = [ + str(RUNNER), "--run-id", "test-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + ] + error = io.StringIO() + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error): + self.assertEqual(self.runner.main(), 2) + + artifact = json.loads(output_path.read_text()) + self.assertIn(("fixture", "abort"), calls) + self.assertIn("fixture aborted with all-keys-released queued", artifact["cleanup"]) + self.assertIn("capture-oracle", error.getvalue()) + + def test_abort_mode_verifies_released_prefix(self) -> None: + calls: list[tuple[str, str]] = [] + fixture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls + target, operation = action(command) + calls.append((target, operation)) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return { + "state": "aborted", "reportsSubmitted": 1, + "transfersCompleted": 2, "lateReports": 0, + "maximumLatenessUs": 0, + } + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "finalize"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "duplicateDownEvents": 0, + "repeatEvents": 0, "unmatchedUpEvents": 0, + "issues": ["received output differs from expected output"], + "events": [{}, {}], + }} + return {"ok": True} + + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("aa") + arguments = [ + str(RUNNER), "--run-id", "abort-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--abort-after-ms", "10", + "--output", str(output_path), + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.time, "sleep"), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["status"], "passed") + self.assertTrue(all(artifact["checks"].values())) + self.assertIn(("fixture", "abort"), calls) + self.assertIn(("capture", "finalize"), calls) + + def test_late_report_allowance_requires_a_lateness_ceiling(self) -> None: + with tempfile.TemporaryDirectory() as directory: + arguments = [ + str(RUNNER), "--run-id", "invalid-budget", "--text", str(RUNNER), + "--max-late-reports", "1", "--output", + str(pathlib.Path(directory) / "result.json"), + ] + error = io.StringIO() + with mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stderr(error), contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 2) + self.assertIn("--max-lateness-us is required", error.getvalue()) + + def test_external_abort_mode_waits_for_physical_abort(self) -> None: + fixture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls + target, operation = action(command) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + return {"state": "aborted", "reportsSubmitted": 1, "transfersCompleted": 2} + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "finalize"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "duplicateDownEvents": 0, + "repeatEvents": 0, "unmatchedUpEvents": 0, "issues": [], "events": [], + }} + if (target, operation) == ("capture", "arm"): + self.assertEqual( + command[command.index("--instruction") + 1], + "PRESS BOOT OR TAP THE SCREEN NOW TO ABORT THE ACTIVE TEST", + ) + return {"ok": True} + + self._run_interruption_case( + fake_run_json, ["--expect-external-abort-ms", "100"], "external-abort" + ) + + def test_usb_cycle_mode_waits_for_release_after_remount(self) -> None: + fixture_status_calls = 0 + + def fake_run_json(command, environment, allowed_returncodes=(0,)): + del environment, allowed_returncodes + nonlocal fixture_status_calls + target, operation = action(command) + if (target, operation) == ("fixture", "trace-all"): + return TRACE_BATCH[1:] + if (target, operation) == ("fixture", "status"): + fixture_status_calls += 1 + if fixture_status_calls == 1: + return {"address": "10.0.0.47", "state": "idle"} + if fixture_status_calls == 2: + return { + "state": "error", "usbMounted": False, + "reportsSubmitted": 1, "transfersCompleted": 1, + } + return { + "state": "error", "usbMounted": True, + "reportsSubmitted": 1, "transfersCompleted": 2, + } + if (target, operation) == ("capture", "status"): + return { + "ok": True, "snapshot": {"state": "idle"}, + "systemReadiness": READY_PREFLIGHT, + } + if (target, operation) == ("capture", "finalize"): + return {"snapshot": { + "state": "failed", "received": "a", "pressedKeyCodes": [], + "activeModifiers": 0, "duplicateDownEvents": 0, + "repeatEvents": 0, "unmatchedUpEvents": 0, "issues": [], "events": [], + }} + if (target, operation) == ("capture", "arm"): + self.assertEqual( + command[command.index("--instruction") + 1], + "UNPLUG USB-C NOW · WAIT 2 SECONDS · RECONNECT THE SAME CABLE", + ) + return {"ok": True} + + self._run_interruption_case( + fake_run_json, ["--expect-usb-cycle-ms", "100"], "usb-cycle" + ) + + def _run_interruption_case(self, fake_run_json, mode_arguments, expected_mode) -> None: + with tempfile.TemporaryDirectory() as directory: + input_path = pathlib.Path(directory) / "input.txt" + output_path = pathlib.Path(directory) / "result.json" + input_path.write_text("aa") + arguments = [ + str(RUNNER), "--run-id", "manual-run", "--text", str(input_path), + "--fixture-host", "fixture.local", "--output", str(output_path), + *mode_arguments, + ] + with mock.patch.object(self.runner, "load_fixture_token", return_value="token"), \ + mock.patch.object(self.runner, "run_json", side_effect=fake_run_json), \ + mock.patch.object(self.runner, "run_checked", side_effect=self.compile_script), \ + mock.patch.object(self.runner.time, "sleep"), \ + mock.patch.object(self.runner.sys, "argv", arguments), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.runner.main(), 0) + artifact = json.loads(output_path.read_text()) + self.assertEqual(artifact["status"], "passed") + self.assertEqual(artifact["interruptionMode"], expected_mode) + self.assertTrue(all(artifact["checks"].values())) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py b/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py new file mode 100644 index 000000000..9c1f22df0 --- /dev/null +++ b/Scripts/lab/tests/physical-hid-repeat-matrix-tests.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Contract tests for the strict repeat/duplicate physical-HID matrix.""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "Scripts/lab/physical-hid-repeat-matrix" +LOADER = importlib.machinery.SourceFileLoader("physical_hid_repeat_matrix", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def artifact(expected: str, received: str, *, focused: bool = True) -> dict: + return { + "runID": "case", + "status": "passed" if expected == received else "failed", + "admissionMode": "strict", + "fixture": {"state": "complete", "lateReports": 0, "maximumLatenessUs": 0}, + "capture": { + "expected": expected, + "received": received, + "focused": focused, + "pressedKeyCodes": [], + "activeModifiers": 0, + "duplicateDownEvents": 0, + "repeatEvents": 0, + "unmatchedUpEvents": 0, + }, + } + + +class PhysicalHIDRepeatMatrixTests(unittest.TestCase): + def test_case_run_ids_fit_firmware_limit_and_keep_profile_identity(self) -> None: + prefix = "repeat-matrix-with-an-extremely-long-human-readable-campaign-name" + identifiers = { + MODULE.case_run_id(prefix, "alternating", 5, workers, swift) + for workers, swift in ((0, False), (2, False), (0, True), (2, True)) + } + self.assertEqual(len(identifiers), 4) + self.assertTrue(all(len(identifier) <= 48 for identifier in identifiers)) + + def test_retry_ids_fit_firmware_limit_and_do_not_collide_after_truncation(self) -> None: + first = MODULE.case_run_id("campaign-with-a-very-long-prefix", "single", 5, 0, True) + second = MODULE.case_run_id("campaign-with-a-very-long-prefix", "single", 5, 2, True) + retries = {MODULE.retry_run_id(first, 2), MODULE.retry_run_id(second, 2)} + + self.assertEqual(len(retries), 2) + self.assertTrue(all(len(identifier) <= 48 for identifier in retries)) + + def test_resume_reuses_a_valid_retry_instead_of_repeating_hid(self) -> None: + base = MODULE.case_run_id("campaign", "single", 5, 0, True) + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) + inconclusive = artifact("abcd", "abcd") + inconclusive["status"] = "inconclusive" + (output / f"{base}.json").write_text(json.dumps(inconclusive)) + retry_path = output / f"{MODULE.retry_run_id(base, 2)}.json" + retry_path.write_text(json.dumps(artifact("abcd", "abcd"))) + + resumed, next_retry = MODULE.valid_resume_artifact(output, base) + + self.assertEqual(resumed, retry_path) + self.assertEqual(next_retry, 3) + + def test_campaign_requires_explicit_exclusive_desktop_confirmation(self) -> None: + with mock.patch.object(MODULE.sys, "argv", [str(SCRIPT)]): + self.assertEqual(MODULE.main(), 2) + + def test_analysis_distinguishes_additions_deletions_and_substitutions(self) -> None: + added = MODULE.analyze_artifact(artifact("abcd", "abbcd")) + deleted = MODULE.analyze_artifact(artifact("abcd", "acd")) + substituted = MODULE.analyze_artifact(artifact("abcd", "abXd")) + + self.assertEqual(added["addedCharacters"], 1) + self.assertEqual(deleted["deletedCharacters"], 1) + self.assertEqual(substituted["substitutedCharacters"], 1) + + def test_classification_calls_added_output_a_repeated_input_observation(self) -> None: + case = MODULE.analyze_artifact(artifact("abcd", "abbcd")) + self.assertEqual(MODULE.classify([case]), "repeated-input-observed") + + def test_classification_fails_closed_when_focus_is_lost(self) -> None: + case = MODULE.analyze_artifact(artifact("abcd", "abcd", focused=False)) + self.assertEqual(MODULE.classify([case]), "harness-invalid") + + def test_inconclusive_runner_artifact_is_not_valid_resume_evidence(self) -> None: + result = artifact("abcd", "abcd") + result["status"] = "inconclusive" + case = MODULE.analyze_artifact(result) + + self.assertFalse(case["validEvidence"]) + self.assertEqual(MODULE.classify([case]), "harness-invalid") + + def test_campaign_uses_strict_runner_and_load_budget_only_for_loaded_case(self) -> None: + commands: list[list[str]] = [] + + def fake_run(command, text=True, capture_output=True): + del text, capture_output + commands.append(command) + if command[-1] == "status": + return subprocess.CompletedProcess( + command, 0, + stdout=json.dumps({ + "systemReadiness": {"canProceed": True, "detail": "stable"} + }), + stderr="", + ) + output = pathlib.Path(command[command.index("--output") + 1]) + corpus = pathlib.Path(command[command.index("--text") + 1]).read_text() + repeat = int(command[command.index("--repeat") + 1]) + output.write_text(json.dumps(artifact(corpus * repeat, corpus * repeat))) + return subprocess.CompletedProcess(command, 0, stdout="{}", stderr="") + + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + corpus = root / "corpus.txt" + corpus.write_text("aaaa") + output = root / "output" + arguments = [ + str(SCRIPT), "--run-id-prefix", "test", "--interval-values", "10", + "--cpu-worker-values", "0,2", "--hold-ms", "2", "--repeat", "1", + "--readiness-timeout-seconds", "0", "--output-directory", str(output), + "--exclusive-desktop-confirmed", + ] + with mock.patch.object(MODULE, "DEFAULT_CORPORA", [("single", corpus)]), \ + mock.patch.object(MODULE, "ensure_jig_running", return_value=(True, "ready")), \ + mock.patch.object(MODULE.subprocess, "run", side_effect=fake_run), \ + mock.patch.object(MODULE.sys, "argv", arguments): + self.assertEqual(MODULE.main(), 0) + + runner_commands = [command for command in commands if "--output" in command] + self.assertEqual(len(runner_commands), 4) + calm = next(command for command in runner_commands if ( + command[command.index("--cpu-load-workers") + 1] == "0" and + "--swift-compile-load" not in command + )) + loaded = next(command for command in runner_commands if ( + command[command.index("--cpu-load-workers") + 1] == "2" and + "--swift-compile-load" not in command + )) + swift = next(command for command in runner_commands if ( + command[command.index("--cpu-load-workers") + 1] == "0" and + "--swift-compile-load" in command + )) + self.assertNotIn("--demo-mode", calm) + self.assertNotIn("--max-late-reports", calm) + self.assertIn("--max-late-reports", loaded) + self.assertEqual(loaded[loaded.index("--max-late-reports") + 1], "5") + self.assertIn("--max-late-reports", swift) + summary = json.loads((output / "summary.json").read_text()) + self.assertEqual(summary["classification"], "exact-output-observed") + self.assertEqual(summary["completedCases"], 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-hid-shift-matrix-tests.py b/Scripts/lab/tests/physical-hid-shift-matrix-tests.py new file mode 100644 index 000000000..582eba3ae --- /dev/null +++ b/Scripts/lab/tests/physical-hid-shift-matrix-tests.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 + +import importlib.machinery +import importlib.util +import pathlib +import subprocess +import unittest +from unittest import mock + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "Scripts/lab/physical-hid-shift-matrix" +LOADER = importlib.machinery.SourceFileLoader("physical_hid_shift_matrix", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ShiftMatrixTests(unittest.TestCase): + def artifact(self, expected="A!", received="a1", focused=True): + return { + "runID": "case", + "status": "failed", + "modifierTiming": {"shiftLeadMs": 0, "keyHoldMs": 8, "shiftReleaseLagMs": 0}, + "fixture": {"lateReports": 0, "maximumLatenessUs": 0}, + "fixtureTrace": [{"sequence": 1}, {"sequence": 2}], + "capture": { + "expected": expected, "received": received, "focused": focused, + "pressedKeyCodes": [], "activeModifiers": 0, + }, + } + + def test_analysis_counts_shift_demotions(self): + case = MODULE.analyze_artifact(self.artifact()) + self.assertEqual(case["wrongCharacters"], 2) + self.assertEqual(case["shiftDemotions"], 2) + self.assertEqual(case["fixtureTraceReports"], 2) + + def test_classification_finds_timing_sensitive_exact_case(self): + baseline = MODULE.analyze_artifact(self.artifact()) + improved_artifact = self.artifact(expected="A!", received="A!") + improved_artifact["modifierTiming"]["shiftLeadMs"] = 4 + improved = MODULE.analyze_artifact(improved_artifact) + self.assertEqual(MODULE.classify([baseline, improved]), "modifier-timing-sensitive") + + def test_classification_fails_closed_on_focus_loss(self): + invalid = MODULE.analyze_artifact(self.artifact(focused=False)) + self.assertEqual(MODULE.classify([invalid]), "harness-invalid") + + def test_ready_jig_is_reused_without_focus_or_reopen(self): + responses = [subprocess.CompletedProcess([], 0, stdout="{}", stderr="")] + with mock.patch.object(MODULE.subprocess, "run", side_effect=responses) as run: + ready, _ = MODULE.ensure_jig_running() + self.assertTrue(ready) + self.assertEqual(len(run.call_args_list), 1) + self.assertEqual(run.call_args_list[0].args[0][-1], "status") + + def test_missing_jig_is_opened(self): + responses = [ + subprocess.CompletedProcess([], 2, stdout="", stderr="not running"), + subprocess.CompletedProcess([], 0, stdout='{"snapshot":{"focused":true}}', stderr=""), + ] + with mock.patch.object(MODULE.subprocess, "run", side_effect=responses) as run: + ready, _ = MODULE.ensure_jig_running() + self.assertTrue(ready) + self.assertEqual(run.call_args_list[1].args[0][-1], "open") + + def test_readiness_wait_does_not_request_focus(self): + response = subprocess.CompletedProcess( + [], 0, stdout='{"systemReadiness":{"canProceed":true,"detail":"stable"}}', stderr="" + ) + with mock.patch.object(MODULE.subprocess, "run", return_value=response) as run: + ready, detail = MODULE.wait_for_jig_readiness(0) + self.assertTrue(ready) + self.assertEqual(detail, "stable") + self.assertEqual(run.call_args.args[0][-1], "status") + + def test_readiness_wait_times_out_closed(self): + response = subprocess.CompletedProcess( + [], 0, + stdout='{"systemReadiness":{"canProceed":false,"detail":"memory pressure"}}', + stderr="", + ) + with mock.patch.object(MODULE.subprocess, "run", return_value=response): + ready, detail = MODULE.wait_for_jig_readiness(0) + self.assertFalse(ready) + self.assertEqual(detail, "memory pressure") + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/physical-remap-session-tests.py b/Scripts/lab/tests/physical-remap-session-tests.py new file mode 100755 index 000000000..0864696ba --- /dev/null +++ b/Scripts/lab/tests/physical-remap-session-tests.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +TOOL = pathlib.Path(__file__).resolve().parents[1] / "physical-remap-session" + + +class PhysicalSessionTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tmp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + self.output = self.root / "out" + self.make("sw_vers", '#!/bin/sh\n[ "$1" = -productVersion ] && { echo 15.7.7; exit; }; echo 15.7.7\n') + self.make("ioreg", '#!/bin/sh\necho \'"Product" = "mWave"\'\necho \'"Manufacturer" = "Kinesis Corporation"\'\necho \'"SerialNumber" = "do-not-retain"\'\n') + self.make("ready", '#!/bin/sh\necho runtime_state=ready\n') + self.make("keypath-cli", '#!/bin/sh\necho \'{"ok":true,"output":"w"}\'\n') + self.make("open", '#!/bin/sh\nexit 0\n') + self.make("osascript", '#!/bin/sh\ncase "$*" in *"get text"*) printf "%s\\n" "${TEXTEDIT_VALUE:-w}";; esac\n') + self.make("peekaboo", '#!/bin/sh\nexit 0\n') + self.make("peekaboo-ui", '''#!/bin/sh +command=$1; shift +out= +app= +while [ $# -gt 0 ]; do + [ "$1" = --output ] && out=$2 + [ "$1" = --app ] && app=$2 + shift +done +if [ "$command" = screenshot ]; then printf png > "$out"; exit 0; fi +if [ "$app" = KeyPath ]; then + printf '%s\n' '{"data":{"ui_elements":[{"identifier":"keyboard-overlay"},{"identifier":"keycap-code-12","value":"pressed"}]}}' > "$out" +else + printf '%s\n' '{"data":{"ui_elements":[{"label":"TextEdit"}]}}' > "$out" +fi +''') + result = self.bin / "scenario-result" + result.write_text((TOOL.parent / "scenario-result").read_text()) + result.chmod(0o755) + + def make(self, name: str, body: str) -> None: + path = self.bin / name + path.write_text(body) + path.chmod(0o755) + + def tearDown(self): + self.tmp.cleanup() + + def env(self, **extra): + values = os.environ | { + "KEYPATH_PHYSICAL_RESULT": str(self.bin / "scenario-result"), + "KEYPATH_PHYSICAL_ASSERT_READY": str(self.bin / "ready"), + "KEYPATH_PHYSICAL_PEEKABOO_UI": str(self.bin / "peekaboo-ui"), + "KEYPATH_PHYSICAL_PEEKABOO": str(self.bin / "peekaboo"), + "KEYPATH_PHYSICAL_INSTALLED_CLI": str(self.bin / "keypath-cli"), + "KEYPATH_PHYSICAL_SW_VERS": str(self.bin / "sw_vers"), + "KEYPATH_PHYSICAL_IOREG": str(self.bin / "ioreg"), + "KEYPATH_PHYSICAL_OPEN": str(self.bin / "open"), + "KEYPATH_PHYSICAL_OSASCRIPT": str(self.bin / "osascript"), + "KEYPATH_PHYSICAL_SLEEP_SCALE": "0", + } + values.update(extra) + return values + + def call(self, *args, env=None): + return subprocess.run([str(TOOL), *args], env=env or self.env(), text=True, capture_output=True) + + def test_one_physical_event_proves_output_overlay_and_timing(self): + prepared = self.call("prepare", "--output", str(self.output), "--device-match", "mWave") + self.assertEqual(prepared.returncode, 0, prepared.stderr) + observed = self.call("observe", "--output", str(self.output), "--timeout-seconds", "1") + self.assertEqual(observed.returncode, 0, observed.stderr) + for block in ("P02", "P03", "P04"): + result = json.loads((self.output / block / "result.json").read_text()) + self.assertEqual(result["status"], "passed", block) + self.assertNotIn("do-not-retain", (self.output / "prepare" / "hid-inventory.txt").read_text()) + timing = json.loads((self.output / "P04" / "timing.json").read_text()) + self.assertIn("launchToOutputMilliseconds", timing) + + def test_missing_named_guest_hid_blocks_before_arming(self): + prepared = self.call("prepare", "--output", str(self.output), "--device-match", "Advantage360") + self.assertEqual(prepared.returncode, 4) + result = json.loads((self.output / "prepare" / "result.json").read_text()) + self.assertEqual(result["failure"]["step"], "physical-hid-admission") + + def test_literal_q_is_a_product_failure_and_cannot_prove_timing(self): + self.assertEqual(self.call("prepare", "--output", str(self.output), "--device-match", "mWave").returncode, 0) + observed = self.call("observe", "--output", str(self.output), "--timeout-seconds", "1", env=self.env(TEXTEDIT_VALUE="q")) + self.assertEqual(observed.returncode, 1) + p02 = json.loads((self.output / "P02" / "result.json").read_text()) + p03 = json.loads((self.output / "P03" / "result.json").read_text()) + p04 = json.loads((self.output / "P04" / "result.json").read_text()) + self.assertEqual(p02["failure"]["classification"], "keypath-product-failure") + self.assertEqual(p03["status"], "passed") + self.assertEqual(p04["status"], "blocked") + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/pico-hid-fixture-client-tests.py b/Scripts/lab/tests/pico-hid-fixture-client-tests.py new file mode 100755 index 000000000..4440f5568 --- /dev/null +++ b/Scripts/lab/tests/pico-hid-fixture-client-tests.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 + +import importlib.util +import importlib.machinery +import hashlib +import hmac +import json +import pathlib +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +CLIENT_PATH = ROOT / "Scripts/lab/pico-hid-fixture-client" +LOADER = importlib.machinery.SourceFileLoader("pico_hid_fixture_client", str(CLIENT_PATH)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +assert SPEC and SPEC.loader +CLIENT = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CLIENT) + + +class RecordingHandler(BaseHTTPRequestHandler): + requests = [] + received_headers = [] + reported_build = "old-build" + reported_slot = "ota_0" + + def log_message(self, _format, *_arguments): + pass + + def _handle(self): + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + self.__class__.requests.append((self.command, self.path, self.headers.get("Authorization"), body)) + self.__class__.received_headers.append({name.lower(): value for name, value in self.headers.items()}) + if self.path == "/v1/status": + payload = {"ok": True, "state": "idle", "build": self.__class__.reported_build, + "wifiConnected": True, "updateReady": True, + "displayHealthy": True, "splashComplete": True, + "otaSlot": self.__class__.reported_slot, "otaState": "valid"} + elif self.path == "/v1/firmware": + self.__class__.reported_build = self.headers["X-KeyPath-Expected-Build"] + self.__class__.reported_slot = "ota_1" if self.__class__.reported_slot == "ota_0" else "ota_0" + payload = {"ok": True, "rebooting": True, "targetSlot": self.__class__.reported_slot} + elif self.path.startswith("/v1/trace"): + encoded = b'{"runId":"r","from":0,"available":1}\n{"sequence":1}\n' + self.send_response(200) + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + return + else: + payload = {"ok": True} + encoded = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + do_GET = _handle + do_POST = _handle + + +class ClientTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + RecordingHandler.requests = [] + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), RecordingHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.thread.join() + + def setUp(self): + RecordingHandler.requests.clear() + RecordingHandler.received_headers.clear() + RecordingHandler.reported_build = "old-build" + RecordingHandler.reported_slot = "ota_0" + self.client = CLIENT.FixtureClient("127.0.0.1", "test-token", self.server.server_port) + + def test_compile_text_emits_complete_reports_and_crc(self): + script = CLIENT.compile_text("run-1", "aA 1!\n", 80, 30, 2, 500) + header, payload = script.split("\n", 1) + fields = header.split() + self.assertEqual(fields[:5], ["KPHID1", "run-1", "12", "2", "980000"]) + self.assertEqual(int(fields[5], 16), CLIENT.zlib.crc32(payload.encode("ascii")) & 0xFFFFFFFF) + lines = payload.splitlines() + self.assertEqual(lines[0], "0 0 4 0 0 0 0 0") + self.assertEqual(lines[2], "80000 2 4 0 0 0 0 0") + self.assertEqual(lines[-1].split()[1:], ["0", "0", "0", "0", "0", "0", "0"]) + + def test_compile_rejects_unsupported_text_and_unsafe_timing(self): + with self.assertRaisesRegex(ValueError, "unsupported"): + CLIENT.compile_text("run", "🙂", 80, 30, 1, 0) + with self.assertRaisesRegex(ValueError, "at least 4 ms"): + CLIENT.compile_text("run", "a", 3, 2, 1, 0) + with self.assertRaisesRegex(ValueError, "hold duration"): + CLIENT.compile_text("run", "a", 20, 20, 1, 0) + with self.assertRaisesRegex(ValueError, "timeout"): + CLIENT.FixtureClient("fixture", "token", timeout=0) + + def test_compile_text_can_separate_shift_press_and_release(self): + script = CLIENT.compile_text("shift-order", "A!", 50, 8, 1, 100, 4, 6) + header, payload = script.split("\n", 1) + fields = header.split() + self.assertEqual(fields[:5], ["KPHID1", "shift-order", "8", "1", "200000"]) + self.assertEqual(payload.splitlines(), [ + "0 2 0 0 0 0 0 0", + "4000 2 4 0 0 0 0 0", + "12000 2 0 0 0 0 0 0", + "18000 0 0 0 0 0 0 0", + "50000 2 0 0 0 0 0 0", + "54000 2 30 0 0 0 0 0", + "62000 2 0 0 0 0 0 0", + "68000 0 0 0 0 0 0 0", + ]) + with self.assertRaisesRegex(ValueError, "fit inside"): + CLIENT.compile_text("shift-order", "A", 20, 10, 1, 0, 5, 5) + + def test_client_authenticates_and_uses_expected_endpoints(self): + self.assertEqual(self.client.status()["state"], "idle") + self.client.arm("run-2") + self.client.start("run-2", 2000) + self.client.abort() + paths = [(method, path) for method, path, _auth, _body in RecordingHandler.requests] + self.assertEqual(paths, [("GET", "/v1/status"), ("POST", "/v1/arm"), + ("POST", "/v1/start"), ("POST", "/v1/abort")]) + self.assertTrue(all(auth == "Bearer test-token" for _method, _path, auth, _body in RecordingHandler.requests)) + + def test_trace_decodes_ndjson(self): + trace = self.client.trace(0, 8) + self.assertEqual(trace[0]["available"], 1) + self.assertEqual(trace[1]["sequence"], 1) + + def test_trace_all_returns_complete_trace(self): + trace = self.client.trace_all() + self.assertEqual(trace, [{"sequence": 1}]) + self.assertEqual(RecordingHandler.requests[-1][1], "/v1/trace?from=0&limit=8") + + def test_presentation_uses_bounded_json_channel(self): + self.client.present({"phase": "result", "result": "pass", "brand": "bear", "progress": 1000, + "title": "Swift stress", "reportsExpected": 40, + "reportsObserved": 40, "safeRelease": True}) + method, path, auth, body = RecordingHandler.requests[-1] + self.assertEqual((method, path, auth), ("POST", "/v1/presentation", "Bearer test-token")) + payload = json.loads(body) + self.assertEqual(payload["result"], "pass") + self.assertEqual(payload["brand"], "bear") + self.assertEqual(payload["reportsObserved"], 40) + + def test_presentation_parser_accepts_explicit_bear_brand(self): + arguments = CLIENT.parser().parse_args([ + "present", "--phase", "testing", "--brand", "bear", + "--title", "TYPING IN BEAR", + ]) + self.assertEqual(arguments.brand, "bear") + self.assertEqual(arguments.title, "TYPING IN BEAR") + + def test_firmware_update_authenticates_image_and_verifies_reconnected_build(self): + firmware = b"esp32-application-image" + result = self.client.update_firmware(firmware, "a1b2c3d4", wait_seconds=2) + + method, path, auth, body = RecordingHandler.requests[1] + headers = RecordingHandler.received_headers[1] + self.assertEqual((method, path, auth, body), + ("POST", "/v1/firmware", "Bearer test-token", firmware)) + self.assertEqual(headers["x-keypath-sha256"], hashlib.sha256(firmware).hexdigest()) + self.assertEqual(headers["x-keypath-hmac-sha256"], + hmac.new(b"test-token", firmware, hashlib.sha256).hexdigest()) + self.assertEqual(headers["x-keypath-expected-build"], "a1b2c3d4") + self.assertEqual(result["verifiedBuild"], "a1b2c3d4") + self.assertEqual(result["previousSlot"], "ota_0") + self.assertEqual(result["verifiedSlot"], "ota_1") + self.assertEqual(result["status"]["otaSlot"], "ota_1") + + def test_firmware_update_rejects_empty_images_and_ambiguous_builds(self): + with self.assertRaisesRegex(ValueError, "empty"): + self.client.update_firmware(b"", "a1") + with self.assertRaisesRegex(ValueError, "expected build"): + self.client.update_firmware(b"image", "not-a-build") + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py b/Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py new file mode 100755 index 000000000..23971c81d --- /dev/null +++ b/Scripts/lab/tests/pico-hid-fixture-control-soak-tests.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +import http.server +import json +import os +import pathlib +import subprocess +import tempfile +import threading +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SOAK = ROOT / "lab/pico-hid-fixture-control-soak" + + +class StatusHandler(http.server.BaseHTTPRequestHandler): + count = 0 + + def do_GET(self): + if self.path != "/v1/status" or self.headers.get("Authorization") != "Bearer test-token": + self.send_error(403) + return + type(self).count += 1 + body = json.dumps({ + "ok": True, + "build": "abc123", + "diagnostics": {"uptimeMs": 10_000 + type(self).count, "statusRequests": type(self).count}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + +class ControlSoakTests(unittest.TestCase): + def test_records_latency_and_continuity_without_exposing_token(self): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), StatusHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as directory: + output = pathlib.Path(directory) / "soak.json" + result = subprocess.run([ + str(SOAK), "--host", "127.0.0.1", "--port", str(server.server_port), + "--requests", "5", "--interval-ms", "0", "--output", str(output), + ], text=True, capture_output=True, + env=os.environ | {"KEYPATH_FIXTURE_TOKEN": "test-token"}) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = json.loads(output.read_text()) + self.assertEqual(artifact["status"], "pass") + self.assertEqual(artifact["requests"]["succeeded"], 5) + self.assertFalse(artifact["continuity"]["resetObserved"]) + self.assertEqual(artifact["target"]["resolvedHost"], "127.0.0.1") + self.assertIsNotNone(artifact["latencyMs"]["p95"]) + self.assertNotIn("test-token", result.stdout + result.stderr + output.read_text()) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/pico-hid-fixture-tool-tests.py b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py new file mode 100644 index 000000000..8ba297dd3 --- /dev/null +++ b/Scripts/lab/tests/pico-hid-fixture-tool-tests.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +TOOL = ROOT / "Scripts/lab/pico-hid-fixture-tool" + + +class FixtureToolTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.idf = self.directory / "esp-idf" + self.fake_bin = self.directory / "bin" + self.fake_bin.mkdir() + self.idf.mkdir() + (self.idf / "export.sh").write_text(f'export PATH="{self.fake_bin}:$PATH"\n') + self.build = self.directory / "build" + self.sdkconfig = self.directory / "sdkconfig" + self.device_dir = self.directory / "dev" + self.device_dir.mkdir() + self.environment = os.environ.copy() + self.environment.update({ + "KEYPATH_FIXTURE_HOST_OS": "Darwin", + "KEYPATH_FIXTURE_IDF_PATH": str(self.idf), + "KEYPATH_FIXTURE_BUILD_DIR": str(self.build), + "KEYPATH_FIXTURE_SDKCONFIG": str(self.sdkconfig), + "KEYPATH_FIXTURE_DEVICE_DIR": str(self.device_dir), + "KEYPATH_FIXTURE_CLIENT": str(self.directory / "fixture-client"), + "KEYPATH_FIXTURE_SECRETS_FILE": str(self.directory / "missing-secrets.env"), + "KEYPATH_WIFI_SSID_1": "fixture-primary", + "KEYPATH_WIFI_PASSWORD_1": "fixture-password-one", + "KEYPATH_WIFI_SSID_3": "fixture-fallback-two", + "KEYPATH_WIFI_PASSWORD_3": "fixture-password-three", + "KEYPATH_WIFI_SSID_4": "fixture-current-location", + "KEYPATH_WIFI_PASSWORD_4": "fixture-password-four", + "KEYPATH_HACKER_DOJO_USERNAME": "fixture-enterprise-user", + "KEYPATH_HACKER_DOJO_PASSWORD": "fixture-enterprise-password", + "KEYPATH_FIXTURE_TOKEN": "fixture-test-token-value", + }) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def run_tool(self, *arguments: str, environment=None) -> subprocess.CompletedProcess[str]: + return subprocess.run([str(TOOL), *arguments], text=True, capture_output=True, + env=environment or self.environment) + + def test_help_names_the_single_install_command(self) -> None: + result = self.run_tool("help") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("install [--port DEV]", result.stdout) + self.assertIn("never pass it on the command line", result.stdout) + + def test_doctor_accepts_environment_credentials_without_printing_values(self) -> None: + result = self.run_tool("doctor") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("production credentials (values hidden)", result.stdout) + self.assertIn("board not connected", result.stdout) + self.assertNotIn(self.environment["KEYPATH_WIFI_PASSWORD_1"], result.stdout + result.stderr) + self.assertNotIn(self.environment["KEYPATH_HACKER_DOJO_PASSWORD"], + result.stdout + result.stderr) + self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], result.stdout + result.stderr) + + def test_doctor_rejects_missing_or_placeholder_credentials(self) -> None: + environment = self.environment.copy() + environment.pop("KEYPATH_FIXTURE_TOKEN") + result = self.run_tool("doctor", environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertRegex(result.stderr, r"sops is required|encrypted secrets file is missing") + + environment = self.environment.copy() + environment["KEYPATH_FIXTURE_TOKEN"] = "fixture-placeholder-token" + result = self.run_tool("doctor", environment=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("placeholder", result.stderr) + + def test_build_uses_the_configured_cache_and_checks_for_output(self) -> None: + idf_log = self.directory / "idf.log" + fake_idf = self.fake_bin / "idf.py" + fake_idf.write_text(textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf '%s\\n' "$*" >> {str(idf_log)!r} + build_dir= + while [[ $# -gt 0 ]]; do + if [[ "$1" == -B ]]; then build_dir=$2; shift 2; else shift; fi + done + mkdir -p "$build_dir" + : > "$build_dir/keypath_esp32_s3_hid_fixture.bin" + """)) + fake_idf.chmod(0o755) + + result = self.run_tool("build") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.build / "keypath_esp32_s3_hid_fixture.bin").is_file()) + invocation = idf_log.read_text() + self.assertIn(str(self.build), invocation) + self.assertIn(f"SDKCONFIG={self.sdkconfig}", invocation) + self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], invocation) + + def test_update_builds_authenticates_and_verifies_without_exposing_token(self) -> None: + idf_log = self.directory / "idf.log" + fake_idf = self.fake_bin / "idf.py" + fake_idf.write_text(textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf '%s\\n' "$*" >> {str(idf_log)!r} + build_dir= + while [[ $# -gt 0 ]]; do + if [[ "$1" == -B ]]; then build_dir=$2; shift 2; else shift; fi + done + mkdir -p "$build_dir/esp-idf/main/generated" + : > "$build_dir/keypath_esp32_s3_hid_fixture.bin" + printf '%s\\n' '#define KEYPATH_FIXTURE_BUILD_ID "a1b2c3d4e5f6"' \\ + > "$build_dir/esp-idf/main/generated/fixture_config.h" + """)) + fake_idf.chmod(0o755) + client_log = self.directory / "client.log" + fake_client = pathlib.Path(self.environment["KEYPATH_FIXTURE_CLIENT"]) + fake_client.write_text(textwrap.dedent(f"""\ + #!/bin/bash + set -eu + printf '%s\\n' "$*" >> {str(client_log)!r} + if [[ "$1" == status ]]; then + printf '%s\\n' '{{"ok":true,"updateReady":true,"otaSlot":"ota_0"}}' + else + printf '%s\\n' '{{"ok":true,"verifiedBuild":"a1b2c3d4e5f6"}}' + fi + """)) + fake_client.chmod(0o755) + + result = self.run_tool("update") + + self.assertEqual(result.returncode, 0, result.stderr) + invocation = client_log.read_text() + self.assertIn("update-firmware", invocation) + self.assertIn("--timeout 120", invocation) + self.assertIn("--expected-build a1b2c3d4e5f6", invocation) + self.assertNotIn(self.environment["KEYPATH_FIXTURE_TOKEN"], result.stdout + result.stderr + invocation) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/runtime-state-tests.sh b/Scripts/lab/tests/runtime-state-tests.sh new file mode 100755 index 000000000..5d79215d8 --- /dev/null +++ b/Scripts/lab/tests/runtime-state-tests.sh @@ -0,0 +1,92 @@ +#!/bin/zsh +set -euo pipefail + +repo=$(cd "$(dirname "$0")/../../.." >/dev/null && pwd -P) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mkdir -p "$tmp/bin" + +cat > "$tmp/bin/keypath-cli" <<'EOF' +#!/bin/zsh +print -r -- "${KEYPATH_TEST_STATUS_JSON:?}" +exit "${KEYPATH_TEST_STATUS_EXIT:-0}" +EOF + +cat > "$tmp/bin/launchctl" <<'EOF' +#!/bin/zsh +if [[ ${KEYPATH_TEST_LAUNCHD_RUNNING:-0} == 1 ]]; then + print 'state = running' + exit 0 +fi +exit 113 +EOF + +cat > "$tmp/bin/tcp-probe" <<'EOF' +#!/bin/zsh +[[ ${KEYPATH_TEST_TCP_READY:-0} == 1 ]] +EOF + +cat > "$tmp/bin/verify-lane" <<'EOF' +#!/bin/zsh +[[ ${KEYPATH_TEST_MANAGED_POLICY_VALID:-0} == 1 ]] +EOF + +chmod +x "$tmp/bin/"* + +ready='{"apiVersion":1,"data":{"isOperational":true,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":true,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":true,"karabinerDaemonRunning":true,"vhidHealthy":true}}' +degraded='{"apiVersion":1,"data":{"isOperational":false,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":true,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":false,"karabinerDaemonRunning":true,"vhidHealthy":true}}' +managed_ready='{"apiVersion":1,"data":{"isOperational":true,"helperInstalled":true,"helperWorking":true,"helperVersion":"1.2.3","keyPathAccessibility":true,"keyPathInputMonitoring":true,"kanataAccessibility":false,"kanataInputMonitoring":true,"kanataBinaryInstalled":true,"karabinerDriverInstalled":true,"vhidDeviceHealthy":true,"kanataRunning":true,"karabinerDaemonRunning":true,"vhidHealthy":true}}' + +env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + "$repo/Scripts/lab/assert-runtime-state" ready + +env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_LAB_VERIFY_LANE="$tmp/bin/verify-lane" \ + KEYPATH_TEST_STATUS_JSON="$managed_ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + KEYPATH_TEST_MANAGED_POLICY_VALID=1 \ + "$repo/Scripts/lab/assert-runtime-state" ready \ + --managed-policy-manifest "$tmp/manifest.json" + +if env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_LAB_VERIFY_LANE="$tmp/bin/verify-lane" \ + KEYPATH_TEST_STATUS_JSON="$managed_ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + KEYPATH_TEST_MANAGED_POLICY_VALID=0 \ + "$repo/Scripts/lab/assert-runtime-state" ready \ + --managed-policy-manifest "$tmp/manifest.json" >/dev/null 2>&1; then + print -u2 "invalid managed policy incorrectly substituted for Accessibility evidence" + exit 1 +fi + +env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$degraded" KEYPATH_TEST_LAUNCHD_RUNNING=0 KEYPATH_TEST_TCP_READY=0 \ + "$repo/Scripts/lab/assert-runtime-state" degraded + +if env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$ready" KEYPATH_TEST_LAUNCHD_RUNNING=1 KEYPATH_TEST_TCP_READY=1 \ + "$repo/Scripts/lab/assert-runtime-state" degraded >/dev/null 2>&1; then + print -u2 "ready runtime incorrectly passed the degraded assertion" + exit 1 +fi + +if env KEYPATH_LAB_CLI="$tmp/bin/keypath-cli" \ + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" \ + KEYPATH_LAB_TCP_PROBE="$tmp/bin/tcp-probe" \ + KEYPATH_TEST_STATUS_JSON="$degraded" KEYPATH_TEST_LAUNCHD_RUNNING=0 KEYPATH_TEST_TCP_READY=0 \ + "$repo/Scripts/lab/assert-runtime-state" ready >/dev/null 2>&1; then + print -u2 "degraded runtime incorrectly passed the ready assertion" + exit 1 +fi + +print "runtime-state tests passed" diff --git a/Scripts/lab/tests/scenario-matrix-runner-tests.py b/Scripts/lab/tests/scenario-matrix-runner-tests.py new file mode 100755 index 000000000..1c5d0e28e --- /dev/null +++ b/Scripts/lab/tests/scenario-matrix-runner-tests.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 + +import json +import os +import pathlib +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +RUNNER = ROOT / "Scripts/lab/scenario-matrix-runner" + + +class MatrixRunnerTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.directory = pathlib.Path(self.temporary.name) + self.installer = self.directory / "KeyPath.zip" + self.installer.write_bytes(b"candidate") + self.log = self.directory / "lab.log" + self.lab = self.directory / "keypath-lab" + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) print 'lease_id\tcbx_test_lease'; print 'manifest\t/tmp/manifest' ;; + status) print 'status\tready' ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + self.dashboard_log = self.directory / "dashboard.log" + self.dashboard = self.directory / "dashboard-updater" + self.dashboard.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.dashboard_log)!r} + """)) + self.dashboard.chmod(0o755) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def plan(self, steps: list[str], *, job_id: str = "vm-job") -> pathlib.Path: + path = self.directory / f"{job_id}.json" + path.write_text(json.dumps({ + "schemaVersion": 1, + "cadence": "weekly", + "summary": {"eligiblePairs": 10}, + "jobs": [{ + "id": job_id, "title": "VM job", "provider": "tart", "macOS": 15, + "lane": "managed-functional", "automation": "operator", "ttlMinutes": 120, + "steps": steps, "finalizer": "destroy-owned-lease", + "factors": {"platform": "macos15", "lane": "managed", "family": "install", "boundary": "fresh", "evidence": "runtime"}, + }], + "waves": [[job_id]], + "excluded": [], + })) + return path + + def run_runner(self, plan: pathlib.Path, *extra: str, + environment=None) -> subprocess.CompletedProcess[str]: + return subprocess.run([ + str(RUNNER), "--plan", str(plan), "--state", str(self.directory / "state.json"), + "--artifacts", str(self.directory / "artifacts"), "--commit", "a" * 40, + "--installer", str(self.installer), "--lab", str(self.lab), "--repo", str(ROOT), + "--dashboard-updater", str(self.dashboard), "--dashboard-state", str(self.directory / "dashboard-state.json"), + *extra, + ], text=True, capture_output=True, env=environment) + + def test_executes_steps_updates_dashboard_and_destroys_owned_lease(self) -> None: + result = self.run_runner(self.plan(["create-fresh-lease", "install-exact-artifact", "artifact-capture"])) + self.assertEqual(result.returncode, 0, result.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["status"], "passed") + self.assertEqual(state["jobs"][0]["status"], "passed") + self.assertEqual(state["jobs"][0]["cleanupStatus"], "passed") + lab_log = self.log.read_text() + self.assertIn("create --macos 15", lab_log) + self.assertIn("install-runtime cbx_test_lease", lab_log) + self.assertIn("scenario cbx_test_lease artifact-capture", lab_log) + self.assertIn("artifacts cbx_test_lease --output", lab_log) + self.assertIn("destroy cbx_test_lease", lab_log) + dashboard = self.dashboard_log.read_text() + self.assertIn("initialize --plan", dashboard) + self.assertIn("--status running", dashboard) + self.assertIn("--status passed", dashboard) + + def test_optional_hid_presentation_tracks_campaign_without_exposing_token(self) -> None: + presentation_log = self.directory / "presentation.log" + presenter = self.directory / "fixture-client" + presenter.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(presentation_log)!r} + print '{{"ok":true}}' + """)) + presenter.chmod(0o755) + environment = os.environ.copy() + environment["KEYPATH_FIXTURE_TOKEN"] = "fixture-test-secret" + + result = self.run_runner(self.plan(["create-fresh-lease", "artifact-capture"]), + "--hid-presentation", "--hid-fixture-client", str(presenter), + environment=environment) + + self.assertEqual(result.returncode, 0, result.stderr) + calls = presentation_log.read_text() + self.assertIn("--timeout 2.0 present --phase preparing", calls) + self.assertIn("--timeout 2.0 present --phase testing", calls) + self.assertIn("--timeout 2.0 present --phase observing", calls) + self.assertIn("--result pass", calls) + self.assertNotIn("fixture-test-secret", calls) + + def test_upgrade_boundary_uses_dedicated_controller_command(self) -> None: + fixture = self.directory / "KeyPath-beta3.zip" + fixture.write_bytes(b"fixture") + plan = self.plan(["create-fresh-lease-with-fixture", "install-upgrade-artifact"]) + + result = self.run_runner(plan, "--fixture", str(fixture)) + + self.assertEqual(result.returncode, 0, result.stderr) + lab_log = self.log.read_text() + self.assertIn("--fixture", lab_log) + self.assertIn("install-upgrade-runtime cbx_test_lease", lab_log) + + def test_human_checkpoint_waits_without_cleanup_then_resumes(self) -> None: + plan = self.plan(["create-fresh-lease", "operator-visible-action", "artifact-capture"]) + first = self.run_runner(plan) + self.assertEqual(first.returncode, 4, first.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["status"], "waiting") + self.assertEqual(state["jobs"][0]["waitingCheckpoint"], "vm-job:operator-visible-action") + self.assertNotIn("destroy", self.log.read_text()) + + second = self.run_runner(plan, "--ack-checkpoint", "vm-job:operator-visible-action") + self.assertEqual(second.returncode, 0, second.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["status"], "passed") + self.assertIn("destroy cbx_test_lease", self.log.read_text()) + + def test_managed_macos15_drives_input_monitoring_then_verifies_runtime(self) -> None: + marker = self.directory / "approval-requested" + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) print 'lease_id\tcbx_test_lease'; print 'manifest\t/tmp/manifest' ;; + status) print 'status\tready' ;; + install-runtime) + if [[ ! -f {str(marker)!r} ]]; then + touch {str(marker)!r} + print 'install_runtime\twaiting' + print 'user_action_required\tApprove KeyPath' + exit 4 + fi + print 'install_runtime\tpassed' + ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + plan = self.plan(["create-fresh-lease", "install-exact-artifact", "artifact-capture"]) + + first = self.run_runner(plan) + self.assertEqual(first.returncode, 0, first.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["status"], "passed") + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "passed") + self.assertEqual(self.log.read_text().count("install-runtime cbx_test_lease"), 2) + self.assertIn("approve-input-monitoring cbx_test_lease", self.log.read_text()) + self.assertIn("destroy cbx_test_lease", self.log.read_text()) + + def test_parallels_desktop_bootstrap_verifies_inherited_console_first(self) -> None: + plan = self.plan(["create-desktop-lease", "desktop-bootstrap"], job_id="macos26-selectors") + value = json.loads(plan.read_text()) + value["jobs"][0].update({"provider": "parallels", "macOS": 26, "lane": "unmanaged-ui"}) + plan.write_text(json.dumps(value)) + + result = self.run_runner(plan) + + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.log.read_text().splitlines() + verify_index = next(i for i, call in enumerate(calls) if call.startswith("verify-console-login ")) + bootstrap_index = next(i for i, call in enumerate(calls) if call.startswith("desktop-bootstrap ")) + self.assertLess(verify_index, bootstrap_index) + + def test_failed_automatic_input_monitoring_falls_back_to_retained_checkpoint(self) -> None: + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) print 'lease_id\tcbx_test_lease'; print 'manifest\t/tmp/manifest' ;; + status) print 'status\tready' ;; + install-runtime) print 'install_runtime\twaiting'; exit 4 ;; + approve-input-monitoring) print 'target occluded' >&2; exit 1 ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + result = self.run_runner(self.plan(["create-fresh-lease", "install-exact-artifact"])) + self.assertEqual(result.returncode, 4, result.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["status"], "waiting") + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "waiting") + self.assertNotIn("destroy", self.log.read_text()) + + def test_refuses_checkpoint_preapproval(self) -> None: + result = self.run_runner(self.plan(["create-fresh-lease", "operator-visible-action"]), + "--ack-checkpoint", "vm-job:operator-visible-action") + self.assertEqual(result.returncode, 2) + self.assertIn("not currently waiting", result.stderr) + self.assertFalse(self.log.exists()) + + def test_recovers_created_lease_but_blocks_other_uncertain_mutations(self) -> None: + plan = self.plan(["create-fresh-lease", "install-exact-artifact"]) + initial = self.run_runner(plan) + self.assertEqual(initial.returncode, 0, initial.stderr) + + state_path = self.directory / "state.json" + state = json.loads(state_path.read_text()) + state["status"] = "running" + state["jobs"][0]["status"] = "running" + state["jobs"][0]["cleanupStatus"] = "pending" + state["jobs"][0]["leaseId"] = "cbx_test_lease" + state["jobs"][0]["steps"][1]["status"] = "running" + state_path.write_text(json.dumps(state)) + resumed = self.run_runner(plan) + self.assertEqual(resumed.returncode, 1) + state = json.loads(state_path.read_text()) + self.assertEqual(state["jobs"][0]["status"], "blocked") + self.assertIn("refusing to repeat", state["jobs"][0]["blocker"]) + + def test_recovers_interrupted_install_at_durable_approval_boundary(self) -> None: + plan = self.plan(["create-fresh-lease", "install-exact-artifact"]) + initial = self.run_runner(plan) + self.assertEqual(initial.returncode, 0, initial.stderr) + + state_path = self.directory / "state.json" + state = json.loads(state_path.read_text()) + state["status"] = "running" + state["jobs"][0]["status"] = "running" + state["jobs"][0]["cleanupStatus"] = "pending" + state["jobs"][0]["leaseId"] = "cbx_test_lease" + state["jobs"][0]["steps"][1]["status"] = "running" + state_path.write_text(json.dumps(state)) + self.log.write_text("") + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + status) print 'status\tready'; print 'install_runtime_status\tawaiting-approval' ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + + resumed = self.run_runner(plan) + self.assertEqual(resumed.returncode, 4, resumed.stderr) + state = json.loads(state_path.read_text()) + self.assertEqual(state["status"], "waiting") + self.assertEqual(state["jobs"][0]["steps"][1]["status"], "waiting") + self.assertEqual(state["jobs"][0]["waitingCheckpoint"], "vm-job:install-exact-artifact") + self.assertNotIn("destroy", self.log.read_text()) + + def test_failed_create_adopts_controller_lease_and_cleans_it_up(self) -> None: + self.lab.write_text(textwrap.dedent(f"""\ + #!/bin/zsh + print -r -- "$*" >> {str(self.log)!r} + case "$1" in + create) + print 'provisioning provider=tart lease=cbx_failed_create slug=test' + exit 1 + ;; + status) print 'status\tmanaged-policy-failed' ;; + *) print 'ok\t'$1 ;; + esac + """)) + self.lab.chmod(0o755) + + result = self.run_runner(self.plan(["create-fresh-lease"])) + self.assertEqual(result.returncode, 1, result.stderr) + state = json.loads((self.directory / "state.json").read_text()) + self.assertEqual(state["jobs"][0]["leaseId"], "cbx_failed_create") + self.assertEqual(state["jobs"][0]["status"], "failed") + self.assertEqual(state["jobs"][0]["cleanupStatus"], "passed") + lab_log = self.log.read_text() + self.assertIn("artifacts cbx_failed_create", lab_log) + self.assertIn("destroy cbx_failed_create", lab_log) + + def test_rejects_changed_installer_on_resume(self) -> None: + plan = self.plan(["create-fresh-lease"]) + first = self.run_runner(plan) + self.assertEqual(first.returncode, 0, first.stderr) + self.installer.write_bytes(b"different") + second = self.run_runner(plan) + self.assertEqual(second.returncode, 2) + self.assertIn("installer changed", second.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/scenario-matrix-tests.py b/Scripts/lab/tests/scenario-matrix-tests.py new file mode 100755 index 000000000..4d8c5bf2e --- /dev/null +++ b/Scripts/lab/tests/scenario-matrix-tests.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +import importlib.machinery +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "Scripts/lab/scenario-matrix" +CATALOG = ROOT / "Scripts/lab/scenarios/matrix-catalog.json" + + +loader = importlib.machinery.SourceFileLoader("scenario_matrix", str(SCRIPT)) +spec = importlib.util.spec_from_loader(loader.name, loader) +matrix = importlib.util.module_from_spec(spec) +loader.exec_module(matrix) + + +class ScenarioMatrixTests(unittest.TestCase): + def run_plan(self, *arguments: str) -> dict: + result = subprocess.run( + [str(SCRIPT), *arguments], text=True, capture_output=True, check=True + ) + return json.loads(result.stdout) + + def test_nightly_is_unattended_and_requires_cleanup(self): + plan = self.run_plan("--cadence", "nightly") + self.assertGreaterEqual(plan["summary"]["jobs"], 2) + self.assertTrue(all(job["automation"] == "unattended" for job in plan["jobs"])) + self.assertTrue(all( + job["provider"] == "local" or job["finalizer"] == "destroy-owned-lease" + for job in plan["jobs"] + )) + excluded = {entry["id"] for entry in plan["excluded"]} + self.assertIn("macos15-clean-install", excluded) + self.assertIn("macos15-repair", excluded) + self.assertIn("macos26-selectors", excluded) + self.assertIn("macos15-cancellation-recovery", excluded) + self.assertIn("macos15-physical-remap", excluded) + physical = next(entry for entry in plan["excluded"] if entry["id"] == "macos15-physical-remap") + self.assertEqual(physical["title"], "macOS 15 physical q-to-w remap") + + def test_weekly_plan_covers_every_eligible_pair(self): + plan = self.run_plan("--cadence", "weekly") + self.assertTrue(plan["summary"]["pairwiseComplete"]) + self.assertEqual( + plan["summary"]["pairwisePairsCovered"], plan["summary"]["eligiblePairs"] + ) + + def test_operator_and_physical_cases_require_separate_opt_in(self): + operator = self.run_plan("--cadence", "weekly", "--include-operator") + operator_ids = {job["id"] for job in operator["jobs"]} + self.assertIn("macos15-cancellation-recovery", operator_ids) + self.assertNotIn("macos15-physical-remap", operator_ids) + upgrade = next(job for job in operator["jobs"] if job["id"] == "macos26-upgrade") + self.assertEqual(upgrade["steps"], [ + "create-fresh-lease-with-fixture", "install-fixture", "upgrade-beta3-before", + "install-upgrade-artifact", "upgrade-beta3-after", "artifact-capture", + ]) + + physical = self.run_plan("--cadence", "weekly", "--include-physical") + physical_ids = {job["id"] for job in physical["jobs"]} + self.assertIn("macos15-physical-remap", physical_ids) + self.assertNotIn("macos15-cancellation-recovery", physical_ids) + + def test_ttl_rejects_a_plan_before_any_lease_can_start(self): + result = subprocess.run( + [str(SCRIPT), "--cadence", "nightly", "--ttl-minutes", "20"], + text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("lease TTL 20m is too short", result.stderr) + + def test_shared_identity_cases_are_serialized_even_with_capacity(self): + cases = [ + { + "id": "a", "provider": "parallels", "identityScope": "shared", + }, + { + "id": "b", "provider": "parallels", "identityScope": "shared", + }, + ] + self.assertEqual(matrix.schedule(cases, {"tart": 1, "parallels": 2}), [["a"], ["b"]]) + + def test_plan_is_deterministic_except_timestamp(self): + first = self.run_plan("--cadence", "weekly") + second = self.run_plan("--cadence", "weekly") + first.pop("generatedAt") + second.pop("generatedAt") + self.assertEqual(first, second) + + def test_invalid_catalog_without_cleanup_fails_closed(self): + catalog = json.loads(CATALOG.read_text()) + catalog["cases"][1].pop("cleanup") + with tempfile.TemporaryDirectory() as directory: + path = pathlib.Path(directory) / "catalog.json" + path.write_text(json.dumps(catalog)) + result = subprocess.run( + [str(SCRIPT), "--catalog", str(path), "--cadence", "nightly"], + text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("require destroy-owned-lease cleanup", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/lab/tests/uninstalled-state-tests.sh b/Scripts/lab/tests/uninstalled-state-tests.sh new file mode 100755 index 000000000..0a02f2864 --- /dev/null +++ b/Scripts/lab/tests/uninstalled-state-tests.sh @@ -0,0 +1,55 @@ +#!/bin/zsh +set -euo pipefail + +repo=$(cd "$(dirname "$0")/../../.." >/dev/null && pwd -P) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +mkdir -p "$tmp/root" "$tmp/home/.config/keypath" "$tmp/bin" +print -n 'preserve-me' > "$tmp/home/.config/keypath/lab-sentinel" +expected=$(shasum -a 256 "$tmp/home/.config/keypath/lab-sentinel" | awk '{print $1}') + +cat > "$tmp/bin/launchctl" <<'EOF' +#!/bin/zsh +exit 113 +EOF +cat > "$tmp/bin/pgrep" <<'EOF' +#!/bin/zsh +exit 1 +EOF +cat > "$tmp/bin/systemextensionsctl" <<'EOF' +#!/bin/zsh +print '* * G43BCU2T37 org.pqrs.Karabiner-DriverKit-VirtualHIDDevice [activated enabled]' +EOF +chmod +x "$tmp/bin/"* + +base_env=( + KEYPATH_LAB_ROOT="$tmp/root" + KEYPATH_LAB_HOME="$tmp/home" + KEYPATH_LAB_LAUNCHCTL="$tmp/bin/launchctl" + KEYPATH_LAB_PGREP="$tmp/bin/pgrep" + KEYPATH_LAB_SYSTEMEXTENSIONSCTL="$tmp/bin/systemextensionsctl" +) + +env $base_env "$repo/Scripts/lab/assert-uninstalled-state" \ + --preserved-config "$tmp/home/.config/keypath/lab-sentinel" \ + --expected-sha256 "$expected" + +mkdir -p "$tmp/root/Applications/KeyPath.app" +if env $base_env "$repo/Scripts/lab/assert-uninstalled-state" \ + --preserved-config "$tmp/home/.config/keypath/lab-sentinel" \ + --expected-sha256 "$expected" >/dev/null 2>&1; then + print -u2 "remaining KeyPath app incorrectly passed uninstall assertion" + exit 1 +fi +rmdir "$tmp/root/Applications/KeyPath.app" + +print -n 'changed' > "$tmp/home/.config/keypath/lab-sentinel" +if env $base_env "$repo/Scripts/lab/assert-uninstalled-state" \ + --preserved-config "$tmp/home/.config/keypath/lab-sentinel" \ + --expected-sha256 "$expected" >/dev/null 2>&1; then + print -u2 "changed configuration incorrectly passed uninstall assertion" + exit 1 +fi + +print "uninstalled-state tests passed" diff --git a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift index fe2b4cd27..71ea3649a 100644 --- a/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift +++ b/Sources/KeyPathAppKit/Core/PrivilegedOperationsRouter.swift @@ -305,7 +305,10 @@ public final class PrivilegedOperationsRouter { #else try await runActivateVirtualHIDManager() #endif - try await enforceVHIDServicesPostcondition(after: "activateVirtualHIDManager") + // Activation owns the DriverKit system extension. Runtime service + // installation happens in a later installer recipe, so requiring those + // services here makes a clean install fail before it can install them. + try await enforceVHIDDriverPostcondition(after: "activateVirtualHIDManager") } private func runActivateVirtualHIDManager() async throws { @@ -904,7 +907,9 @@ public final class PrivilegedOperationsRouter { let deadline = Date().addingTimeInterval(Self.kanataStopVerifyTimeoutSeconds) repeat { let pids = await SystemStateProvider.shared.processIDs(matching: "kanata.*--cfg") - if pids.isEmpty { return true } + if pids.isEmpty { + return true + } do { try await Task.sleep(for: Self.postconditionPollInterval) } catch { return false } } while Date() < deadline @@ -932,13 +937,17 @@ public final class PrivilegedOperationsRouter { } else { false } - if staleOnEntry { return .staleRegistration } + if staleOnEntry { + return .staleRegistration + } let deadline = Date().addingTimeInterval(Self.kanataReadinessTimeout) var launchctlNotFoundSamples: [Bool] = [] while Date() < deadline { - if Task.isCancelled { return .timedOut } + if Task.isCancelled { + return .timedOut + } let remaining = deadline.timeIntervalSince(Date()) let timeoutMs = max(50, min(300, Int(remaining * 1000))) @@ -979,7 +988,9 @@ public final class PrivilegedOperationsRouter { } } - if await detectKanataTCPPortConflict() { return .tcpPortInUse } + if await detectKanataTCPPortConflict() { + return .tcpPortInUse + } return .timedOut } @@ -1023,7 +1034,9 @@ public final class PrivilegedOperationsRouter { let vhidManager = VHIDDeviceManager() let start = Date() while Date().timeIntervalSince(start) < Self.vhidVerifyTimeoutSeconds { - if await vhidManager.detectRunning() { return true } + if await vhidManager.detectRunning() { + return true + } try await Task.sleep(for: Self.postconditionPollInterval) } @@ -1075,7 +1088,9 @@ public final class PrivilegedOperationsRouter { let vhidManager = VHIDDeviceManager() let startTime = Date() while Date().timeIntervalSince(startTime) < Self.vhidVerifyTimeoutSeconds { - if await vhidManager.detectRunning() { return true } + if await vhidManager.detectRunning() { + return true + } try await Task.sleep(for: Self.postconditionPollInterval) } @@ -1086,7 +1101,9 @@ public final class PrivilegedOperationsRouter { let now = Date() if let last = lastSMAppApprovalNotice, now.timeIntervalSince(last) < smAppApprovalNoticeThrottle - { return } + { + return + } lastSMAppApprovalNotice = now NotificationCenter.default.post(name: .smAppServiceApprovalRequired, object: nil) } diff --git a/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift b/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift index 8718c734e..bc83e3fe6 100644 --- a/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift +++ b/Sources/KeyPathCLI/Commands/System/SystemUninstallCommand.swift @@ -1,3 +1,4 @@ +import AppKit import ArgumentParser import Foundation import KeyPathAppKit @@ -22,6 +23,10 @@ struct SystemUninstall: AsyncParsableCommand { CLIOutput.progress("Starting uninstall (configuration will be preserved)...", context: ctx) } + guard await terminateRunningKeyPathApplication(context: ctx) else { + throw ValidationError("The running KeyPath application could not be closed.") + } + let facade = SystemFacade() let shouldDeleteConfig = deleteConfig let timeoutSeconds = globals.timeout @@ -43,4 +48,33 @@ struct SystemUninstall: AsyncParsableCommand { throw ExitCode.failure } } + + @MainActor + private func terminateRunningKeyPathApplication(context: OutputContext) async -> Bool { + let currentPID = ProcessInfo.processInfo.processIdentifier + let applications = NSRunningApplication.runningApplications( + withBundleIdentifier: "com.keypath.KeyPath" + ).filter { $0.processIdentifier != currentPID && !$0.isTerminated } + + guard !applications.isEmpty else { return true } + + CLIOutput.progress("Closing the running KeyPath application...", context: context) + for application in applications { + _ = application.terminate() + } + + for _ in 0 ..< 30 where applications.contains(where: { !$0.isTerminated }) { + try? await Task.sleep(for: .milliseconds(100)) + } + + for application in applications where !application.isTerminated { + _ = application.forceTerminate() + } + + for _ in 0 ..< 20 where applications.contains(where: { !$0.isTerminated }) { + try? await Task.sleep(for: .milliseconds(100)) + } + + return applications.allSatisfy(\.isTerminated) + } } diff --git a/Sources/KeyPathCLISupport/CLIReportModels.swift b/Sources/KeyPathCLISupport/CLIReportModels.swift index b599f1be1..022bc06d8 100644 --- a/Sources/KeyPathCLISupport/CLIReportModels.swift +++ b/Sources/KeyPathCLISupport/CLIReportModels.swift @@ -261,6 +261,8 @@ public struct CLIRepairTelemetryEvent: Codable, Sendable { } public struct CLIInspectResult: Codable, Sendable { + public let planID: String? + public let snapshotID: String? public let macOSVersion: String public let driverCompatible: Bool public let planStatus: String @@ -275,6 +277,8 @@ public struct CLIInspectResult: Codable, Sendable { public let stateMatrixPlan: [String]? public init( + planID: String? = nil, + snapshotID: String? = nil, macOSVersion: String, driverCompatible: Bool, planStatus: String, @@ -288,6 +292,8 @@ public struct CLIInspectResult: Codable, Sendable { stateMatrixRow: String? = nil, stateMatrixPlan: [String]? = nil ) { + self.planID = planID + self.snapshotID = snapshotID self.macOSVersion = macOSVersion self.driverCompatible = driverCompatible self.planStatus = planStatus diff --git a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift index b697ac575..2118ec629 100644 --- a/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift +++ b/Sources/KeyPathInstallationWizard/Core/ServiceBootstrapper.swift @@ -720,8 +720,15 @@ public final class ServiceBootstrapper { try await daemonManager.unregister() let bootedOut = await bootOutStaleKanataSMAppServiceJob(attempt: attempt) if !bootedOut { - AppLogger.shared.log("❌ Attempt \(attempt) failed: stale launchd job could not be booted out") - continue + // SMAppService.unregister() is authoritative. The explicit + // launchctl cleanup is only a best-effort workaround for + // stale jobs and may be unavailable to noninteractive CLI + // repair. Continue with registration; if a stale job truly + // remains, register() or the health postcondition will fail + // and the normal retry path will handle it. + AppLogger.shared.log( + "⚠️ Attempt \(attempt): stale launchd cleanup was unavailable; continuing after successful SMAppService unregister" + ) } ServiceHealthChecker.shared.invalidateHealthCache() for _ in 0 ..< 10 { // ~1s @@ -907,7 +914,9 @@ public final class ServiceBootstrapper { ServiceHealthChecker.shared.invalidateHealthCache() daemonLoaded = await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidDaemonServiceID) managerLoaded = await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidManagerServiceID) - if daemonLoaded, managerLoaded { break } + if daemonLoaded, managerLoaded { + break + } _ = await WizardSleep.ms(500) } let configured = ServiceHealthChecker.shared.isVHIDDaemonConfiguredCorrectly() diff --git a/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift b/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift index a65fa8abb..54dff04c7 100644 --- a/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift +++ b/Tests/KeyPathTests/CLI/CLIOutputContractTests.swift @@ -257,6 +257,8 @@ final class CLIOutputContractTests: XCTestCase { canAutoFix: true ) let result = CLIInspectResult( + planID: "plan-test", + snapshotID: "snapshot-test", macOSVersion: "26.5.0", driverCompatible: true, planStatus: "ready", @@ -276,7 +278,7 @@ final class CLIOutputContractTests: XCTestCase { let keys = try jsonKeys(result) let required: Set = [ "driverCompatible", "issues", "isOperational", "macOSVersion", "planIntent", - "planStatus", "plannedRecipes", "promptsNeeded", "stateMatrixPlan", + "planID", "planStatus", "plannedRecipes", "promptsNeeded", "snapshotID", "stateMatrixPlan", "stateMatrixRow", "userActionRequired", ] XCTAssertTrue(required.isSubset(of: keys), "Missing required keys: \(required.subtracting(keys))") @@ -285,6 +287,8 @@ final class CLIOutputContractTests: XCTestCase { let decoded = try decoder.decode(CLIInspectResult.self, from: data) XCTAssertEqual(decoded.stateMatrixRow, InstallerStateMatrixRow.helperMissing.rawValue) XCTAssertEqual(decoded.stateMatrixPlan, [InstallerStateMatrixAction.installHelper.rawValue]) + XCTAssertEqual(decoded.planID, "plan-test") + XCTAssertEqual(decoded.snapshotID, "snapshot-test") } func testPermissionIssuesPreserveTriModeSemantics() { diff --git a/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift b/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift index b49e70c44..38f62d208 100644 --- a/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift +++ b/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift @@ -554,7 +554,7 @@ final class PrivilegedOperationsRouterTests: XCTestCase { #endif } - func testActivateVirtualHIDManagerFailsWhenPostconditionFails() async throws { + func testActivateVirtualHIDManagerDoesNotRequireRuntimeServices() async throws { #if DEBUG PrivilegedOperationsRouter.resetTestingState() var activationCalls = 0 @@ -562,6 +562,27 @@ final class PrivilegedOperationsRouterTests: XCTestCase { activationCalls += 1 } PrivilegedOperationsRouter.vhidServicesPostconditionOverride = { _ in false } + PrivilegedOperationsRouter.vhidDriverPostconditionOverride = { _ in true } + #else + throw XCTSkip("Uses DEBUG-only PrivilegedOperationsRouter test overrides") + #endif + + let coordinator = PrivilegedOperationsRouter.shared + try await coordinator.activateVirtualHIDManager() + + #if DEBUG + XCTAssertEqual(activationCalls, 1) + #endif + } + + func testActivateVirtualHIDManagerFailsWhenDriverPostconditionFails() async throws { + #if DEBUG + PrivilegedOperationsRouter.resetTestingState() + var activationCalls = 0 + PrivilegedOperationsRouter.activateVirtualHIDManagerOverride = { + activationCalls += 1 + } + PrivilegedOperationsRouter.vhidDriverPostconditionOverride = { _ in false } #else throw XCTSkip("Uses DEBUG-only PrivilegedOperationsRouter test overrides") #endif @@ -571,7 +592,7 @@ final class PrivilegedOperationsRouterTests: XCTestCase { try await coordinator.activateVirtualHIDManager() XCTFail("Expected activateVirtualHIDManager to fail when postcondition fails") } catch let PrivilegedOperationError.operationFailed(message) { - XCTAssertTrue(message.contains("VHID services postcondition failed")) + XCTAssertTrue(message.contains("VHID driver postcondition failed")) } catch { XCTFail("Unexpected error type: \(error)") } diff --git a/Tests/KeyPathTests/Lint/PostconditionLintTests.swift b/Tests/KeyPathTests/Lint/PostconditionLintTests.swift index 6573d53e6..d87feac49 100644 --- a/Tests/KeyPathTests/Lint/PostconditionLintTests.swift +++ b/Tests/KeyPathTests/Lint/PostconditionLintTests.swift @@ -25,8 +25,7 @@ final class PostconditionLintTests: XCTestCase { try assertFunctions( [ "installRequiredRuntimeServices", - "repairVHIDDaemonServices", - "activateVirtualHIDManager" + "repairVHIDDaemonServices" ], contain: "enforceVHIDServicesPostcondition" ) @@ -44,7 +43,8 @@ final class PostconditionLintTests: XCTestCase { func testVHIDDriverInstallEnforcesDriverPostcondition() throws { try assertFunctions( [ - "downloadAndInstallCorrectVHIDDriver" + "downloadAndInstallCorrectVHIDDriver", + "activateVirtualHIDManager" ], contain: "enforceVHIDDriverPostcondition" ) diff --git a/build.sh b/build.sh index e5650d312..dc3002025 100755 --- a/build.sh +++ b/build.sh @@ -2,12 +2,13 @@ set -euo pipefail # Canonical build entry point: delegates to Scripts/build-and-sign.sh -# This builds, signs, notarizes (unless SKIP_NOTARIZE=1), deploys to ~/Applications, and restarts the app. +# This builds, signs, notarizes (unless SKIP_NOTARIZE=1), deploys to /Applications, and restarts the app. # # Usage: # ./build.sh # Full build with notarization # SKIP_NOTARIZE=1 ./build.sh # Skip notarization for faster local testing # SKIP_NOTARIZE=1 SKIP_CODESIGN=1 ./build.sh # Skip notarization + codesign for local dev +# SKIP_NOTARIZE=1 SKIP_DEPLOY=1 ./build.sh # Signed lab artifact without local deployment SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd) diff --git a/docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md b/docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md new file mode 100644 index 000000000..97f58019f --- /dev/null +++ b/docs/bugs/2026-07-25-clean-install-vhid-postcondition-order.md @@ -0,0 +1,27 @@ +# VHID activation must not require later runtime services + +## Symptom + +On a fresh managed macOS 15 clone, the installer activated the bundled DriverKit +virtual HID extension successfully, then stopped before installing KeyPath's +runtime service plists. A second supported installer pass succeeded. + +## Root cause + +`activateVirtualHIDManager()` enforced the aggregate VHID-services +postcondition. That postcondition requires the VHID daemon and manager services +to be installed, loaded, healthy, and correctly configured. The clean-install +plan intentionally installs those runtime services in a later recipe, so the +activation recipe was checking state it did not own and the plan had not yet +created. + +## Invariant + +The activation operation verifies the DriverKit extension outcome it owns. The +later runtime-service installation and VHID repair operations continue to +enforce the full VHID-services postcondition before reporting success. + +`PrivilegedOperationsRouterTests` pins both sides of the boundary: activation +does not require absent future services, and activation still fails when the +driver postcondition itself is unsatisfied. `PostconditionLintTests` pins the +operation-to-postcondition classification. diff --git a/docs/testing/installer-gui-automation-capabilities.md b/docs/testing/installer-gui-automation-capabilities.md index 3c870425c..86c85cc66 100644 --- a/docs/testing/installer-gui-automation-capabilities.md +++ b/docs/testing/installer-gui-automation-capabilities.md @@ -85,9 +85,13 @@ The remaining core capabilities are: 5. Hardened macOS 26 and macOS 27 selectors before those OS versions can claim the same unattended UI coverage as macOS 15. The macOS 27 console session, secret-safe Remote Management authorization, and RFB event delivery are now - proven. The macOS 26 pointer probe is implemented and fails closed when RFB - authentication is not configured; selector capture, Remote Management base - setup, and composition remain. + proven. The macOS 27 selector driver now fails closed unless fresh + accessibility evidence matches its declared selector contract. The clean + macOS 27 source remains intentionally pristine; a separate reusable Desktop + Automation Base must carry the signed tools, desktop permissions, Remote + Management approval, and an Apple-Account-free console state. The macOS 26 + pointer probe is implemented and fails closed when RFB authentication is not + configured; selector capture, desktop-base setup, and composition remain. Clean install, repair, upgrade, reboot, uninstall, reinstall, cancellation, nightly, and pairwise entries are consumers of those foundations. They are @@ -153,6 +157,12 @@ Record the product's `runID`, `planID`, and before/after snapshot IDs in the lab timeline. These IDs join a failed lab action directly to product telemetry and must survive artifact collection. +The executable path is `keypath-lab install-runtime LEASE_ID`. It records the +inspection plan/snapshot identity before execution and the installer report's +run/plan/before/after identity afterward. The matrix's +`install-exact-artifact` checkpoint calls this command; `install-app` is only +the internal bundle-staging primitive and is not installation evidence. + ### Harness retries are not product retries The runner may retry a failed screenshot, stale semantic snapshot, or diff --git a/docs/testing/keypath-hid-fixture-first-board.md b/docs/testing/keypath-hid-fixture-first-board.md new file mode 100644 index 000000000..dec57ae10 --- /dev/null +++ b/docs/testing/keypath-hid-fixture-first-board.md @@ -0,0 +1,101 @@ +# KeyPath HID fixture: first-board runbook + +This is the shortest safe path from an unopened Waveshare ESP32-S3-Touch-LCD-1.69 to a verified +KeyPath physical keyboard fixture. Run it from the KeyPath feature worktree on the Mac. + +## Before the board arrives + +```bash +Scripts/lab/pico-hid-fixture-tool configure +Scripts/lab/pico-hid-fixture-tool doctor +``` + +The secure setup stores four SSID/password pairs plus the control token. Connection priority is +the current-location network first, followed by the established `1, 3, 2` fallback order: + +- Profile 4: `beachFi`, attempted first at the current location. +- Profile 1: `529beach`, the primary lab fallback. +- Profile 3: `iPhone`, used when its hotspot has **Maximize Compatibility** enabled. +- Profile 2: `Alpern-Home`, the non-5 GHz home fallback. +- `KEYPATH_FIXTURE_TOKEN`: a random value of at least 16 characters. + +Enter values only in Add Secret.app. Do not paste them into chat or pass them as command-line +arguments. Before hardware is attached, `doctor` should pass everything and report only +`wait board not connected`. + +## Install tomorrow + +1. Connect the board directly to the Mac with a known data-capable USB cable. +2. Run: + + ```bash + cd /Users/malpern/local-code/keypath-pico-hid-fixture + Scripts/lab/pico-hid-fixture-tool install + ``` + +3. If the Mac sees no serial device, hold **BOOT**, tap **RESET**, release **BOOT**, and rerun the + same command. + + While the application is still running, holding the middle **BOOT** button shows `BOOT HELD` + and `KEEP HOLDING + TAP BOTTOM RESET`. The display freezes when RESET transfers control to the + ROM downloader; that is expected because the fixture application is no longer running. +4. Confirm the brief Hacker Dojo splash is upright and clean, then wait for the display to progress + from `WAKING UP` to `JOINING LAB` to `READY`. +5. Confirm `READY` shows an IP address and `USB READY` or `USB WAIT`. `USB WAIT` is expected until + the board is attached to a guest or host that has enumerated its HID interface. +6. Recheck at any time with: + + ```bash + Scripts/lab/pico-hid-fixture-tool status + ``` + +The returned `firmware` and `build` fields identify exactly what is running. + +The current installation command also lays down the two-slot firmware partition table. This is the +last update that inherently requires BOOT/RESET. Once `status` reports an `otaSlot` beginning with +`ota_`, future application firmware can be installed and verified without touching the board: + +```bash +Scripts/lab/pico-hid-fixture-tool update +``` + +An OTA result is not accepted merely because upload completed: the board must reboot, restore Wi-Fi, +and report the exact newly built identity. A new image that cannot restore the control plane within +60 seconds rolls back to the previous slot automatically. + +The complete screen language, core allocation, UX sign-off list, and explicitly deferred cleanup +are documented in +[`keypath-hid-fixture-readiness.md`](keypath-hid-fixture-readiness.md). +Live first-board evidence and the remaining manual rows are tracked in +[`keypath-hid-fixture-physical-results.md`](keypath-hid-fixture-physical-results.md). + +## Failure routing + +| Evidence | Likely layer | Next action | +|---|---|---| +| No `/dev/cu...` device | Cable, port, or boot mode | Use a data cable; enter BOOT/RESET download mode; retry with `--port` if several devices exist. | +| Flash command cannot connect | Bootloader state | Hold BOOT while tapping RESET, release BOOT, and rerun. | +| Display stays dark | Power, board revision, or LCD initialization | Try another data cable/port; retain the flash output; do not start HID acceptance. | +| `JOINING LAB` persists | Wi-Fi credentials or 2.4 GHz reachability | Run `configure` again, rebuild/flash, and confirm the network is 2.4 GHz. | +| IP appears but health check fails | mDNS or Mac network route | Run `status`; test `keypath-hid-fixture.local`; inspect the router for the displayed IP. | +| `USB WAIT` persists | USB enumeration/ownership | Check System Information before attaching the device to a VM; confirm the cable carries data. | +| `ATTENTION` appears | Firmware safety state | Record the exact on-screen detail; query `status` and `trace`; do not repeat a run blindly. | +| OTA command says the fixture is not update-safe | A test is loaded/running or a release is pending | Finish or abort the run, wait for the release report, then retry. | +| OTA upload succeeds but the new build never appears | New image failed health validation or networking | Query `status` for the prior build; it should have rolled back. Use USB BOOT/RESET recovery if unreachable. | + +Production intentionally has no USB serial console: adding one would change the device exposed to +the VM and weaken the HID-only oracle. Use the display for boot/network/USB routing and use the +authenticated status and trace APIs for runtime diagnosis. + +## Physical acceptance gate + +The software build is not hardware proof. Before calling the fixture ready: + +1. Confirm display orientation, full-frame color, icon animation, touch coordinates, and tones. +2. Confirm macOS enumerates one keyboard HID interface and no serial or storage interface. +3. Run a short baseline script and compare requested reports, transfers, CRC, received text, and + release state. +4. Repeat under CPU, memory, disk, and UI load; timing pressure may switch to `HID PRIORITY`, but + reports must remain complete and ordered. +5. Verify touch abort, button abort, Wi-Fi abort, and USB removal all finish with a released-key + report before another run can arm. diff --git a/docs/testing/keypath-hid-fixture-physical-results.md b/docs/testing/keypath-hid-fixture-physical-results.md new file mode 100644 index 000000000..70041994c --- /dev/null +++ b/docs/testing/keypath-hid-fixture-physical-results.md @@ -0,0 +1,284 @@ +# KeyPath HID fixture physical acceptance results + +## 2026-07-27 first-board session + +Hardware: Waveshare ESP32-S3-Touch-LCD-1.69 revision 2. The fixture reported firmware +`0.3.0-esp32s3`, build `f24be33f2d7b`, Wi-Fi `529beach`, and a mounted native USB HID interface. +The host oracle was the isolated AppKit HID Capture Jig on macOS 27.0. + +Combined artifacts are stored with mode `0600` under +`~/.local/state/keypath-hid-capture-jig/combined/`. An interrupted attempt named +`physical-jig-burst-10ms-1` is excluded; its fixture run was remotely aborted and it is not test +evidence. + +## Automated physical results + +| Case | Result | Characters | Reports | Timing evidence | +|---|---:|---:|---:|---| +| Cooperative baseline | Pass | 34 / 34 | 68 / 68 | 0 late; 32 us maximum lateness | +| Shift and symbols | Pass | 36 / 36 | 72 / 72 | 0 late; modifiers released | +| 10 ms burst | Pass | 240 / 240 | 480 / 480 | 0 late; 40 us maximum | +| 5 ms burst | Pass | 600 / 600 | 1,200 / 1,200 | 0 late; 48 us maximum | +| 4 ms boundary | Pass | 1,200 / 1,200 | 2,400 / 2,400 | 0 late; 44 us maximum | +| 4 ms with 8 saturated host cores | Pass | 1,200 / 1,200 | 2,400 / 2,400 | 0 late; 48 us maximum | +| 5 ms with CPU and storage contention | Pass | 1,200 / 1,200 | 2,400 / 2,400 | 0 late; 65 us maximum | +| Sustained 5 ms with 8 saturated host cores | Pass | 3,600 / 3,600 | 7,200 / 7,200 | 3 late within the explicit 5-report / 5,000 us stress budget; 2,165 us maximum | +| Remote abort during active typing | Pass | Correct 10-character prefix | Stopped at 20 / 6,800 | Final release transfer completed; no stuck key or modifier | +| Post-flash cooperative baseline | Pass | 34 / 34 | 68 / 68 | Safety/button-feedback build; exact output and final release | +| Physical BOOT abort | Pass | Correct 1,332-character prefix | Stopped at 2,663 / 6,800 | Final release completed; no repeat, duplicate-down, or unmatched-up events | +| Physical touchscreen abort | Pass | Correct 668-character prefix | Stopped at 1,335 / 6,800 | Final release completed; no repeat, duplicate-down, or unmatched-up events | + +Every passing completion case had exact output, the full requested report count, no duplicate +downs, no AppKit repeat events, no unmatched ups, and an empty final key/modifier set. + +## Boundaries and defects found + +- A 3 ms diagnostic delivered all 1,200 characters and all 2,400 reports in order, but 1,601 + reports missed the 1 ms deadline and the worst miss was 6,727 us. The compiler now rejects + intervals below 4 ms instead of promising a cadence the full-speed USB path cannot guarantee. +- A 4 ms CPU-plus-storage boundary run delivered exact output but recorded three deadline misses. + The zero-lateness stress floor is therefore 5 ms; load runs can use explicit, recorded timing + budgets without weakening output, ordering, count, or release checks. +- The first sustained load attempt exposed two HID Capture Jig crashes in text drawing. The jig + was snapshotting and redrawing on every event and the launcher could leave multiple instances + racing on one RPC directory. Drawing is now bounded to 20 FPS, fonts and paragraph styles are + cached, terminal snapshots stop refreshing, and the launcher enforces one process. The sustained + rerun passed and produced no new crash report; completed-jig CPU returned below 1%. +- Load-aware capture timeouts now include a 50% or 10-second drain margin so WindowServer can + deliver queued events before the oracle freezes its fail-closed result. +- Wi-Fi loss previously rotated profiles without stopping an active script, contradicting the + safety checklist. The new firmware aborts only armed/running scripts on a connected-to-disconnected + transition and queues the same all-keys-released report as other abort paths. + +## Remaining manual gate + +The following checks require a person to manipulate or observe the board and are not yet physical +proof: + +- During active typing, unplug and reconnect USB; verify the unmount error, remount, and final + release report before another run arms. +- The held-BOOT overlay is proven: it remained visible and instructed `KEEP HOLDING + TAP BOTTOM + RESET`. The POWER overlay and tone are proven, including restoration of the prior `TEST PASSED` + state. The post-reset overlay is also visually confirmed, followed by a normal return to `READY`. + Confirm the protected-motion state remains legible. +- Where the lab network can be intentionally interrupted, verify physical Wi-Fi loss aborts the + active run and safely releases the keyboard. + +Do not call the first-board gate complete until these manual rows are recorded. + +The `physical-jig-button-abort-post-flash-1` attempt is excluded: its 30-second external-action +window expired without a physical button event, after which the fixture completed normally. It is +not evidence of either a passing or failing physical-button abort. + +## Open repeated-key research campaign + +The original report of repeated keypresses while compiling a large Swift program remains distinct +from the Shift-demotion defect below. `Scripts/lab/physical-hid-repeat-matrix` now defines the strict, +resumable campaign needed to investigate it. The plan covers repeated, alternating, rolling, and +shifted corpora at 50, 10, and 5 ms pacing under calm CPU, bounded CPU saturation, generated Swift +type-check pressure, and combined compiler/CPU pressure. + +The campaign starts every load only after the ordinary three-sample host admission and capture arm. +It records exact output, inserted/deleted/substituted characters, AppKit duplicate-down/repeat/up +counters, focus and release state, firmware timing, and the full bounded report trace. It contains +no demo-mode bypass. This campaign is implemented and host-tested but has not yet emitted physical +HID; do not claim a KeyPath result until its strict case artifacts exist. + +## 2026-07-27 shifted-key CPU matrix + +Firmware build `ccd910cb18d9` ran the same 20-cycle shifted corpus +`aZ9!?-_=+[]\n` at a 50 ms character interval under four controlled host CPU levels. The Jig +performed its normal three-sample calm admission before each case; bounded CPU workers started only +after capture was armed. Artifacts are under +`~/.local/state/keypath-hid-capture-jig/load-matrix/20260727-1925/`. + +| CPU workers | Measured CPU avg / max | Received | Wrong characters | Oracle state | +|---:|---:|---:|---:|---| +| 0 (calm control) | 29.8% / 67.1% | 240 / 240 | 12 | Focus retained; released | +| 2 (moderate) | 40.0% / 63.3% | 240 / 240 | 5 | Focus retained; released | +| 6 (high) | 70.6% / 81.1% | 240 / 240 | 4 | Focus retained; released | +| 10 (saturated) | 90.9% / 96.2% | 37 / 240 | 204 including missing tail | Focus lost; Shift release unobserved | + +The fixture submitted all 480 requested reports plus its final release transfer in every case, with +zero device-side late reports. In the calm-through-high cases, every corruption was an unshifted +version of the expected character (`Z→z`, `!→1`, `?→/`, `_→-`, or `+→=`). The error rate did not +increase with CPU load: the calm control had the most errors and the high case the fewest. This is +evidence that CPU load up to the measured 81.1% peak is not required for, and did not amplify, the +modifier-order defect in this matrix. + +Full saturation crossed a different boundary: the AppKit oracle became unresponsive long enough to +lose focus and did not observe most input or the final Shift release. Because the independent oracle +lost validity, that row is a system/harness starvation result, not clean KeyPath product attribution. +After all ten workers were terminated, a calm recovery baseline passed 34 / 34 characters and 68 / +68 reports with no stuck key or modifier. KeyPath, Kanata, VHID, the Jig, Wi-Fi, and USB all remained +healthy. + +### Kanata-off isolation attempt + +The installed `keypath-cli service stop` could not stop the system service because its +`SystemFacade` calls `launchctl kill SIGTERM system/com.keypath.kanata` directly as the unprivileged +user instead of routing through KeyPath's privileged helper. The native **Stop KeyPath Runtime…** +menu command did use the privileged path and successfully stopped Kanata without disabling VHID. + +With Kanata confirmed stopped, the fixture submitted all 480 reports for the same 20-cycle shifted +corpus with zero device-side lateness, but the Jig received 0 / 240 characters. This Mac therefore +does not currently route the physical fixture directly to applications when Kanata is absent, so +the result cannot distinguish a Kanata modifier-order defect from device admission/routing below +Kanata. It does establish that the prior corruptions came through the working Kanata/VirtualHID +delivery path. KeyPath was relaunched afterward, and a recovery baseline passed 34 / 34 characters +and 68 / 68 reports with all keys and modifiers released. + +## 2026-07-28 Shift-matrix readiness and smoke result + +Firmware build `4d1cb1cb54c4` is running from a valid OTA slot and rotated successfully from its stale +`192.168.4.21` address to `529beach` at `10.0.0.47`. Authenticated status reports mounted USB, +healthy live display frames, and a completed boot splash. The complete host, core, client, and QEMU +suite passes. + +The diagnostic compiler now independently varies Shift lead and release lag around a fixed key hold. +The combined runner persists the firmware's exact report trace alongside Jig focus, event, output, +release, and timing evidence. Three three-cell smoke attempts were excluded before HID execution: +the first could not acquire Jig focus after reopening the app, the second was rejected by elevated +macOS memory pressure, and the third timed out at the 0.9x-per-core competing-load ceiling while the +operator was on a video call. The third artifact is +`~/.local/state/keypath-hid-capture-jig/modifier-matrix/shift-smoke-20260728T140721Z/summary.json`; +it records zero completed cases and zero submitted reports. These are fail-closed harness admissions, +not KeyPath test outcomes. + +Focus orchestration now preserves an existing healthy Jig, waits for resources without activating it, +and requests focus only immediately before arm. Physical runs require exclusive use of the active +desktop because real USB keyboard input cannot target a background application. + +The admitted smoke run `shift-smoke-20260728T222037Z` completed all three cells: + +| Shift lead | Release lag | Output | Firmware trace | Timing | +|---:|---:|---:|---:|---:| +| 0 ms | 0 ms | 24 / 24 exact | 48 reports | 0 late; 34 us maximum | +| 4 ms | 0 ms | 24 / 24 exact | 58 reports | 0 late; 35 us maximum | +| 8 ms | 0 ms | 24 / 24 exact | 58 reports | 0 late; 34 us maximum | + +Every cell retained Jig focus and ended with no pressed keys or active modifiers. The combined +summary is +`~/.local/state/keypath-hid-capture-jig/modifier-matrix/shift-smoke-20260728T222037Z/summary.json`. +This proves the harness can execute and correlate the timing variants, but 24 characters per cell +are too few to supersede the earlier 240-character calm failure. + +### Full 5x5 Shift lead/release matrix + +The full matrix completed all 25 timing cells with a 60-character shifted corpus per cell, a fixed +8 ms key hold, and a 50 ms character interval. It covered Shift lead and release-lag values of 0, +2, 4, 8, and 12 ms. Across 1,500 received characters and 4,000 firmware trace reports, every cell +retained Jig focus, every key and modifier was released, and the fixture recorded zero late reports; +maximum firmware lateness was 175 us. + +| Shift lead | Exact cells | Wrong characters | Shift demotions | Result | +|---:|---:|---:|---:|---| +| 0 ms | 1 / 5 | 10 / 300 | 10 | Defect reproduced in 4 / 5 release-lag variants | +| 2 ms | 5 / 5 | 0 / 300 | 0 | Exact | +| 4 ms | 5 / 5 | 0 / 300 | 0 | Exact | +| 8 ms | 5 / 5 | 0 / 300 | 0 | Exact | +| 12 ms | 5 / 5 | 0 / 300 | 0 | Exact | + +Every mismatch was a Shift demotion: `_→-`, `?→/`, `Z→z`, or `!→1`. Release lag did not show a +monotonic relationship with corruption: the 0 ms-lead / 4 ms-release cell was exact, while the +other four zero-lead cells produced between one and five demotions. All 20 cells with a 2 ms or +greater Shift lead were exact (1,200 / 1,200 characters). + +This isolates the observed defect to modifier assertion timing in the working +fixture → Kanata → VirtualHID → application path. CPU load is not required, firmware deadline +misses are not implicated, and adding a single 2 ms lead before the shifted key removed every +observed corruption in this matrix. The result identifies a reliable reproduction boundary; it +does not yet prove whether the product-side fault is in Kanata's physical-input handling or the +VirtualHID delivery boundary. + +The result is combined from these fail-closed artifacts: + +- `shift-full-20260728T222602Z`: 10 valid cells (0 and 2 ms lead rows). The next cell was not + executed after the fixture's Wi-Fi control service timed out. +- `shift-full-resume-20260728T223504Z`: 14 valid cells (4 and 8 ms rows plus four 12 ms cells). The + final cell was not executed because the Jig's macOS memory-pressure admission timed out. +- `shift-full-final-20260728T224119Z`: the single missing 12 / 12 ms cell, retried only after three + consecutive healthy resource samples. + +The Wi-Fi timeout and memory-pressure rejection are harness-infrastructure events, not product test +outcomes. Neither submitted HID reports for its blocked cell, and only the completed, independently +validated cells are included in the 25-cell result. + +## 2026-07-28 demo/control reliability evidence + +Two audience-demo attempts are excluded from KeyPath conclusions. The first capture expected 1,200 +characters and received 18; the second expected 120 and received 3 before the control request +failed and the old runner finalized early. Both captures ended with no pressed keys or modifiers, +but neither completed the synchronized protocol. Their artifacts are: + +- `~/.local/state/keypath-hid-capture-jig/artifacts/physical-usb-cycle-instructed-20260729T010245Z-failed.json` +- `~/.local/state/keypath-hid-capture-jig/artifacts/physical-demo-immediate-20260729T011124Z-failed.json` + +These exposed harness defects: transient ESP control loss could discard later evidence, the runner +could finalize before confirming release, the showroom path rebuilt/re-signed the Jig, and the demo +depended on a live Wi-Fi start request. The runner now writes an atomic inconclusive artifact on +every exception, retries bounded control operations, aborts best-effort, waits for the Jig to +observe release, and records control degradation separately. The showroom path now reuses a +source-hashed Jig app and starts a fixed on-device script with top-power then touch, so Wi-Fi is not +on the critical start path. + +A read-only control soak against the still-installed pre-diagnostic firmware then completed 20/20 +status requests with a 206.745 ms p95, 219.339 ms maximum, no observed reboot, and no build change. +The artifact is +`~/.local/state/keypath-hid-fixture/control/control-soak-20260729T013236Z.json`. An earlier version +of the soak measured 3.2–5.3 second calls because it repeated `.local` resolution for every request; +that result is retained but superseded. Discovery and ESP endpoint latency are now measured +separately. The next firmware install will add reset, heap, Wi-Fi, HTTP restart/request, and handler +latency counters to this evidence. + +The USB-removal gate remains unproven on this board without a battery or other independent power: +disconnecting its sole USB-C cable removes both data and power, so it cannot execute or report the +reconnect safety path. Do not count a power-cycle as a USB-unmount acceptance result. + +### Post-update control soak + +Authenticated OTA installed build `f92209f3ea52` into valid slot `ota_1`; the fixture returned to +idle with mounted USB, healthy display frames, completed splash, and an update-safe release state. +The first diagnostic-bearing soak then passed 120/120 status requests with no failure, reset, build +change, or HTTP server restart. Host-observed latency was 28.153 ms median, 93.715 ms p95, and +105.845 ms maximum. Firmware handler latency peaked at 7.465 ms; the remaining time is host/network +transport rather than response construction. Free heap remained 8.33 MB and minimum free heap was +8.29 MB. The artifact is +`~/.local/state/keypath-hid-fixture/control/control-soak-20260729T014347Z.json`. + +This validates the updated control plane under a 120-request idle soak. It does not yet validate +the two-step offline demo's physical buttons/touch/output or control responsiveness concurrent with +a high-rate HID run; those require an exclusive desktop window because the next action emits real +keyboard reports. + +### Exact showroom proof + +With exclusive desktop ownership and the Jig's three-sample resource gate green, the authenticated +showroom path passed end to end. ESP32 build `f92209f3ea52` submitted all 40 locally timed USB HID +reports for `KeyPath demo OK` plus Return. The independent Jig captured all 16 expected characters, +reported no capture issues, and observed every key and modifier released. The fixture trace also +contained all 40 reports with no lateness beyond the configured zero-tolerance budget. Evidence is +stored in `~/.local/state/keypath-hid-capture-jig/artifacts/showroom-live.json`. + +The packaged zero-touch command was then validated separately in 7.1 seconds with the same 40/40 +reports, 16/16 characters, clean release, and exact independent result. Its timestamped evidence is +`~/.local/state/keypath-hid-capture-jig/artifacts/showroom-20260729T030744Z.json`. + +The saved showroom command now runs 14 cycles for a 20.006-second active presentation. Firmware +build `612941a38ed8` submitted all 560 expected reports; the Jig captured all 224 expected +characters exactly, observed no capture issues, and verified every key and modifier released. The +run deliberately used `admissionMode: demo-bypass`, so it is presentation evidence rather than a +strict KeyPath acceptance result. Its artifact is +`~/.local/state/keypath-hid-capture-jig/artifacts/showroom-20260729T053642Z.json`. + +The immediately preceding attempt was rejected before HID emission because an older Jig executable +was still resident after the app bundle had been rebuilt. Showroom and offline demo launches now +restart the source-hashed Jig before arming, preventing an old in-memory protocol or admission policy +from being mistaken for the current build. The rejected attempt retained an inconclusive artifact +and confirmed all keys and modifiers released. + +The preceding offline attempt is not counted as a product or fixture failure: the Jig remained +focused and ready but received zero events, while the fixture recorded no top-power or touch event. +That proves only that the physical two-step trigger was not completed during its 30-second window. +The repeatable audience path is now `Scripts/lab/hid-capture-jig-tool showroom`; it requires no +operator timing and retains the full combined artifact. diff --git a/docs/testing/keypath-hid-fixture-readiness.md b/docs/testing/keypath-hid-fixture-readiness.md new file mode 100644 index 000000000..ab7d74cef --- /dev/null +++ b/docs/testing/keypath-hid-fixture-readiness.md @@ -0,0 +1,133 @@ +# KeyPath HID fixture readiness and on-device UX + +## Current readiness + +The firmware and Mac workflow are ready for the first physical board. Host tests, client tests, +installer tests, campaign-runner tests, the ESP32-S3 QEMU boot smoke test, and the credential-bearing +production build pass. This proves the software shape; it does not prove the delivered LCD, touch +controller, buzzer revision, native USB behavior, or report timing. + +There are no additional software or visual changes required before the first flash. The remaining +release gate is the physical acceptance checklist in +[`keypath-hid-fixture-first-board.md`](keypath-hid-fixture-first-board.md). + +## Operator journey + +### Install or update + +Connect the board to the Mac with a data-capable USB cable, then run: + +```bash +cd /Users/malpern/local-code/keypath-pico-hid-fixture +Scripts/lab/pico-hid-fixture-tool install +``` + +The command checks the Mac and encrypted credentials, runs all software tests, builds production +firmware, finds an unambiguous serial device, flashes it, and verifies the authenticated status API. +It does not ask the operator to copy credentials into a terminal. + +### Fast showroom proof + +When the Mac is exclusively available for real keyboard input, run: + +```bash +cd /Users/malpern/local-code/keypath-pico-hid-fixture +Scripts/lab/hid-capture-jig-tool showroom +``` + +This is the zero-touch path: it opens and focuses the source-hashed Jig, then immediately asks the +ESP32 to run 14 lively `KeyPath demo OK` plus Return cycles over real USB HID. At the 60 ms key +cadence and 469 ms cycle gap, the active sequence lasts 20.006 seconds and produces 224 expected +characters from 560 reports. Demo mode deliberately bypasses the Mac CPU, load, memory-pressure, +and thermal admission gate so an audience is never left waiting for the machine to become idle. It +still requires exclusive keyboard focus, bounded execution, exact independent Jig capture of every +character, and verified release of every key and modifier. + +The bypass makes this demonstration evidence only: it must never be used to accept or reject a +KeyPath build. `physical-hid-capture-run` remains strict by default, and matrix/acceptance callers do +not pass `--demo-mode`; they still require three clean host-resource samples. A timestamped combined +artifact records `admissionMode: demo-bypass` or `strict`. Use `hid-capture-jig-tool demo` only for +the separate offline top-power-then-touch presentation; it uses the same demo-only bypass and the +same 14-cycle, 20.006-second active sequence. + +### Cold-boot screen sequence + +| Approximate time | Display | Operator meaning | +|---|---|---| +| 0–0.26 s | Hacker Dojo mark reveals | Display task is alive. | +| 0.26–1.20 s | Mark, `HACKER DOJO`, and `MOUNTAIN VIEW / SINCE 2009` | Short identity splash; USB, Wi-Fi, and HID initialization continue concurrently. | +| 1.20–1.65 s | Splash expands and dissolves | Transition into live fixture state. | +| After 1.65 s | `WAKING UP` or `JOINING LAB` | Runtime is starting or trying the named Wi-Fi profile. | +| Connected | `READY`, IP address, and `USB READY` or `USB WAIT` | Control plane is reachable; USB state is explicit. | + +The splash is intentionally brief and has no image-decoding work. It is built from LVGL vector +primitives and runs on the display task. It must never postpone HID, USB, or networking work. + +### Test-state language + +| Display state | Meaning | Operator action | +|---|---|---| +| `READY` | Wi-Fi is connected and no test is armed. | Safe to load a script. | +| `SCRIPT LOADED` | Script passed admission and CRC validation. | Arm only after observers are ready. | +| `ARMED` | Fixture is ready to emit reports. | Start the synchronized run or abort. | +| `DEMO ARMED` | The fixed offline demo is loaded; no key has been sent. | Tap the screen only after the Jig says it is armed. | +| `TYPING` or `SENDING KEYS` | Reports are being emitted from locally timed script data. | Avoid disconnecting USB unless testing removal. | +| `HID SENT / CHECK JIG` | The offline demo finished sending reports. | Read the independent Jig result; this is not itself a pass. | +| `HID PRIORITY` | Timing pressure was detected and display work was reduced to 8 FPS. | The test may continue; inspect trace timing afterward. | +| Pass, fail, or inconclusive result | Campaign supplied a classified outcome and metrics. | Preserve the campaign evidence. | +| `ATTENTION` | Safety or runtime error. | Record the detail, query `status` and `trace`, and do not blindly retry. | + +## Work split across both ESP32-S3 cores + +- **Core 1:** the priority-20 HID scheduler. It owns locally timed keyboard reports and uses a + short polling loop only while a script is running. +- **Core 0:** priority-18 TinyUSB service, priority-8 HTTP control, priority-6 display, priority-5 + button and buzzer work, and the ESP-IDF Wi-Fi task. +- Animation quality automatically steps from showcase to active to `HID PRIORITY`; visual fidelity + is expendable, report timing is not. + +The ESP-IDF TCP/IP task currently has no hard affinity. That is acceptable for the first-board +baseline because the HID task has higher priority, but it is a deliberate measurement item during +load acceptance. Pin TCP/IP to Core 0 only if traces show Core 1 interference; do not make that +change without comparing before-and-after timing evidence. + +## Physical UX sign-off + +Record pass, fail, or notes for each item before calling the device finished: + +- Splash is upright, centered, legible, and feels brief rather than like a boot delay. +- Splash dissolves cleanly into the live scene without a black flash or stale frame. +- `JOINING LAB` names the network currently being attempted. +- `READY` shows a readable IP address and unambiguous USB status. +- Color, icons, and motion distinguish loaded, armed, running, protected, complete, and error. +- `HID PRIORITY` visibly calms the display without making it appear frozen. +- Ordinary tests: touch and BOOT abort armed or running scripts. Offline demo: top power arms, + touch starts, and BOOT/touch during execution aborts. +- Tones are audible but not distracting; current-board revision 2 uses GPIO42. +- Pass, fail, and inconclusive remain understandable without reading the HTTP response. +- At typical viewing distance, there is no clipping, tearing, unreadably small text, or excessive + brightness. + +## Configuration knobs + +The target's `KeyPath HID fixture` menu exposes: + +- Board revision: `2` by default; use `1` only if the delivered board has the older buzzer wiring. +- Brightness: `85%` by default. +- State-change tones: enabled by default. +- Reduced motion: available without removing state, color, or progress feedback. + +Do not tune these before seeing the physical display. Capture the default result first so changes +address observed hardware behavior rather than simulator assumptions. + +## Deferred improvements, not first-flash blockers + +- Pin the TCP/IP task to Core 0 if physical traces demonstrate scheduler interference. +- Tune brightness, tone duration, touch coordinates, or splash timing from observed hardware. +- Capture a short reference video after visual acceptance so later firmware changes can be compared. +- Add factory-reset or over-the-air update UX only if repeated physical maintenance demonstrates a + real need. USB flashing remains simpler and safer for this lab-only fixture. + +Avoid adding USB serial, storage, or a composite debug interface. The device must continue to look +like one ordinary keyboard to the VM; status and trace diagnostics belong on the independent Wi-Fi +control plane. diff --git a/docs/testing/p02-virtualhid-output-proof.md b/docs/testing/p02-virtualhid-output-proof.md index 00d06ff30..e39ebf4a1 100644 --- a/docs/testing/p02-virtualhid-output-proof.md +++ b/docs/testing/p02-virtualhid-output-proof.md @@ -197,14 +197,33 @@ P02 passes only when all of the following are true in one disposable lease: VirtualHID daemon running, Kanata running, and TCP readiness responding. 2. A deterministic configuration maps physical `q` to virtual `w`. 3. The harness focuses an independent target app with an observable text value. -4. `keypath-lab desktop-type LEASE --text q` reports the native `vnc-key` - delivery method. -5. The target app's accessibility value changes to `w`, not `q`. +4. The guest HID inventory contains the explicitly named physical keyboard; + VNC, Peekaboo, and guest-synthesized events are not accepted as the source. +5. While the operator holds physical `q`, the overlay's `keycap-code-12` + accessibility value is `pressed` or `held`. +6. The target app's accessibility value changes to `w`, not `q`. -Step 5 is the functional assertion. Driver metadata, a successful click, and a +Step 6 is the functional assertion. Driver metadata, a successful click, and a KeyPath-local input monitor are useful preparation evidence but are not output proof. +Use the two-stage physical session so P02 output, P03 overlay state, and P04 +first-confirmation timing come from the same physical event: + +```bash +Scripts/lab/keypath-lab run LEASE -- Scripts/lab/physical-remap-session prepare \ + --output .keypath-lab/scenario-output/physical-remap-session \ + --device-match mWave +Scripts/lab/keypath-lab run LEASE -- Scripts/lab/physical-remap-session observe \ + --output .keypath-lab/scenario-output/physical-remap-session +``` + +Run `observe`, then hold the physical `q` key until it completes. The watcher +clears and focuses TextEdit itself, rejects a missing guest-visible keyboard, +and writes separate machine-readable P02, P03, and P04 results. A literal `q` +is a product failure only after the named physical HID and independently ready +runtime were admitted; a timeout remains an environment precondition failure. + ## Resume path Continue with one explicit path: diff --git a/docs/testing/remote-installer-lab.md b/docs/testing/remote-installer-lab.md index 53194e767..92e9a1228 100644 --- a/docs/testing/remote-installer-lab.md +++ b/docs/testing/remote-installer-lab.md @@ -73,10 +73,20 @@ that host resources and provider inventory remain isolated. ## Lifecycle -Creation never syncs the current worktree. It exports the explicit commit with -`git archive`, adds the installer and source metadata, uploads that immutable -payload, and atomically initializes a clean synthetic archive on the external -lab volume. Each lease receives its own clean checkout cloned from that archive. +Creation never syncs the current worktree. It exports the explicit product +commit plus the committed `Scripts/lab` tree from a separately recorded harness +commit with `git archive`, adds the installer and source metadata, uploads that +immutable payload, and atomically initializes a clean synthetic archive on the +external lab volume. The archive key includes both commits, so a harness change +cannot silently reuse or mutate an older product-and-installer cache. Each lease +receives its own clean checkout cloned from that archive. + +When the exact product-and-installer archive already exists, a harness-only +change is derived on the mini: the controller uploads only the committed +`Scripts/lab` overlay (and regenerated exact managed policy when needed), while +the mini clones the verified product cache without hard links and publishes a +new harness-qualified archive atomically. This avoids retransferring the signed +product payload while keeping every resulting archive immutable and auditable. CrabBox's claim therefore binds one disposable lease to one stable repository root, and every run, download, and destructive stop executes from that exact claimed checkout. The interface refuses to sync if Git reports tracked or @@ -100,6 +110,7 @@ Scripts/lab/keypath-lab create \ Scripts/lab/keypath-lab list Scripts/lab/keypath-lab status cbx_example Scripts/lab/keypath-lab install-app cbx_example +Scripts/lab/keypath-lab install-runtime cbx_example Scripts/lab/keypath-lab nameplate cbx_example enable Scripts/lab/keypath-lab run cbx_example -- sw_vers Scripts/lab/keypath-lab secure-dialog-input cbx_example \ @@ -223,6 +234,43 @@ inter-key delay. Parallels can drop characters when a complete credential is sent as one unpaced event batch. The command deliberately reports credential transport only; the RFB probe is the required independent postcondition. +When building a new versioned macOS 27 desktop base, an inherited login +keychain can still use the source image's old password after the lab has +aligned the disposable account password. Repair only that owned candidate: + +```bash +Scripts/lab/keypath-lab reset-desktop-keychain cbx_example +Scripts/lab/keypath-lab reboot-guest cbx_example +``` + +`reset-desktop-keychain` is admitted only for a macOS 27, Parallels, +desktop-enabled lease with a verified console login. It creates a fresh login +keychain using the encrypted guest credential and moves the previous keychain +to a timestamped `.before-desktop-base-*` backup. Do not use it in ordinary +scenario clones and do not delete the backup until the candidate has passed +fresh-clone validation. + +`reboot-guest` is an owned provider transport, not proof that the desktop is +ready. After it returns, independently verify the console user, privacy +permissions, and whichever runtime postcondition the scenario requires. + +For non-secret navigation of an expected disposable-guest dialog, the +controller can send one bounded Parallels key event: + +```bash +Scripts/lab/keypath-lab console-key cbx_example --key 23 +Scripts/lab/keypath-lab console-key cbx_example --key 36 +``` + +Parallels uses X11-style key codes; `23` is Tab and `36` is Return. An optional +`--modifier CODE` holds that modifier only for the duration of the key event. +The command is admitted only for an owned, desktop-enabled macOS 26 or 27 +Parallels lease. It records transport, not UI success. Use it only when the +expected dialog is already visible, and verify the intended state through a +fresh screenshot, accessibility snapshot, or runtime postcondition. Never use +it for credentials; use `secure-console-submit` for the focused authorization +field. + On July 16, 2026, the supported UI flow enabled Remote Management and VNC control for `keypathqa`, and the probe moved the guest cursor from `684.703125 141.1875` to `80 60`. That proves event posting for this disposable @@ -231,8 +279,43 @@ seed; do not infer delivery from the visible toggle alone. For a console-ready candidate base, run `desktop-bootstrap --install-tools` once before capturing its checkpoint. It installs Python 3 as well as -Peekaboo and mcporter, then records the console-user and Peekaboo evidence. -This is base provisioning, not a per-scenario setup step. +Peekaboo and mcporter into the console user's `~/.local` tree from pinned +release archives with checked SHA-256 digests; the clean source does not need +Homebrew. + +The bootstrap also installs a stable, ad-hoc-signed +`~/Applications/Peekaboo Lab Host.app` and a per-user login agent. macOS 27's +Screen Recording picker accepts applications, not Peekaboo's standalone +command-line executable. Approve Screen Recording and Accessibility for this +lab-only host application through the real logged-in System Settings UI. The +application owns the long-lived Peekaboo bridge, so SSH-driven tests use that +approved desktop process instead of granting broad capture access to every +remote shell. Preserve the same application path, identifier, and bytes when +capturing the base; changing any of them invalidates the reusable TCC identity. + +The bootstrap then records the console-user and Peekaboo evidence. This is base +provisioning, not a per-scenario setup step. A successful install or a visible +toggle is not enough: require `peekaboo permissions status --json`, a real +semantic capture, and a new disposable clone from the captured base. + +Register the promoted Parallels source under the distinct CrabBox template +alias `keypath-macos-27-desktop`. The controller selects that alias only for +macOS 27 leases created with `--desktop`; headless macOS 27 leases continue to +clone `keypath-macos-27`, preserving the pristine loginwindow source. A desktop +lease manifest must record `base_name=keypath-macos-27-desktop` before its +fresh-clone evidence is accepted. + +On a fresh clone, admit the inherited console state without replaying +credential-bearing setup: + +```bash +Scripts/lab/keypath-lab verify-console-login cbx_example +``` + +This read-only check requires `/dev/console` to be owned by `keypathqa` and +records `console_login_method=inherited-base`. Only then run the RFB pointer +probe and desktop bootstrap. Use `console-login` for candidate construction or +repair, not to make a fresh-base acceptance test pass. ### Disposable desktop identity with Nameplate @@ -412,12 +495,23 @@ injection. The command proves delivery only; the scenario must separately observe its expected application or runtime postcondition. P01 used a captured key chip in KeyPath's Input Capture Experiment as that postcondition. -`install-app` expands the staged ZIP into `/Applications` on the disposable -guest. Tart uses the base image's noninteractive sudo contract. Parallels uses -the same passwordless `prlctl exec` guest-control channel CrabBox already uses -to prepare the disposable clone, scoped to the exact provider resource recorded -in the owned lease manifest. Neither path changes the base image or stores a -guest password. +`install-app` only expands the staged ZIP into `/Applications` on the disposable +guest; it does not prove KeyPath installation. Tart uses the base image's +noninteractive sudo contract for that bundle-staging step. Parallels uses the +same passwordless `prlctl exec` guest-control channel CrabBox already uses to +prepare the disposable clone, scoped to the exact provider resource recorded in +the owned lease manifest. Neither path changes the base image or stores a guest +password. + +`install-runtime` is the reliability-test entry point. It stages the bundle once, +captures `keypath-cli system inspect --json`, invokes the product-owned +`keypath-cli system install --json` plan once, and records the preflight plan and +snapshot IDs plus the install report's run, plan, and before/after snapshot IDs. +It then compares KeyPath's claimed result with `assert-runtime-state` and a fresh +product inspection. If macOS approval is required, it returns a waiting outcome +and preserves the lease. Resuming after the visible approval verifies those +postconditions without repeating the installer mutation. A controller +interruption after mutation starts is fail-closed and requires artifact review. ## Installer scenarios @@ -433,7 +527,9 @@ Scripts/lab/keypath-lab scenario cbx_example reboot-persistence-before # Reboot the disposable guest through the approved lab workflow. Scripts/lab/keypath-lab scenario cbx_example reboot-persistence-after Scripts/lab/keypath-lab scenario cbx_example uninstall -Scripts/lab/keypath-lab scenario cbx_example cancellation-failure +Scripts/lab/keypath-lab scenario cbx_example cancellation-recovery-before +# Open and capture KeyPath's real uninstall confirmation, then click Cancel. +Scripts/lab/keypath-lab scenario cbx_example cancellation-recovery-after Scripts/lab/keypath-lab scenario cbx_example artifact-capture Scripts/lab/keypath-lab scenario cbx_example macos-27-regression Scripts/lab/keypath-lab artifacts cbx_example @@ -441,13 +537,99 @@ Scripts/lab/keypath-lab artifacts cbx_example The scenario set covers clean installation, every macOS approval gate, helper/daemon and TCP health, launch, repair/reinstall, reboot persistence, -uninstall, cancellation/failure rendering, and final artifact capture. Approval -and cancellation cases intentionally give an operator a controlled observation -point rather than attempting to bypass macOS security UI. Never place Apple IDs, +uninstall, cancellation/recovery, and final artifact capture. The cancellation +pair captures a ready baseline, gives the operator a controlled observation point +for the real uninstall confirmation, verifies that Cancel preserved the app and +configuration, injects a non-destructive Kanata-service failure, and performs one +checkpointed repair. Approval and cancellation cases intentionally retain that +operator observation point rather than attempting to bypass macOS security UI. +Never place Apple IDs, passwords, private keys, TCC databases, or other credentials in a scenario or artifact bundle. CrabBox does not redact collected files automatically; inspect every bundle before sharing or publishing it. +## Nightly and weekly matrix plans + +Generate the unattended curated diagonal and the broader deterministic pairwise +plan without creating a lease: + +```bash +Scripts/lab/scenario-matrix --cadence nightly --output /tmp/keypath-nightly.json +Scripts/lab/scenario-matrix --cadence weekly --output /tmp/keypath-weekly.json +``` + +The planner rejects a case whose predicted runtime exceeds the requested lease +TTL, assigns work to waves within Tart and Parallels capacity, and serializes +every job that uses the shared macOS 26 enrollment identity even if Parallels +has spare compute capacity. Every VM job carries `destroy-owned-lease` as its +mandatory finalizer. The generated JSON is deterministic apart from its +timestamp and can be retained beside the consolidated scenario report. + +Operator-visible cancellation and physical-HID cases are excluded from +unattended plans. They require separate explicit flags so a scheduled job can +never silently treat a missing human observation or physical keypress as a +pass: + +```bash +Scripts/lab/scenario-matrix --cadence weekly --include-operator +Scripts/lab/scenario-matrix --cadence weekly --include-physical +``` + +These flags change admission only; they do not bypass the operator or physical +checkpoint. The executor must still stop at the named step and retain the same +result, failure-ownership, artifact-sanitization, and owned-cleanup contracts. + +Execute a retained plan with the exact candidate and commit. The runner writes +an owner-only checkpoint after every transition, runs jobs in each provider-safe +wave concurrently, streams those transitions to the dashboard, downloads the +sanitized evidence before cleanup, and refuses to call a VM job passed until its +owned lease has been destroyed successfully: + +```bash +campaign=.keypath-lab/matrix/weekly-$(date +%Y%m%d-%H%M%S) +mkdir -p "$campaign" +Scripts/lab/scenario-matrix \ + --cadence weekly \ + --include-operator \ + --output "$campaign/plan.json" +Scripts/lab/scenario-matrix-runner \ + --plan "$campaign/plan.json" \ + --state "$campaign/state.json" \ + --artifacts "$campaign/artifacts" \ + --commit "$(git rev-parse HEAD)" \ + --installer dist/KeyPath.zip \ + --fixture /path/to/KeyPath-beta3.zip \ + --dashboard-updater ../keypath-lab-state-dashboard/Scripts/lab/update-matrix-dashboard \ + --dashboard-state /tmp/keypath-matrix-state.json +``` + +The beta-to-current case makes `--fixture` mandatory before any lease starts. +If an explicit checkpoint pauses a job, complete the named visible action and +resume the same state file with, for example: + +```bash +Scripts/lab/scenario-matrix-runner ... \ + --ack-checkpoint macos15-cancellation-recovery:operator-cancel-visible-dialog +``` + +An acknowledgement is accepted only for a checkpoint already recorded as +waiting; it cannot pre-authorize or bypass a future interaction. A process +interruption never blindly repeats an uncertain mutation. Lease creation can be +recovered only when both the durable command log and the owned controller status +identify the same ready lease; any other uncertain mutation blocks for evidence +review. The `install-exact-artifact` matrix step routes through `install-runtime`. +The macOS 26 upgrade uses `install-upgrade-runtime`: it retains the same strict +installer lineage and signature checks, but can record the exact, independently +proven Parallels Apple-virtualization boundary where the registered Kanata +service has zero guest-visible input devices. No other installer failure is +accepted by that boundary. When an installer step waits for macOS approval, +acknowledgement means “verify now,” not “assume +the installer passed.” Failed jobs still collect available artifacts and destroy their owned +leases, allowing independent later waves to continue. A waiting job retains its +lease and pauses only subsequent work on that provider. `matrix-report.json` +contains campaign/job outcomes and `scenario-report.json` consolidates validated +scenario result files. + ### macOS 27 beta regression capture On every significant macOS 27 beta seed, run the non-destructive evidence @@ -465,6 +647,25 @@ Scripts/lab/keypath-lab scenario cbx_example macos-27-regression Scripts/lab/keypath-lab artifacts cbx_example ``` +Before an automated macOS 27 System Settings flow relies on selectors, capture +fresh accessibility evidence in the guest: + +```bash +Scripts/lab/macos-27-selector-scenario \ + --output .keypath-lab/scenario-output/macos-27-selectors +``` + +The scenario reopens the macOS 27 Accessibility privacy pane, waits for its +semantic UI contract, and then runs the fail-closed driver. The driver admits +only macOS 27, requires Peekaboo's desktop permissions, and writes the exact OS +version, build, preflight, accessibility snapshot, and expected selector +contract. The contract uses the page identifier, explanatory copy, and the +visible `Peekaboo Lab Host` row rather than relying on the private deep-link +token to appear in the accessibility tree. Missing desktop permissions are an +`environment-precondition-failure`; a missing expected selector is an +`unsupported-os-selector`. Either result blocks the scenario instead of +guessing coordinates or attributing a harness problem to KeyPath. + The command records the exact OS build, canonical CLI system snapshot, VirtualHID extension state, KeyPath-owned launchd jobs, signatures, Gatekeeper and stapling results, processes, TCP readiness, and relevant logs. It also emits