Skip to content

Commit 7efb596

Browse files
committed
docs(ios-qa): document regeneration and device verification flow
1 parent c378074 commit 7efb596

9 files changed

Lines changed: 211 additions & 99 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device):
9292
|---------|-------------|
9393
| `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. |
9494
| `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). |
95+
| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). |
9596

9697
End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md).
9798

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
245245
| `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. |
246246
| `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). |
247247
| `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. |
248+
| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. |
248249

249250
### Continuous checkpoint mode (opt-in, local by default)
250251

docs/howto-ios-testing-with-gstack.md

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin
99
- macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB.
1010
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
1111
- An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`.
12-
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
12+
- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
1313
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
1414

1515
For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable.
@@ -30,28 +30,49 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca
3030

3131
The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity.
3232

33-
## Step 1: Add the DebugBridge templates to your iOS app
33+
## Step 1: Generate the DebugBridge package
3434

35-
The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand:
35+
Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
3636

37-
1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
38-
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
39-
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
40-
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
41-
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
42-
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
43-
- `Package.swift` (from `Package.swift.template`)
44-
2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively.
45-
3. In your `@main` App init, gate the wiring on `#if DEBUG`:
37+
```bash
38+
gstack-ios-qa-regen \
39+
--app-source "$PWD/Sources/YourApp" \
40+
--bridge-dir "$PWD/DebugBridge"
41+
```
42+
43+
The command copies an explicit allowlist of canonical templates into the local
44+
`DebugBridge/` Swift package, generates
45+
`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
46+
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
47+
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
48+
cache hit. It also removes the explicit legacy generated-file set from older
49+
flat harness layouts so stale bridge sources cannot shadow the package.
50+
51+
1. Add `DebugBridge/` as a local package dependency. Depend on the
52+
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
53+
`DebugBridgeTouch` come in transitively.
54+
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
55+
3. In your `@main` App init, install the UIKit resolvers before starting the
56+
server, then register the generated accessor. Replace the example
57+
state/accessor names with the type the generator found:
4658

4759
```swift
4860
#if DEBUG
4961
import DebugBridgeCore
50-
StateServer.shared.start()
5162
#if canImport(UIKit)
5263
import DebugBridgeUI
64+
#endif
65+
#endif
66+
67+
// Inside App.init(), after appState is initialized:
68+
#if DEBUG
69+
#if canImport(UIKit)
5370
DebugBridgeUIWiring.installAll()
5471
#endif
72+
DebugBridgeManager.shared.start(
73+
appState: appState,
74+
register: AppStateAccessor.register
75+
)
5576
#endif
5677
```
5778

@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
109130
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
110131
```
111132

112-
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
133+
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
134+
available iPhone.
135+
Automatic selection is restricted to available iPhones and prefers a wired
136+
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
137+
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
138+
failure.
139+
If a newly started daemon reaches an already-running target whose one-use boot
140+
token was consumed by an earlier daemon, it verifies the bundle owner, force
141+
relaunches that target once, waits for a fresh token, verifies ownership again,
142+
and rotates normally.
113143

114144
## Step 4: Drive the device
115145

@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
123153
| `POST /session/release` | Release the lock. | bearer + session |
124154
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
125155
| `GET /elements` | Accessibility-tree snapshot. | bearer |
126-
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
127-
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
156+
| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
157+
| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
128158
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
129159
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
130160
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
131161

132162
Mutating requests require both an `Authorization: Bearer <token>` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer.
133163

