A self-contained pack of read-only Claude Code tooling for iOS work on the simulator: two subagents, a /diagnose-ui-bug command, its mitmproxy capture runner + addons, and a one-time setup doc. Everything is anonymized — swap the MyApp placeholders and it's yours.
Swap the placeholders — MyApp → your app's PRODUCT_NAME, com.example.myapp.* → your bundle IDs, myapp-dev / myapp-staging → your schemes, and the api.*.example.com / login.idp.example.com hosts → your backends. The /events/me and /events endpoints in the addons are meant to be edited to the screen you're pinning down. Then mirror the path shown on each block under your project's .claude/. The runner needs mitmproxy (brew install --cask mitmproxy).
Subagentsread-only agents you dispatch · 2
▍ios-log-analyzer
log triage
Capture and analyze runtime logs from a booted simulator — crashes, auth/network failures, deep-link issues, or correlating log output with a UI symptom. Reports the exact log lines, error clusters, and a ranked root cause with file:line to inspect.
BashReadGrepGlob
.claude/agents/ios-log-analyzer.md
---
name: ios-log-analyzer
description: Capture and analyze an iOS app's runtime logs from a booted simulator. Use when diagnosing a crash, auth/network failure, deep-link issue, or when correlating log output with a UI symptom. Reports the relevant log lines, error clusters, and a ranked root cause — read-only, never edits code.
tools: Bash, Read, Grep, Glob
---
You analyze runtime logs for an iOS app (hybrid SwiftUI + UIKit, Combine/MVVM + Coordinator).
> Replace the `MyApp` placeholder below with your app's `PRODUCT_NAME`, and the `com.example.myapp.*` bundle IDs with yours.
## Logging facts for this app
- Logs are emitted with `os_log(… log: .default …)` — **no custom subsystem/category**. Filter by **process**, not subsystem. The process name equals the build's `PRODUCT_NAME`, which varies by config: **Staging → "MyApp Staging"** (bundle `com.example.myapp.adhoc`, the project default — logged-in build hitting the pre-prod backend), Dev → "MyApp Dev" (`com.example.myapp.dev`), Debug → "MyApp Debug", Release → "MyApp". The predicates below use `process CONTAINS "MyApp"` so they match whichever build is actually running — a stale build lingering on the sim won't match an exact `== "MyApp Staging"`.
- Messages are tagged inline in brackets; `[DeepLink]` is the only tag in use today. Auth/network failures currently surface as Alamofire validation errors and `ConnectionError` (`Network/Connection.swift`), not tagged `os_log`.
- ~26 `print()` call sites exist — these do **not** appear in the unified log, only the Xcode console. Call this out if you suspect a `print`-only path.
## How to capture
```bash
# Live stream — run while the symptom reproduces
xcrun simctl spawn booted log stream --level debug --style compact \
--predicate 'process CONTAINS "MyApp"'
# Retrospective dump — no repro needed
xcrun simctl spawn booted log show --last 10m --style syslog \
--predicate 'process CONTAINS "MyApp"'
# Narrow to a tag / keyword
# ... --predicate 'process CONTAINS "MyApp" AND eventMessage CONTAINS[c] "[DeepLink]"'
```
If XcodeBuildMCP log tools are connected, prefer them (`start_sim_log_cap` / `stop_sim_log_cap`) for capture tied to a build session.
## What to report
1. The **exact** log lines that matter (timestamp, process, message), quoted verbatim.
2. Error clusters: HTTP status codes, `ConnectionError`, decode failures (watch the `.0000000Z` vs `.SSS` date bug), auth (401 / OIDC / the known guest-token-overrides-OIDC class of bug), deep-link routing.
3. A ranked list of **likely root causes**, each with the `file:line` to inspect (e.g. `Network/Webservice.swift`, `AuthenticationHandler`, a specific ViewModel).
4. If logs are silent, say the path is probably `print()`-only or off the `.default` log, and suggest where an `os_log` would help.
Never assert a cause you did not see evidence for in the captured output.
▍ui-verifier
visual check
Build, launch, and visually verify the app on a simulator after a change — confirm a UI change renders as intended, or reproduce a UI bug. Captures a screenshot + accessibility tree; every PASS/FAIL is backed by evidence, never assumption.
inherits all tools
.claude/agents/ui-verifier.md
---
name: ui-verifier
description: Build, launch, and visually verify an iOS app on a simulator after a change. Use to confirm a UI change renders as intended, or to reproduce a UI bug — it boots the sim, installs the Dev build, drives to the screen, captures a screenshot + accessibility tree, and reports whether the expected UI is present. Confirms with evidence, never assumption.
---
You verify UI behavior for an iOS app on the iOS Simulator. Every claim is backed by a screenshot + accessibility snapshot, never by assumption.
> Replace the `MyApp` / `myapp` placeholders below with your app's `PRODUCT_NAME`, Xcode project/scheme names, and bundle id.
## Environment
- Build: `-project myapp.xcodeproj -scheme myapp-dev -configuration Dev` (there is **no** `.xcworkspace`).
- Default simulator: **iPhone 17 Pro / iOS 26.3**. App & process name **"MyApp Dev"**, bundle id `com.example.myapp.dev`.
- Stack is hybrid **SwiftUI + UIKit** with Coordinator navigation — some screens are SwiftUI, some are `UIHostingController`-wrapped or pure UIKit. Inspect the **accessibility tree**, not just the pixels.
## Procedure (prefer XcodeBuildMCP tools)
1. Boot the target simulator; confirm it is booted.
2. Build + install + launch the Dev scheme (XcodeBuildMCP `build_run_sim`, or `xcodebuild` then `simctl install` / `simctl launch`).
3. Navigate to the screen under test (describe each tap; use UI-automation tools if available).
4. Capture **both** a screenshot **and** the accessibility/description snapshot.
5. Compare against the expected state given in the task.
## What to report
- PASS/FAIL for each expected element, each backed by what you observed in the screenshot or a11y tree.
- For a FAIL: the discrepancy and the likely layer — SwiftUI view, ViewModel `@Published` not delivered on main, Coordinator wiring (`setCoordinator()` / `weak var viewController`), `Pager<T>` re-entrancy, or a UIKit `UIViewController`.
- Reference the screenshot path so it can be re-opened.
If the build fails, report the compiler error and stop — do not guess at UI you could not render.
Commandthe entry point that ties it together · 1
▍/diagnose-ui-bug
slash command
Correlates three evidence streams — captured network flows, os_log output, and a screenshot + a11y snapshot — on one timeline to answer the only question that matters: did the data arrive, and if so, why didn't the UI show it? Splits the cause into H1 (network) / H2 (ViewModel never set @Published) / H3 (set, but the view didn't render).
.claude/commands/diagnose-ui-bug.md
---
description: Diagnose a MyApp iOS UI bug by correlating captured network traffic (mitmproxy captures/flows.jsonl), os_log output, and a screenshot + accessibility snapshot. Adapted from our Android network-inspect flow.
argument-hint: <short description of the UI symptom>
---
Diagnose this UI symptom in the MyApp iOS app: **$ARGUMENTS**
One question drives everything: **did the data arrive, and if so, why didn't the UI show it?**
Do **not** propose a fix until the three evidence streams line up on a timeline.
## 1. Network — capture flows (mitmproxy, local mode)
No SSL pinning / no mTLS / ATS open, so the CA alone suffices (one-time setup in `.claude/docs/proxy-and-logs.md`).
```bash
.claude/scripts/mitm-run.sh sleep 120 # reproduce the symptom during these 120s
```
Writes `captures/flows.jsonl` + `captures/assertions.json` (addons: `.claude/mitm/`). Then:
```bash
jq -c '{t: .t_resp_iso, method, path, status, ms: .duration_ms}' captures/flows.jsonl
jq 'select(.path|endswith("/events/me")) | {t_resp_iso, status, resp_body}' captures/flows.jsonl
cat captures/assertions.json # no_duplicates:false ⇒ double-fetch (Pager re-entrancy)
```
Watch for: non-2xx; 401 (the known guest-token-overrides-OIDC bug); empty/oversized bodies; the `.0000000Z` vs `.SSS` date-decode bug; missing pagination params. If `mitmproxy-mcp` is connected, query it instead of grepping. Filter to the env hosts in CLAUDE.md → Debugging.
## 2. Logs — os_log
Dispatch the `ios-log-analyzer` subagent, or:
```bash
xcrun simctl spawn booted log show --last 10m --style syslog --predicate 'process CONTAINS "MyApp"'
```
`.default` log only, filter by process (`CONTAINS "MyApp"` matches whichever build runs — Dev/Debug/Staging), `print()` won't appear. os_log is sparse here — if the ViewModel path is silent, that itself is a signal (add a temporary `os_log` in the `@Published` setter to timestamp the emit).
## 3. UI — render state
Dispatch the `ui-verifier` subagent for a screenshot + accessibility tree of the broken screen.
## 4. Correlate: line up `t_resp_iso` (flows) with the os_log emit and the a11y render
Three outcomes (the H1/H2/H3 method from our Android `network-inspect`, mapped to iOS):
- **H1 — network**: no matching flow, non-200, or empty `resp_body`. Data never arrived → fix the API/request, not the UI.
- **H2 — ViewModel didn't set `@Published`**: flow shows 200 + real body at `t_resp`, but **no** `@Published` mutation / no os_log after `t_resp`. Decode/mapping broke (`Provider`/`Boxed`, date format, wrong branch) or the update was published off-main and lost.
- **H3 — set, but the View didn't render**: the emit happened after `t_resp`, yet the screen is empty. It's the observe side — missing `.receive(on: DispatchQueue.main)`, the view not observing the ViewModel, Coordinator wiring (`setCoordinator()` / `weak var viewController`), or a lost SwiftUI update on the SwiftUI↔UIKit bridge.
## 5. Force a flaky race deterministic (separates H2 from H3)
```bash
mitmdump -q --mode local -s .claude/mitm/log_flows.py -s .claude/mitm/modify.py --set delay_session=true &
```
Unscoped `--mode local` is safe here — `modify.py` only rewrites its specific endpoint constants, so it never touches unrelated apps. A 3s delay on `/events/me` makes "state set before the view subscribed" reproduce every run. Also: `--set empty_events=true` (empty-state path), `--set fail_session=true` (error handling). Edit endpoint constants in `.claude/mitm/modify.py` to the screen under test.
## Report
Evidence from each stream (quote the flow line + os_log line + a11y observation), the correlated root cause as `file:line`, and only then the fix.
Runner & mitmproxy addonsnetwork capture the command drives · 4
▍mitm-run.sh
runner
Starts mitmproxy in local mode, auto-scoped to the running app process so it captures only your app — never the Mac's global proxy, so there's nothing to leave dangling on kill -9. --reset clears orphans.
.claude/scripts/mitm-run.sh
#!/usr/bin/env bash
# mitmproxy runner — LOCAL MODE. Does NOT touch the Mac's global proxy (no kill -9 footgun).
# Pattern from Android (scoped proxy), ported to iOS: instead of 'adb ... http_proxy'
# we use 'mitmdump --mode local', because the iOS Simulator has no proxy knob of its own.
# Usage: .claude/scripts/mitm-run.sh <command> e.g. .claude/scripts/mitm-run.sh sleep 120
# .claude/scripts/mitm-run.sh --reset # kill orphaned mitmdumps (after kill -9 / crash)
# Env: MITM_PORT (8080)
# MITM_TARGET — process name to scope to. Default AUTO: detects the running
# "MyApp *" process (Dev/Debug/Staging/Release) and captures only it.
# MITM_TARGET="Name" forces it; MITM_TARGET="" = ALL local traffic.
set -euo pipefail
# repo root from the script location → works from any CWD
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"; cd "$ROOT"
PORT="${MITM_PORT:-8080}"
PATTERN="mitmdump.*--mode local"
reset() { pkill -f "$PATTERN" 2>/dev/null && echo "[reset] killed mitmdump (local mode)" || echo "[reset] nothing to kill"; }
[ "${1:-}" = "--reset" ] && { reset; exit 0; }
command -v mitmdump >/dev/null || { echo "[ERR] mitmdump not found (brew install --cask mitmproxy)"; exit 1; }
# TARGET: by default auto-detect the running "MyApp *" process (clean capture — app only,
# without other Mac apps' traffic). Process name = build's PRODUCT_NAME, depends on config.
TARGET="${MITM_TARGET-__auto__}"
if [ "$TARGET" = "__auto__" ]; then
TARGET="$(ps -Ao comm= 2>/dev/null | sed -E 's#.*/##' | grep -m1 -E '^MyApp' || true)"
[ -n "$TARGET" ] && echo "[mitm] auto-scope → process \"$TARGET\"" \
|| echo "[mitm] no running 'MyApp' process — capturing ALL local traffic (launch the app before capturing to scope it)"
fi
# local mode = no global state to restore; just kill mitmdump.
MITM_PID=""
cleanup() { [ -n "$MITM_PID" ] && kill "$MITM_PID" 2>/dev/null || true; }
trap cleanup EXIT INT TERM
MODE="local"; [ -n "$TARGET" ] && MODE="local:$TARGET"
echo "[mitm] --mode $MODE :$PORT addons=.claude/mitm/*.py → captures/flows.jsonl (+ captures/assertions.json)"
echo " (first time, macOS asks to approve a system extension — accept it)"
mitmdump -q --mode "$MODE" -p "$PORT" \
-s .claude/mitm/log_flows.py \
-s .claude/mitm/assert_flows.py \
-s .claude/mitm/modify.py \
>"/tmp/mitmdump.$PORT.log" 2>&1 &
MITM_PID=$!
sleep 1
kill -0 "$MITM_PID" 2>/dev/null || { echo "[ERR] mitmdump failed to start — see /tmp/mitmdump.$PORT.log"; exit 1; }
echo "[run] $*"
"$@"
# the trap kills mitmdump on error / Ctrl-C too. After kill -9: mitm-run.sh --reset
▍log_flows.py
addon
Logs every request/response to captures/flows.jsonl with ISO timestamps — the correlation key against os_log.
.claude/mitm/log_flows.py
"""mitmproxy addon: log every request/response to flows.jsonl for agentic diagnosis.
Run: mitmdump -s .claude/mitm/log_flows.py (see .claude/scripts/mitm-run.sh)
Output: captures/flows.jsonl (one JSON object per line), relative to repo root.
The ISO timestamps (t_req_iso / t_resp_iso) are the correlation key: line them up
against os_log timestamps (`ios-log-analyzer`) to tell "data arrived" from
"data arrived but the UI never showed it".
"""
import json
import os
import time
from datetime import datetime
from mitmproxy import http
FLOWS_FILE = "captures/flows.jsonl"
MAX_BODY = 5000
def _iso(ts: float) -> str:
return datetime.fromtimestamp(ts).isoformat(timespec="milliseconds")
def _body(message) -> str:
if not message or not message.content:
return ""
return message.get_text(strict=False)[:MAX_BODY]
class LogFlows:
def __init__(self):
self.started = {}
async def request(self, flow: http.HTTPFlow) -> None:
self.started[flow.id] = time.time()
async def response(self, flow: http.HTTPFlow) -> None:
t_resp = time.time()
t_req = self.started.pop(flow.id, t_resp)
record = {
"t_req": t_req,
"t_resp": t_resp,
"t_req_iso": _iso(t_req),
"t_resp_iso": _iso(t_resp),
"duration_ms": round((t_resp - t_req) * 1000, 1),
"method": flow.request.method,
"url": flow.request.pretty_url,
"path": flow.request.path.split("?", 1)[0],
"status": flow.response.status_code,
"req_headers": dict(flow.request.headers),
"resp_headers": dict(flow.response.headers),
"req_body": _body(flow.request),
"resp_body": _body(flow.response),
}
os.makedirs(os.path.dirname(FLOWS_FILE), exist_ok=True)
with open(FLOWS_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
addons = [LogFlows()]
▍assert_flows.py
addon
Tallies requests per path and flags double-fetches in captures/assertions.json (the classic Pager re-entrancy race).
.claude/mitm/assert_flows.py
"""mitmproxy addon: tally requests per path, write assertions.json on shutdown.
Run: mitmdump -s .claude/mitm/assert_flows.py
Output: captures/assertions.json, relative to repo root.
no_duplicates == false means WATCH_ENDPOINT was fetched more than once during the
scenario — the classic double-fetch race (on iOS: `Pager.loadNextPage()` fired by
both scroll and `onAppear` before `isLoading` flips, or a view re-subscribing).
Match is by path suffix so it survives the env prefix (/api/v1/...).
"""
import json
import os
from collections import Counter
from mitmproxy import http
# The primary authenticated list fetch — edit to the endpoint you're pinning down.
WATCH_ENDPOINT = "/events/me"
ASSERT_FILE = "captures/assertions.json"
def _norm(path: str) -> str:
return path.split("?", 1)[0].rstrip("/")
class AssertFlows:
def __init__(self):
self.counts = Counter()
async def request(self, flow: http.HTTPFlow) -> None:
self.counts[_norm(flow.request.path)] += 1
def done(self):
counts = dict(self.counts)
watch = _norm(WATCH_ENDPOINT)
watch_count = sum(c for p, c in self.counts.items() if p.endswith(watch))
duplicated = sorted(p for p, c in self.counts.items() if c > 1)
result = {
"counts": counts,
"watch_endpoint": WATCH_ENDPOINT,
"watch_called": watch_count > 0,
"watch_count": watch_count,
"no_duplicates": watch_count <= 1,
"duplicated_paths": duplicated,
}
os.makedirs(os.path.dirname(ASSERT_FILE), exist_ok=True)
with open(ASSERT_FILE, "w", encoding="utf-8") as fh:
json.dump(result, fh, indent=2, ensure_ascii=False)
addons = [AssertFlows()]
▍modify.py
addon
Fault injection via --set (delay / empty / fail) to turn a flaky race into a deterministic repro — cleanly separates H2 from H3.
.claude/mitm/modify.py
"""mitmproxy addon: inject faults to turn a flaky race into a deterministic repro.
Options (toggle with --set):
mitmdump -s .claude/mitm/modify.py --set delay_session=true # /events/me responds 3s late
mitmdump -s .claude/mitm/modify.py --set empty_events=true # /events returns []
mitmdump -s .claude/mitm/modify.py --set fail_session=true # /events/me returns HTTP 500
Why: a 3s delay makes "state emitted before the SwiftUI view subscribed" reproduce
every run — it cleanly separates H2 (`@Published` never set) from H3 (set, but the
view/`.receive(on:)`/Coordinator didn't render it). Endpoints match by path suffix
so they survive the env prefix (/api/v1/...). Edit the constants to your target.
"""
import asyncio
from mitmproxy import ctx, http
SESSION_ENDPOINT = "/events/me" # primary authenticated fetch
LIST_ENDPOINT = "/events" # a list endpoint (empty-state path)
def _matches(flow: http.HTTPFlow, endpoint: str) -> bool:
path = flow.request.path.split("?", 1)[0].rstrip("/")
return path.endswith(endpoint.rstrip("/"))
class Modify:
def load(self, loader):
loader.add_option("delay_session", bool, False,
f"Delay responses for {SESSION_ENDPOINT} by 3s (asyncio.sleep)")
loader.add_option("empty_events", bool, False,
f"Return [] for {LIST_ENDPOINT}")
loader.add_option("fail_session", bool, False,
f"Return HTTP 500 for {SESSION_ENDPOINT}")
async def request(self, flow: http.HTTPFlow) -> None:
if ctx.options.fail_session and _matches(flow, SESSION_ENDPOINT):
flow.response = http.Response.make(
500, b'{"error":"injected fail_session"}',
{"Content-Type": "application/json"})
return
# /events/me ends with /events too — guard so empty_events only hits the list.
if ctx.options.empty_events and _matches(flow, LIST_ENDPOINT) \
and not _matches(flow, SESSION_ENDPOINT):
flow.response = http.Response.make(
200, b"[]", {"Content-Type": "application/json"})
return
if ctx.options.delay_session and _matches(flow, SESSION_ENDPOINT):
await asyncio.sleep(3)
addons = [Modify()]
Setup docone-time, read once · 1
▍proxy-and-logs.md
reference
Trust the mitmproxy CA on the simulator, capture in local mode, the build-identity & backend-host tables, and an os_log cheat-sheet.
.claude/docs/proxy-and-logs.md
# Debug proxy & log capture — one-time setup
Ported from our **Android** setup (`.claude/mitm/` addons + JSONL correlation),
adapted for iOS. The one real difference is routing: Android scopes the proxy to the
emulator via `adb shell settings put global http_proxy 10.0.2.2:8080`. The iOS
Simulator has **no equivalent proxy knob** — it uses the Mac's network stack — so we
use **`mitmdump --mode local`** (transparent, per-process) instead of mutating the
global macOS system proxy. No global state → nothing to leave dangling on `kill -9`.
## TL;DR — interception works with zero app-code changes
Alamofire's default shared session (`Network/Connection.swift`). Verified across the app
and `Packages/Api`: **no SSL pinning, no TrustKit / `NSPinnedDomains`, no mTLS / client
certs**, and ATS is fully open (`NSAllowsArbitraryLoads = true`). Once the proxy CA is
trusted on the simulator, all HTTPS is visible — no debug flag, no `#if DEBUG` bypass.
> **If pinning or mTLS is ever added later** (show a plan before touching app code):
> - **Pinning** — gate the evaluator behind `#if DEBUG`, or drop `NSPinnedDomains` / disable TrustKit in the Debug config only.
> - **mTLS** — `openssl pkcs12 -in client.p12 -out <host>.pem -nodes`, then `mitmdump --set client_certs=<dir>/` (file `<host>.pem` = key+cert).
## 1. One-time: trust the mitmproxy CA on the simulator
```bash
mitmdump # run once → generates ~/.mitmproxy/mitmproxy-ca-cert.pem, then Ctrl-C
xcrun simctl boot "iPhone 17 Pro"
xcrun simctl keychain booted add-root-cert ~/.mitmproxy/mitmproxy-ca-cert.pem
xcrun simctl shutdown booted && xcrun simctl boot "iPhone 17 Pro" # reboot so trust takes effect
```
## 2. Capture (local mode — the runner)
```bash
.claude/scripts/mitm-run.sh sleep 120 # capture for 120s while you reproduce
.claude/scripts/mitm-run.sh --reset # after a kill -9 / crash: kill orphaned mitmdump
```
The runner **auto-scopes** to the running `MyApp *` process (so it logs only the app, not your
other Mac apps), starting `mitmdump --mode local:"<that process>"` on :8080 with all three addons and
writing `captures/flows.jsonl` (+ `captures/assertions.json`) under `captures/`. If no `MyApp *`
process is up when it starts, it falls back to capturing **all** local traffic. It touches **no** global
proxy, so its only teardown is killing mitmdump (in the trap); a `kill -9` leaves at most an orphaned
mitmdump that `--reset` clears.
- First run: macOS asks to approve a **system extension** (local redirect) — accept it once. (Already approved on this machine: `org.mitmproxy.macos-redirector.network-extension` is `[activated enabled]`.)
- Launch the app **before** the capture so auto-scope can find it; otherwise it runs unscoped and grabs everything.
- Force a name: `MITM_TARGET="MyApp Staging" .claude/scripts/mitm-run.sh sleep 120`. Force **all** local traffic: `MITM_TARGET="" .claude/scripts/mitm-run.sh sleep 120`, then filter by host in `captures/flows.jsonl`.
- **Fallback (system proxy)** if local mode won't cooperate: `networksetup -setsecurewebproxy Ethernet 127.0.0.1 8080 && networksetup -setwebproxy Ethernet 127.0.0.1 8080` — but this is global; reset with `networksetup -setsecurewebproxystate Ethernet off` (and it clobbers any saved proxy, e.g. Proxyman on :9090).
**Build identity** — the process name (used for scoping *and* the os_log predicate) equals the build's `PRODUCT_NAME`, which varies by config:
| Config | Process / os_log name | Bundle id |
|--------|-----------------------|-----------|
| **Staging** (`-scheme myapp-staging`, **project default**) | `MyApp Staging` | `com.example.myapp.adhoc` |
| Dev (`-scheme myapp-dev`) | `MyApp Dev` | `com.example.myapp.dev` |
| Debug | `MyApp Debug` | — |
| Release | `MyApp` | `com.example.myapp` |
Older builds may linger on the sim under legacy bundle ids (e.g. a stale `MyApp Debug` = `com.example.myapp.legacy`). Auto-scope and the `process CONTAINS "MyApp"` log predicate both match whichever build is actually running — don't hard-code a single name.
Backend hosts to filter for (see also CLAUDE.md → Debugging):
| Env | Primary API | Legacy API | OpenID |
|-----|-------------|-----------|--------|
| Dev | `api.dev.example.com` | `legacy.pre.example.com` | `login.idp.example.com` |
| Staging | `api.pre.example.com` | `legacy.pre.example.com` | `login.idp.example.com` |
| Release | `api.example.com` | `legacy.example.com` | `login.idp.example.com` |
## 3. Addons (`.claude/mitm/`, shared shape with Android)
- **`log_flows.py`** → `captures/flows.jsonl`: one object per response with `t_req_iso`/`t_resp_iso` (correlate with os_log), `duration_ms`, `path`, `status`, headers, bodies (capped 5000).
- **`assert_flows.py`** → `captures/assertions.json` on shutdown: per-path counts, `no_duplicates`, `duplicated_paths`. `no_duplicates:false` on `/events/me` = a double-fetch (`Pager.loadNextPage()` scroll+`onAppear`).
- **`modify.py`** fault injection (`--set`): `delay_session` (/events/me 3s late), `empty_events` (/events → `[]`), `fail_session` (/events/me → 500). Edit the endpoint constants to your target screen.
```bash
# read flows
jq -c '{t: .t_resp_iso, method, path, status, ms: .duration_ms}' captures/flows.jsonl
jq 'select(.path|endswith("/events/me")) | {t_resp_iso, status, resp_body}' captures/flows.jsonl
cat captures/assertions.json
# force a race deterministic (run mitmdump directly for --set)
mitmdump -q --mode 'local:MyApp Dev' -s .claude/mitm/log_flows.py -s .claude/mitm/modify.py --set delay_session=true &
```
## 4. Log capture cheat-sheet
```bash
xcrun simctl spawn booted log stream --level debug --style compact --predicate 'process CONTAINS "MyApp"'
xcrun simctl spawn booted log show --last 10m --style syslog --predicate 'process CONTAINS "MyApp"'
```
`os_log(… log: .default …)` — no custom subsystem, filter by **process** (`CONTAINS "MyApp"` matches
Dev/Debug/Staging/Release — see the build-identity table); inline tags like `[DeepLink]`; `print()`
output is Xcode-console only. See the `ios-log-analyzer` subagent.