Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Reviews, improves, and writes SwiftUI code following state management, view composition, performance, and iOS 26+ Liquid Glass best practices.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/record_trace.py
1#!/usr/bin/env python32"""Record an Xcode Instruments .trace file via `xctrace record`.34Three modes:5(default) Start a recording. Stops on Ctrl+C, stop-file, or time limit.6--list-devices Enumerate connected devices + simulators as JSON.7--list-templates Enumerate available Instruments templates as JSON.89Attach vs launch vs all-processes is mutually exclusive and passed straight10through to xctrace. The default template is "SwiftUI" (matches the11SwiftUI template in Xcode 26+ — change with --template).1213Manual stop options, most to least automated:14* Send SIGINT (Ctrl+C) to this script — forwarded to xctrace, which15finalises the trace before exiting.16* Pass --stop-file PATH; when that file appears on disk, this script17sends SIGINT to xctrace. Useful for `Bash run_in_background`18workflows where there's no interactive terminal.19* Pass --time-limit 30s / 5m / etc. — xctrace stops itself.20"""21from __future__ import annotations2223import argparse24import json25import os26import re27import signal28import subprocess29import sys30import time31from datetime import datetime32from pathlib import Path333435def main(argv: list[str] | None = None) -> int:36parser = argparse.ArgumentParser(description="Record an Instruments .trace file.")37list_mode = parser.add_mutually_exclusive_group()38list_mode.add_argument("--list-devices", action="store_true",39help="List devices and simulators as JSON, then exit.")40list_mode.add_argument("--list-templates", action="store_true",41help="List template names as JSON, then exit.")4243parser.add_argument("--template", default="SwiftUI",44help="Template name (default: SwiftUI).")45parser.add_argument("--device", default=None,46help="Device name or UDID. Defaults to the host.")47parser.add_argument("--output", type=Path, default=None,48help="Output .trace path. Defaults to ./<template>-<timestamp>.trace.")49parser.add_argument("--time-limit", default=None,50help="Cap recording duration (e.g. 30s, 5m, 1h). Optional.")51parser.add_argument("--stop-file", type=Path, default=None,52help="When this path appears on disk, stop the recording.")53parser.add_argument("--env", action="append", default=[],54metavar="KEY=VALUE",55help="Env var for the launched process. Can repeat. Launch mode only.")56parser.add_argument("--instrument", action="append", default=[],57help="Extra --instrument flag passthrough (can repeat).")58parser.add_argument("--run-name", default=None)5960target = parser.add_mutually_exclusive_group()61target.add_argument("--launch", metavar="APP",62help="Launch this .app path and record it.")63target.add_argument("--attach", metavar="PID_OR_NAME",64help="Attach to a running process by pid or name.")65target.add_argument("--all-processes", action="store_true",66help="Record every process (system-wide).")6768args = parser.parse_args(argv)6970if args.list_devices:71_print_devices()72return 073if args.list_templates:74_print_templates()75return 07677if not (args.launch or args.attach or args.all_processes):78print("error: need one of --launch, --attach, or --all-processes.",79file=sys.stderr)80return 28182if args.env and not args.launch:83# xctrace silently ignores --env outside launch mode; surfacing this84# explicitly saves agents a confusing "why didn't my env var apply?".85print("error: --env only applies to --launch; remove it or switch target mode.",86file=sys.stderr)87return 28889output = args.output or Path.cwd() / _default_trace_name(args.template)90if output.exists():91print(f"error: output already exists: {output}", file=sys.stderr)92return 29394cmd = _build_xctrace_cmd(args, output)9596# Tell the user (and an agent reading stdout) what's happening + how to stop.97print("[record] starting xctrace record", flush=True)98print(f"[record] template: {args.template}", flush=True)99print(f"[record] device: {args.device or '(host)'}", flush=True)100print(f"[record] target: {_describe_target(args)}", flush=True)101print(f"[record] output: {output}", flush=True)102stop_hints = ["Ctrl+C"]103if args.stop_file:104stop_hints.append(f"`touch {args.stop_file}`")105if args.time_limit:106stop_hints.append(f"after {args.time_limit}")107print(f"[record] stop via: {', '.join(stop_hints)}", flush=True)108print(f"[record] cmd: {' '.join(_shell_quote(c) for c in cmd)}", flush=True)109110# Start xctrace in its own process group so we can signal cleanly.111proc = subprocess.Popen(cmd, start_new_session=True)112113try:114_wait_with_stop(proc, args.stop_file)115except KeyboardInterrupt:116_forward_sigint(proc)117118# Give xctrace up to 60s to finalise after SIGINT — large traces take time.119try:120rc = proc.wait(timeout=60)121except subprocess.TimeoutExpired:122print("[record] xctrace did not exit within 60s after stop; killing.",123file=sys.stderr)124proc.kill()125rc = proc.wait()126127if output.exists():128print(f"[record] done. trace written: {output}", flush=True)129else:130print("[record] done but output file not found — did xctrace error?",131file=sys.stderr)132return rc or 1133return rc134135136def _build_xctrace_cmd(args, output: Path) -> list[str]:137cmd = ["xctrace", "record", "--template", args.template, "--output", str(output)]138if args.device:139cmd += ["--device", args.device]140if args.time_limit:141cmd += ["--time-limit", args.time_limit]142if args.run_name:143cmd += ["--run-name", args.run_name]144for inst in args.instrument:145cmd += ["--instrument", inst]146for env in args.env:147cmd += ["--env", env]148# Target must come last — --launch consumes the remainder.149if args.attach:150cmd += ["--attach", args.attach]151elif args.all_processes:152cmd += ["--all-processes"]153elif args.launch:154cmd += ["--launch", "--", args.launch]155return cmd156157158def _describe_target(args) -> str:159if args.launch:160return f"launch {args.launch}"161if args.attach:162return f"attach {args.attach}"163if args.all_processes:164return "all processes"165return "(none)"166167168def _default_trace_name(template: str) -> str:169safe = re.sub(r"[^A-Za-z0-9]+", "-", template).strip("-").lower() or "trace"170ts = datetime.now().strftime("%Y%m%d-%H%M%S")171return f"{safe}-{ts}.trace"172173174def _wait_with_stop(proc: subprocess.Popen, stop_file: Path | None) -> None:175"""Poll until xctrace exits or stop_file appears; then send SIGINT."""176while True:177rc = proc.poll()178if rc is not None:179return180if stop_file and stop_file.exists():181print(f"[record] stop-file detected ({stop_file}); stopping xctrace.",182flush=True)183_forward_sigint(proc)184return185time.sleep(0.5)186187188def _forward_sigint(proc: subprocess.Popen) -> None:189try:190# Signal the whole group so child instruments tools also get SIGINT.191os.killpg(os.getpgid(proc.pid), signal.SIGINT)192except ProcessLookupError:193pass194195196def _print_devices() -> None:197out = subprocess.run(198["xctrace", "list", "devices"], capture_output=True, text=True, check=True199).stdout200devices: list[dict] = []201section = None202# Device lines end with "(UDID)"; real iOS devices also have "(OS version)"203# before the UDID. The host (macOS) line has only "(UDID)".204line_re = re.compile(r"^(.+?)(?:\s+\(([^()]+)\))?\s+\(([0-9A-Fa-f-]{20,})\)\s*$")205for line in out.splitlines():206stripped = line.strip()207if not stripped:208continue209if stripped.startswith("==") and stripped.endswith("=="):210section = stripped.strip("= ").strip().lower()211continue212m = line_re.match(stripped)213if not m:214continue215name, os_ver, udid = m.group(1).strip(), m.group(2), m.group(3)216devices.append({217"kind": section or "unknown",218"name": name,219"os": os_ver,220"udid": udid,221})222print(json.dumps({"devices": devices}, indent=2))223224225def _print_templates() -> None:226out = subprocess.run(227["xctrace", "list", "templates"], capture_output=True, text=True, check=True228).stdout229groups: dict[str, list[str]] = {}230section = "unknown"231for line in out.splitlines():232stripped = line.strip()233if not stripped:234continue235if stripped.startswith("==") and stripped.endswith("=="):236section = stripped.strip("= ").strip().lower()237groups.setdefault(section, [])238continue239groups.setdefault(section, []).append(stripped)240# Flat convenience list + structured by section.241flat = [name for items in groups.values() for name in items]242print(json.dumps({"templates": flat, "by_section": groups}, indent=2))243244245def _shell_quote(s: str) -> str:246if re.match(r"^[A-Za-z0-9_./:=@-]+$", s):247return s248return "'" + s.replace("'", "'\\''") + "'"249250251if __name__ == "__main__":252sys.exit(main())253