134-
The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default.
164+
The state snapshot is opt-in per field via a standalone generator marker
165+
comment immediately above a property. It is intentionally not a property
166+
wrapper, so it compiles cleanly with Observation:
167+
168+
```swift
169+
@Observable
170+
final class AppState {
171+
// @Snapshotable
172+
var username: String = ""
173+
174+
var authToken: String = "" // never exported
175+
}
176+
```
177+
178+
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
179+
auth state out of recorded fixtures by default. A marked field must be a
180+
writable instance `var` on a file-scope observable class, with an explicit type
181+
and an internal or public setter. Supported snapshot types are JSON-native
182+
scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
183+
`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
184+
those types. Snapshot keys must be unique across observable classes. The
185+
generator reports and stops on invalid declarations, custom values, implicitly
186+
unwrapped Optionals, nested observable classes, or duplicate keys instead of
187+
emitting broken or lossy Swift. Restore uses two phases: every model validates
188+
the complete input first, and only then are assignments applied on MainActor.
135189

136190
## Step 5: Make remote agents work (optional)
137191

ios-clean/SKILL.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation:
821821
1. The `DebugBridge` SPM target from `Package.swift`.
822822
2. The `#if DEBUG` block in the app's `@main` entry that calls
823823
`DebugBridgeManager.shared.start()`.
824-
3. Any `@Snapshotable` property wrappers on the canonical app state struct
825-
(the codegen-detection markers — the wrapper file lives inside
826-
DebugBridge so removing the SPM dep removes the wrapper too).
824+
3. Any standalone `// @Snapshotable` generator marker comments on the
825+
canonical app state class.
827826
4. Generated `StateAccessor.swift` files anywhere under the app source.
828827
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
829828
device (best-effort — only works if device is connected when /ios-clean

ios-clean/SKILL.md.tmpl

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation:
5050
1. The `DebugBridge` SPM target from `Package.swift`.
5151
2. The `#if DEBUG` block in the app's `@main` entry that calls
5252
`DebugBridgeManager.shared.start()`.
53-
3. Any `@Snapshotable` property wrappers on the canonical app state struct
54-
(the codegen-detection markers — the wrapper file lives inside
55-
DebugBridge so removing the SPM dep removes the wrapper too).
53+
3. Any standalone `// @Snapshotable` generator marker comments on the
54+
canonical app state class.
5655
4. Generated `StateAccessor.swift` files anywhere under the app source.
5756
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
5857
device (best-effort — only works if device is connected when /ios-clean

ios-qa/SKILL.md

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -869,17 +869,33 @@ fi
869869
## Phase 1: Read source, plan codegen
870870

871871
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
872-
classes. Note any property marked with the `@Snapshotable` wrapper — those
873-
are the snapshot-eligible fields.
874-
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
875-
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
876-
Subsequent runs are content-hash-cached and finish in ~50ms.
877-
3. Show the user the accessor list and ask whether to install the DebugBridge
872+
classes. Note any property immediately preceded by the generator marker
873+
comment `// @Snapshotable` — those are the snapshot-eligible fields. The
874+
marker is a comment so it composes with the `@Observable` macro. Each
875+
marked field must belong to a file-scope observable class and be a writable
876+
instance `var` with an explicit type and an internal or public setter.
877+
Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths,
878+
`Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their
879+
Optional compositions. Keys must be unique across observable classes.
880+
Codegen stops with a source diagnostic instead of emitting a broken or
881+
lossy harness when any of these constraints is violated.
882+
2. Show the user the accessor list and ask whether to install the DebugBridge
878883
SPM dependency into their `Package.swift` (one AskUserQuestion).
879884

880885
## Phase 2: Bootstrap the device bridge
881886

882-
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
887+
1. Generate the canonical local bridge package, typed accessors, and installed
888+
version marker with one deterministic command:
889+
```bash
890+
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
891+
--app-source "<source-dir>" \
892+
--bridge-dir "<source-dir>/DebugBridge"
893+
```
894+
The regenerator also removes the explicit obsolete flat-file set created by
895+
older ios-sync versions, preventing a stale second harness from remaining
896+
in the app target.
897+
2. Add the generated `DebugBridge` local SPM dependency to the app's
898+
`Package.swift`. The package
883899
ships three Debug-config-only library products:
884900
- `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols.
885901
- `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch
@@ -889,35 +905,43 @@ fi
889905
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
890906
(transitively pulls in Core + Touch). Release builds refuse to link these
891907
targets.
892-
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
908+
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
893909
```swift
894910
#if DEBUG
895911
import DebugBridgeCore
896-
StateServer.shared.start()
897912
#if canImport(UIKit)
898913
import DebugBridgeUI
914+
// Install resolvers before StateServer opens its listener.
899915
DebugBridgeUIWiring.installAll()
900916
#endif
917+
// Replace AppState/AppStateAccessor with the type discovered in Phase 1.
918+
DebugBridgeManager.shared.start(
919+
appState: appState,
920+
register: AppStateAccessor.register
921+
)
901922
#endif
902923
```
903-
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
924+
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
904925
-destination 'platform=iOS,id=<UDID>' build install`.
905-
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
926+
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
906927
Capture the boot token printed to `os_log` on first run.
907-
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
928+
6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
908929
acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another
909930
daemon is alive, the second invocation discovers its port and connects.
910-
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
931+
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
911932
fresh in-memory-only token. The boot token becomes useless ~5s later.
912933
Anything scraping `os_log` past this point sees a dead credential.
934+
If a fresh daemon finds the app running after another daemon consumed that
935+
one-use token, it verifies the bundle owner, relaunches the target once,
936+
waits for the new token, verifies ownership again, and then rotates.
913937

914938
## Phase 3: Vision-driven agent loop
915939

916940
Each iteration:
917941

918942
1. `GET /screenshot` (via daemon) → save PNG.
919943
2. `GET /elements` → accessibility tree.
920-
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
944+
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
921945
4. Decide next action based on what's on the screen vs the test goal.
922946
5. `POST /session/acquire` to grab the device lock.
923947
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
@@ -981,7 +1005,7 @@ live.
9811005
| `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed |
9821006
| `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote <identity>` on the Mac |
9831007
| `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture |
984-
| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
1008+
| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
9851009
| `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies |
9861010
| `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot |
9871011

0 commit comments

Comments
 (0)