ios-qa
The facts
Its description
Live-device iOS QA for SwiftUI apps. (gstack) Triggers: ios qa; test the iphone app; test the ipad app; test my ios app; find bugs on the device; qa the ios app
The description the agent reads to decide when to use it. Phrases the author says should start it.
When it starts
A person asks to test or QA their SwiftUI iPhone or iPad app on a real device over USB, or to hunt for bugs on the device rather than in a simulator. The description and triggers point at live-device iOS QA, so any request to exercise an iOS app on hardware would select it.
Things a person might say (the model's guesses)
- “QA my iOS app on my iPhone”
- “test the iPad app and find bugs on the device”
- “run ios qa against my SwiftUI app”
A model's summary of when it is used.
Tools it may use, or may not
allowed-tools: Bash, Read, Write, Edit, Grep, Glob, AskUserQuestion
Tools it may use while it runs without asking you first.
Starts other agents
3 lines mention starting other agents
Lines in the file that mention starting another agent. Found by matching words, so treat them as pointers.
Runs scripts
bin/gstack-skill-start, scripts/question-registry.ts, bin/gstack-question-preference, bin/gstack-question-log, bin/gstack-paths (first 5 of 9 lines)
Scripts the text names. Found by matching words, so treat them as pointers.
Stops for a person
5 lines stop to ask a person
Lines that stop and wait for a person before going on. Found by matching words, so treat them as pointers.
What it leads to
- calls ios-clean before a Release build line 392
- requires gstack if the shared preamble is not already in context line 27
Other skills this one hands off to, calls, requires or offers instead, as a model read them.
What leads to it
- gstack hands off to this PROACTIVE all, or user names it gstack, line 252
- ios-design-review calls this only when no daemon is running ios-design-review, line 108
- ios-fix requires this ios-fix, line 112
Other skills that hand off to this one, call it, require it or offer it instead, as a model read them.
Licence
MIT, from LICENSE
The terms its author shares it under, which let it be shown here.
Other files in this skill (16) Close
- daemon/src/allowlist.ts
- daemon/src/audit.ts
- daemon/src/auth-mint.ts
- daemon/src/cli-mint.ts
- daemon/src/devicectl.ts
- daemon/src/index.ts
- daemon/src/proxy.ts
- daemon/src/session-cache.ts
- daemon/src/session-tokens.ts
- daemon/src/single-instance.ts
- daemon/src/tailscale-localapi.ts
- daemon/src/tunnel-bootstrap.ts
- daemon/src/types.ts
- docs/tailscale-acl-example.md
- scripts/gen-accessors.ts
- sections/preamble.md
The text
Most marks come from matching words in the file, so treat them as pointers.
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly --> <!-- Regenerate: bun run gen:skill-docs -->
Raw lines 1 to 24Boilerplate, lines 25 to 111. The same text appears in 45 other files here. Close
Telemetry (run last)
After workflow completion, log telemetry with ONE command. OUTCOME is success/error/abort/unknown; SESSION_ID and TEL_START are the values the preamble's skill-start output echoed. It also drains the artifacts-sync queue (the former skill-end sync step — do not run gstack-brain-sync separately).
PLAN MODE EXCEPTION — ALWAYS RUN: This writes telemetry to $GSTACK_STATE_ROOT/analytics/, matching preamble analytics writes.
~/.claude/skills/gstack/bin/gstack-skill-end --skill "ios-qa" --outcome OUTCOME \
--session-id "SESSION_ID" --tel-start "TEL_START" --used-browse USED_BROWSE \
--error-message "ERROR_MESSAGE" --failed-step "FAILED_STEP" 2>/dev/null || trueReplace OUTCOME and USED_BROWSE (yes/no) before running; substitute SESSION_ID/TEL_START from the skill-start echoes. ERROR_MESSAGE/FAILED_STEP are "" unless outcome is error. If the command is missing (stale install), skip telemetry — it never blocks the workflow.
Live-device iOS QA
This skill drives a real iPhone or iPad via USB. The agent reads your Swift source, generates typed state accessors, deploys a debug bridge, and runs a closed find→fix→verify loop. No simulator, no XCTest, no WebDriverAgent.
Raw lines 112 to 138Architecture
┌──────────────────────┐ USB CoreDevice (IPv6) ┌──────────────────┐
│ gstack-ios-qa daemon │ ────────────────────────▶ │ iOS app │
│ (Mac, bun/TS) │ bearer + X-Session-Id │ StateServer │
│ │ │ (loopback only) │
│ - boot token rotate │ │ - /tap /swipe │
│ - session minting │ │ - /type /state │
│ - audit + redact │ │ - /snapshot │
└──────────────────────┘ └──────────────────┘
▲
│ Tailscale (optional, --tailnet)
│
┌──────────────────────┐
│ Remote agent │
│ (OpenClaw, etc.) │
└──────────────────────┘The iOS app's StateServer binds loopback only (::1 + 127.0.0.1). Tailnet ingress is exclusively the Mac daemon's job. The daemon validates Tailscale identities via the local tailscaled socket and mints short-lived session tokens (default 1h) for remote agents.
Prerequisites
- macOS (the daemon uses
devicectlfrom Xcode). - iPhone or iPad connected via USB, paired and trusted. With more than one connected, pick one before starting the daemon:
export GSTACK_IOS_TARGET_UDID=<udid>(xcrun devicectl list devicesshows UDIDs). Otherwise the daemon refuses to guess, lists each device with its UDID, and prints that export line. - Xcode + Swift toolchain installed (
swift --versionreports >= 5.9). - App source available on disk, with at least one
@Observableclass. - For remote-control mode: Tailscale installed and the user logged in.
Phase 0: Session warm-start (optional)
After each successful bootstrap the daemon writes the rotated StateServer bearer to $GSTACK_STATE_ROOT/ios-qa-session.json (mode 0600), bound to the device UDID, bundle ID and StateServer port. A restarted daemon resumes that bearer on its first device request, so the app keeps its QA state instead of being relaunched. It bootstraps normally instead when the app was relaunched (the app rejects the bearer), another device or app answers, devicectl reports no tunnel address for the bound UDID, or other users can read the file.
If the file exists and the daemon answers, the bridge is already installed: skip Phase 1-2 and jump to Phase 3. --cold deletes the file so the next daemon start runs a full bootstrap (the app relaunches once); a daemon that is already running keeps its in-memory session.
GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
SESSION="$GSTACK_STATE_ROOT/ios-qa-session.json"
PORT="${GSTACK_IOS_DAEMON_PORT:-9099}"
if [ "$COLD" = "1" ]; then
rm -f "$SESSION"
elif [ -f "$SESSION" ] && curl -sf "http://127.0.0.1:$PORT/healthz" > /dev/null; then
echo "Warm start: daemon alive on port $PORT with a persisted device session"
fiRaw lines 176 to 201Phase 1: Read source, plan codegen
- Before changing the app or replacing an installed build, verify that the bridge is compatible with the project:
- The generator currently supports file-scope
@Observableclasses only;ObservableObject,@StateObject, and other observation models do not produce accessors. - The documented dependency wiring assumes a SwiftPM app manifest. For an
.xcodeprojor.xcworkspace, do not invent package or target wiring. If either requirement is unmet, stop the bridge bootstrap without modifying the app. Preserve any installed production or TestFlight build. Prefer an existing real-device XCUITest harness; when a separate QA build is needed, use an isolated bundle identifier and non-production entitlements so it can coexist with the production app. Report fixture-driven state, provider UI, and actual external-provider success as distinct evidence tiers. - Walk the app source (passed as
--source <dir>) and identify all@Observableclasses. Note any property immediately preceded by the generator marker comment// @Snapshotable— those are the snapshot-eligible fields. The marker is a comment so it composes with the@Observablemacro. Each marked field must belong to a file-scope observable class and be a writable instancevarwith an explicit type and an internal or public setter. Snapshot types are JSON-native scalars (String,Bool, integer widths,Float,Double,CGFloat), arrays, String-keyed dictionaries, and their Optional compositions. Keys must be unique across observable classes. Codegen stops with a source diagnostic instead of emitting a broken or lossy harness when any of these constraints is violated. - Show the user the accessor list and ask whether to install the DebugBridge SPM dependency into their
Package.swift(one AskUserQuestion).
Phase 2: Bootstrap the device bridge
- Generate the canonical local bridge package, typed accessors, and installed version marker with one deterministic command: ``
bash ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ --app-source "<source-dir>" \ --bridge-dir "<source-dir>/DebugBridge"`The regenerator also removes the explicit obsolete flat-file set created by older ios-sync versions, preventing a stale second harness from remaining in the app target. Source control:DebugBridge/is generated; never hand-edit it. Commit it when teammates or CI build the Debug configuration without gstack (re-run the regenerator after a gstack upgrade); otherwise addDebugBridge/to.gitignore` and have each developer run the regenerator. Tell the user which one you picked. - Add the generated
DebugBridgelocal SPM dependency to the app'sPackage.swift. The package ships three Debug-config-only library products: DebugBridgeCore(Swift, cross-platform) — StateServer + bridge protocols.DebugBridgeTouch(Objective-C, iOS-only) — KIF-derived in-process touch synthesis with iOS 18+_UIHitTestContextSwiftUI hit-testing.DebugBridgeUI(Swift, iOS-only) — Screenshot / Elements / Mutation bridge implementations. The app target depends onDebugBridgeUIwith.when(configuration: .debug)(transitively pulls in Core + Touch). Release builds refuse to link these targets.- Wire the bridges from the
@mainApp init, gated on#if DEBUG: ``swift #if DEBUG import DebugBridgeCore #if canImport(UIKit) import DebugBridgeUI // Install resolvers before StateServer opens its listener. DebugBridgeUIWiring.installAll() #endif // Replace AppState/AppStateAccessor with the type discovered in Phase 1. DebugBridgeManager.shared.start( appState: appState, register: AppStateAccessor.register ) #endif - Build + deploy to the device with
xcodebuild -scheme <SchemeName> -destination 'platform=iOS,id=<UDID>' build install. - Launch via
devicectl device process launch --device <UDID> <bundle-id>. On launch the StateServer writes a one-use boot token to a 0600 file in the app'stmp/; the daemon copies it out withdevicectl. The token is never printed toos_log. If the app cannot write that file, it logsNOT READYand the daemon reportsboot_token_unavailablewith the cause. - Spawn the Mac-side daemon (on-demand) —
gstack-ios-qa-daemon. Daemon acquires an exclusive flock on~/.gstack/ios-qa-daemon.pid. If another daemon is alive, the second invocation discovers its port and connects. - Daemon immediately calls
POST /auth/rotateon the iOS StateServer with a fresh in-memory-only token. Rotation deletes the boot-token file, so a copy taken after this point is a dead credential. If a fresh daemon finds the app running after another daemon consumed that one-use token, it verifies the bundle owner, relaunches the target once, waits for the new token, verifies ownership again, and then rotates.
Phase 3: Vision-driven agent loop
Each iteration:
GET /screenshot(via daemon) → save PNG.GET /elements→ accessibility tree.GET /state/snapshot(only// @Snapshotablefields) → current state.- Decide next action based on what's on the screen vs the test goal.
POST /session/acquireto grab the device lock.- Execute
POST /tap,/swipe,/type, orPOST /state/<key>write. - Re-screenshot; compare; record finding if buggy.
POST /session/releaseonce the iteration is done.
Each authenticated mutating request through the tailnet listener (if remote mode is active) writes an audit row to ~/.gstack/security/ios-qa-audit.jsonl.
Modes
Local-USB mode (default). Daemon binds loopback only; no Tailscale required. The spawning skill gets full-surface access. Best for solo development.
**Tailnet mode (--tailnet).** Daemon additionally binds the Tailscale interface (never 0.0.0.0). Requires tailscaled to be running locally and the daemon to be able to read /var/run/tailscale.sock. Fails closed if the socket is missing, permission-denied, or returns an unparseable WhoIs response. Remote agents hit POST /auth/mint over tailnet, daemon canonicalizes identity via WhoIs, checks the allowlist file, mints a session token. See ios-qa/docs/tailscale-acl-example.md.
Capability tiers (tailnet mode). Minted tokens default to interact (taps, swipes, types). Higher tiers require explicit owner mint:
- observe:
/screenshot,/elements,GET /state/*,/healthz,/session/heartbeat. - interact: observe +
/tap,/swipe,/type. - mutate: interact +
POST /state/<key>. - restore: mutate +
POST /state/restore.
Owner mints via gstack-ios-qa-mint --remote <identity> --capability <tier> on the Mac. Self-service mint over tailnet only succeeds for already-allowlisted identities.
**Recording mode (--recording).** DebugOverlay renders a small diagonal "AGENT DEMO" watermark in a corner so screencasts are unambiguous about the device being agent-driven.
Demo mode
If the user says "demo", "demo mode", "show me", or "I want to see it working", run in DEMO MODE. This changes how the agent interacts with the app:
In demo mode, drive every action through visible UI (/tap, /swipe, /type) rather than POST /state/* writes, because viewers watch each step: they see the agent type every key and tap every button. All other rules in this skill still apply. The on-device DebugOverlay attribution chip shows "Driven by Claude Code (demo)" or the remote agent identity.
In demo mode, the screencap rate is bumped to 4fps so the recording feels live.
Raw lines 340 to 354Failure modes + recovery
| Symptom | Likely cause | Action |
|---|---|---|
curl: connection refused to daemon | daemon crashed | Re-run /ios-qa; spawn-race lock will fail closed |
403 identity_not_allowed from /auth/mint | identity missing from allowlist | Run gstack-ios-qa-mint --remote <identity> on the Mac |
409 schema_mismatch on /state/restore | snapshot from older app build | Discard the snapshot; re-capture |
503 device_disconnected / 504 upstream_timeout from proxy | USB route dropped, app stopped, or app relaunched | Daemon probes the running app with its session bearer and keeps the session (no relaunch, app state intact). It bootstraps only when the app rejected the bearer (401), is not running, or a different device is now selected. A lost /tap//swipe//type response is never replayed: check the screen before retrying. If it persists, reconnect/unlock the device |
multiple_devices at bootstrap | iPhone and iPad (or two devices) connected, no target set | Run the printed export GSTACK_IOS_TARGET_UDID=<udid>, then restart the daemon |
boot_token_unavailable ... could not write | app's tmp/ not writable | Fix the app container, relaunch the app |
| App relaunched after the daemon restarted | a new daemon has no session bearer and the one-use boot token is gone | Expected: the first bootstrap relaunches the app once; keep one daemon alive for a session |
429 rate_limited from /auth/mint | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
413 body_too_large on /state/restore | snapshot >1MB | Increase --max-body or trim snapshot |
Known limits
Device-verified by users, not fixable in the bridge today. Plan around them:
- SwiftUI gestures on iOS 26. In-process synthesized touches report success but never reach a SwiftUI
DragGesture(for example aCanvasdriven by drag input), even with phase-separated touches (seen on iOS 26.5). Buttons and UIKit controls still respond. For gesture-driven views, have the app expose its input handlers to the bridge under#if DEBUGand drive them through a state write, or cover the flow with an XCUITest harness. - **
/swipescrolls only.** It moves the nearest enclosingUIScrollViewand returnsfalsewhen there is none; it is not a drag. Custom pan or drag views need the input-routing approach above. - **
/elementson iOS 26.** The in-process SwiftUI accessibility tree is often not materialized: an iPhone 12 Pro on iOS 26.3.1 returned only the three hosting views, with no identifiers or labels. Locate controls from the screenshot and tap by coordinate. - iPad windows. iPad sessions work like iPhone sessions, but the overlay and window selection have not been verified with Stage Manager or multiple scenes; report what you see.
Cleanup
Use /ios-clean to remove the DebugBridge SPM dependency and all #if DEBUG wiring before a Release build. This is a convenience flow; the structural Release-build guard (Package.swift .when(configuration: .debug) + CI swift build -c release check) is the safety-critical path.
From garrytan/gstack at 3f79d81, 2026-11-01. Skills read by DeepSeek V4.1 Flash on 2026-11-04. Summary and techniques by Claude Opus 5.5 on 2026-11-06.