mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-18 13:37:23 +02:00
docs(ios-qa): document regeneration and device verification flow
This commit is contained in:
@@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device):
|
||||
|---------|-------------|
|
||||
| `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. |
|
||||
| `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). |
|
||||
| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). |
|
||||
|
||||
End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md).
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `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. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
### Continuous checkpoint mode (opt-in, local by default)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin
|
||||
- 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.
|
||||
- A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security.
|
||||
- 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`.
|
||||
- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
|
||||
- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
|
||||
- Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## Step 1: Add the DebugBridge templates to your iOS app
|
||||
## Step 1: Generate the DebugBridge package
|
||||
|
||||
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:
|
||||
Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
|
||||
|
||||
1. Copy these into a `DebugBridge/` SPM package inside your app workspace:
|
||||
- `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`)
|
||||
- `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`)
|
||||
- `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files)
|
||||
- `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`)
|
||||
- `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`)
|
||||
- `Package.swift` (from `Package.swift.template`)
|
||||
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.
|
||||
3. In your `@main` App init, gate the wiring on `#if DEBUG`:
|
||||
```bash
|
||||
gstack-ios-qa-regen \
|
||||
--app-source "$PWD/Sources/YourApp" \
|
||||
--bridge-dir "$PWD/DebugBridge"
|
||||
```
|
||||
|
||||
The command copies an explicit allowlist of canonical templates into the local
|
||||
`DebugBridge/` Swift package, generates
|
||||
`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to
|
||||
`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its
|
||||
own schema hash, so rerunning it with unchanged source is a fast, byte-stable
|
||||
cache hit. It also removes the explicit legacy generated-file set from older
|
||||
flat harness layouts so stale bridge sources cannot shadow the package.
|
||||
|
||||
1. Add `DebugBridge/` as a local package dependency. Depend on the
|
||||
`DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and
|
||||
`DebugBridgeTouch` come in transitively.
|
||||
2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target.
|
||||
3. In your `@main` App init, install the UIKit resolvers before starting the
|
||||
server, then register the generated accessor. Replace the example
|
||||
state/accessor names with the type the generator found:
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#if canImport(UIKit)
|
||||
import DebugBridgeUI
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Inside App.init(), after appState is initialized:
|
||||
#if DEBUG
|
||||
#if canImport(UIKit)
|
||||
DebugBridgeUIWiring.installAll()
|
||||
#endif
|
||||
DebugBridgeManager.shared.start(
|
||||
appState: appState,
|
||||
register: AppStateAccessor.register
|
||||
)
|
||||
#endif
|
||||
```
|
||||
|
||||
@@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp
|
||||
GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099
|
||||
```
|
||||
|
||||
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
|
||||
If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired,
|
||||
available iPhone.
|
||||
Automatic selection is restricted to available iPhones and prefers a wired
|
||||
phone. The daemon keeps a healthy rotated tunnel, then invalidates and
|
||||
rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection
|
||||
failure.
|
||||
If a newly started daemon reaches an already-running target whose one-use boot
|
||||
token was consumed by an earlier daemon, it verifies the bundle owner, force
|
||||
relaunches that target once, waits for a fresh token, verifies ownership again,
|
||||
and rotates normally.
|
||||
|
||||
## Step 4: Drive the device
|
||||
|
||||
@@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099`
|
||||
| `POST /session/release` | Release the lock. | bearer + session |
|
||||
| `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer |
|
||||
| `GET /elements` | Accessibility-tree snapshot. | bearer |
|
||||
| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
|
||||
| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier |
|
||||
| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer |
|
||||
| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
|
||||
| `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier |
|
||||
| `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier |
|
||||
| `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier |
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
The state snapshot is opt-in per field via a standalone generator marker
|
||||
comment immediately above a property. It is intentionally not a property
|
||||
wrapper, so it compiles cleanly with Observation:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
final class AppState {
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
|
||||
var authToken: String = "" // never exported
|
||||
}
|
||||
```
|
||||
|
||||
Unmarked fields never appear in the snapshot, which keeps tokens, PII, and
|
||||
auth state out of recorded fixtures by default. A marked field must be a
|
||||
writable instance `var` on a file-scope observable class, with an explicit type
|
||||
and an internal or public setter. Supported snapshot types are JSON-native
|
||||
scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`,
|
||||
`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of
|
||||
those types. Snapshot keys must be unique across observable classes. The
|
||||
generator reports and stops on invalid declarations, custom values, implicitly
|
||||
unwrapped Optionals, nested observable classes, or duplicate keys instead of
|
||||
emitting broken or lossy Swift. Restore uses two phases: every model validates
|
||||
the complete input first, and only then are assignments applied on MainActor.
|
||||
|
||||
## Step 5: Make remote agents work (optional)
|
||||
|
||||
|
||||
+2
-3
@@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation:
|
||||
1. The `DebugBridge` SPM target from `Package.swift`.
|
||||
2. The `#if DEBUG` block in the app's `@main` entry that calls
|
||||
`DebugBridgeManager.shared.start()`.
|
||||
3. Any `@Snapshotable` property wrappers on the canonical app state struct
|
||||
(the codegen-detection markers — the wrapper file lives inside
|
||||
DebugBridge so removing the SPM dep removes the wrapper too).
|
||||
3. Any standalone `// @Snapshotable` generator marker comments on the
|
||||
canonical app state class.
|
||||
4. Generated `StateAccessor.swift` files anywhere under the app source.
|
||||
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
|
||||
device (best-effort — only works if device is connected when /ios-clean
|
||||
|
||||
@@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation:
|
||||
1. The `DebugBridge` SPM target from `Package.swift`.
|
||||
2. The `#if DEBUG` block in the app's `@main` entry that calls
|
||||
`DebugBridgeManager.shared.start()`.
|
||||
3. Any `@Snapshotable` property wrappers on the canonical app state struct
|
||||
(the codegen-detection markers — the wrapper file lives inside
|
||||
DebugBridge so removing the SPM dep removes the wrapper too).
|
||||
3. Any standalone `// @Snapshotable` generator marker comments on the
|
||||
canonical app state class.
|
||||
4. Generated `StateAccessor.swift` files anywhere under the app source.
|
||||
5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the
|
||||
device (best-effort — only works if device is connected when /ios-clean
|
||||
|
||||
+39
-15
@@ -869,17 +869,33 @@ fi
|
||||
## Phase 1: Read source, plan codegen
|
||||
|
||||
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
|
||||
classes. Note any property marked with the `@Snapshotable` wrapper — those
|
||||
are the snapshot-eligible fields.
|
||||
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
|
||||
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
|
||||
Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
3. Show the user the accessor list and ask whether to install the DebugBridge
|
||||
classes. 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 `@Observable` macro. Each
|
||||
marked field must belong to a file-scope observable class and be a writable
|
||||
instance `var` with 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.
|
||||
2. 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
|
||||
|
||||
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
|
||||
1. 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.
|
||||
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
||||
`Package.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
|
||||
@@ -889,27 +905,35 @@ fi
|
||||
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
||||
(transitively pulls in Core + Touch). Release builds refuse to link these
|
||||
targets.
|
||||
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#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
|
||||
```
|
||||
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
6. 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.
|
||||
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
fresh in-memory-only token. The boot token becomes useless ~5s later.
|
||||
Anything scraping `os_log` past this point sees 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
|
||||
|
||||
@@ -917,7 +941,7 @@ Each iteration:
|
||||
|
||||
1. `GET /screenshot` (via daemon) → save PNG.
|
||||
2. `GET /elements` → accessibility tree.
|
||||
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
|
||||
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
|
||||
4. Decide next action based on what's on the screen vs the test goal.
|
||||
5. `POST /session/acquire` to grab the device lock.
|
||||
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
|
||||
@@ -981,7 +1005,7 @@ live.
|
||||
| `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` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
|
||||
+39
-15
@@ -97,17 +97,33 @@ fi
|
||||
## Phase 1: Read source, plan codegen
|
||||
|
||||
1. Walk the app source (passed as `--source <dir>`) and identify all `@Observable`
|
||||
classes. Note any property marked with the `@Snapshotable` wrapper — those
|
||||
are the snapshot-eligible fields.
|
||||
2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input <source-dir>`.
|
||||
First invocation builds the swift-syntax dependency tree (cold: 2-5 min).
|
||||
Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||
3. Show the user the accessor list and ask whether to install the DebugBridge
|
||||
classes. 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 `@Observable` macro. Each
|
||||
marked field must belong to a file-scope observable class and be a writable
|
||||
instance `var` with 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.
|
||||
2. 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
|
||||
|
||||
1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package
|
||||
1. 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.
|
||||
2. Add the generated `DebugBridge` local SPM dependency to the app's
|
||||
`Package.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
|
||||
@@ -117,27 +133,35 @@ fi
|
||||
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
|
||||
(transitively pulls in Core + Touch). Release builds refuse to link these
|
||||
targets.
|
||||
2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
|
||||
```swift
|
||||
#if DEBUG
|
||||
import DebugBridgeCore
|
||||
StateServer.shared.start()
|
||||
#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
|
||||
```
|
||||
3. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
|
||||
-destination 'platform=iOS,id=<UDID>' build install`.
|
||||
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
|
||||
Capture the boot token printed to `os_log` on first run.
|
||||
5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
|
||||
6. 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.
|
||||
6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
|
||||
fresh in-memory-only token. The boot token becomes useless ~5s later.
|
||||
Anything scraping `os_log` past this point sees 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
|
||||
|
||||
@@ -145,7 +169,7 @@ Each iteration:
|
||||
|
||||
1. `GET /screenshot` (via daemon) → save PNG.
|
||||
2. `GET /elements` → accessibility tree.
|
||||
3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state.
|
||||
3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state.
|
||||
4. Decide next action based on what's on the screen vs the test goal.
|
||||
5. `POST /session/acquire` to grab the device lock.
|
||||
6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/<key>` write.
|
||||
@@ -209,7 +233,7 @@ live.
|
||||
| `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` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
|
||||
+28
-23
@@ -806,49 +806,53 @@ After `/ios-qa` is installed in an app, the user may:
|
||||
|
||||
1. Add new `@Observable` classes or properties that need accessor coverage.
|
||||
2. Upgrade gstack to a newer version with hardening fixes.
|
||||
3. Move the `@Snapshotable` marker to a different field.
|
||||
3. Move the `// @Snapshotable` generator marker comment to a different field.
|
||||
|
||||
This skill regenerates the relevant artifacts in place.
|
||||
|
||||
**Templates live in upstream gstack.** This skill resolves them from
|
||||
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
|
||||
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
|
||||
pattern is gone.
|
||||
**Templates live in upstream gstack.** The installed
|
||||
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
|
||||
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
|
||||
and wildcard-copy patterns are gone.
|
||||
|
||||
## Phase 1: Detect installed version
|
||||
|
||||
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
|
||||
during install). If missing, treat the install as "unknown old version".
|
||||
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
|
||||
value baked into the installed gstack binary).
|
||||
2. Read upstream version from `$GSTACK_ROOT/VERSION`.
|
||||
3. If versions match AND no new `@Observable` classes were added, exit
|
||||
early with "already up to date".
|
||||
|
||||
## Phase 2: Regenerate codegen output
|
||||
|
||||
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
|
||||
Run the deterministic regenerator once. `--app-source` is the directory the
|
||||
accessor scanner should inspect; `--bridge-dir` is the local Swift package
|
||||
that the app links in Debug builds:
|
||||
|
||||
```bash
|
||||
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
|
||||
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
|
||||
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
||||
--app-source "$APP_SOURCE_DIR" \
|
||||
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
|
||||
```
|
||||
|
||||
The command removes only the known obsolete generated files from the former
|
||||
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
|
||||
Generation accepts file-scope observable classes and JSON-native scalar,
|
||||
array, String-keyed dictionary, and Optional field types. It rejects custom
|
||||
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
|
||||
snapshot keys before writing a completion marker.
|
||||
|
||||
The composite-hash cache key handles whether anything actually needs
|
||||
regenerating; if Swift version, generator git rev, lockfile, source content,
|
||||
and platform triple all match the cache, this is a ~50ms no-op.
|
||||
|
||||
## Phase 3: Update templated Swift files in place
|
||||
## Phase 3: Review the generated diff
|
||||
|
||||
For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
|
||||
1. Read the current installed file at
|
||||
`<app>/DebugBridgeGenerated/<Name>.swift`.
|
||||
2. Read the upstream template at
|
||||
`$GSTACK_HOME/ios-qa/templates/<Name>.swift.template`.
|
||||
3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's
|
||||
edits forward.
|
||||
4. Otherwise, replace the file outright with the new template (after
|
||||
AskUserQuestion if the diff is non-trivial).
|
||||
1. Review changes under `<app>/DebugBridge/` and
|
||||
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
|
||||
2. Confirm the command did not modify the app's handwritten Swift files.
|
||||
3. Keep app-specific wiring in the app target; canonical bridge package files
|
||||
are regenerated from upstream and should not be hand-edited.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -862,5 +866,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
| Symptom | Action |
|
||||
|---|---|
|
||||
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
|
||||
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
|
||||
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
|
||||
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
|
||||
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
|
||||
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
|
||||
|
||||
+28
-23
@@ -35,49 +35,53 @@ After `/ios-qa` is installed in an app, the user may:
|
||||
|
||||
1. Add new `@Observable` classes or properties that need accessor coverage.
|
||||
2. Upgrade gstack to a newer version with hardening fixes.
|
||||
3. Move the `@Snapshotable` marker to a different field.
|
||||
3. Move the `// @Snapshotable` generator marker comment to a different field.
|
||||
|
||||
This skill regenerates the relevant artifacts in place.
|
||||
|
||||
**Templates live in upstream gstack.** This skill resolves them from
|
||||
`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's
|
||||
`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch
|
||||
pattern is gone.
|
||||
**Templates live in upstream gstack.** The installed
|
||||
`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only
|
||||
the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch
|
||||
and wildcard-copy patterns are gone.
|
||||
|
||||
## Phase 1: Detect installed version
|
||||
|
||||
1. Read `<app>/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
|
||||
during install). If missing, treat the install as "unknown old version".
|
||||
2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
|
||||
value baked into the installed gstack binary).
|
||||
2. Read upstream version from `$GSTACK_ROOT/VERSION`.
|
||||
3. If versions match AND no new `@Observable` classes were added, exit
|
||||
early with "already up to date".
|
||||
|
||||
## Phase 2: Regenerate codegen output
|
||||
|
||||
Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
|
||||
Run the deterministic regenerator once. `--app-source` is the directory the
|
||||
accessor scanner should inspect; `--bridge-dir` is the local Swift package
|
||||
that the app links in Debug builds:
|
||||
|
||||
```bash
|
||||
swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
|
||||
gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
|
||||
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
|
||||
--app-source "$APP_SOURCE_DIR" \
|
||||
--bridge-dir "$APP_SOURCE_DIR/DebugBridge"
|
||||
```
|
||||
|
||||
The command removes only the known obsolete generated files from the former
|
||||
flat `DebugBridgeGenerated/` layout before emitting the current accessor.
|
||||
Generation accepts file-scope observable classes and JSON-native scalar,
|
||||
array, String-keyed dictionary, and Optional field types. It rejects custom
|
||||
types, implicitly unwrapped Optionals, nested observable classes, and duplicate
|
||||
snapshot keys before writing a completion marker.
|
||||
|
||||
The composite-hash cache key handles whether anything actually needs
|
||||
regenerating; if Swift version, generator git rev, lockfile, source content,
|
||||
and platform triple all match the cache, this is a ~50ms no-op.
|
||||
|
||||
## Phase 3: Update templated Swift files in place
|
||||
## Phase 3: Review the generated diff
|
||||
|
||||
For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
|
||||
1. Read the current installed file at
|
||||
`<app>/DebugBridgeGenerated/<Name>.swift`.
|
||||
2. Read the upstream template at
|
||||
`$GSTACK_HOME/ios-qa/templates/<Name>.swift.template`.
|
||||
3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's
|
||||
edits forward.
|
||||
4. Otherwise, replace the file outright with the new template (after
|
||||
AskUserQuestion if the diff is non-trivial).
|
||||
1. Review changes under `<app>/DebugBridge/` and
|
||||
`<app>/DebugBridgeGenerated/StateAccessor.swift`.
|
||||
2. Confirm the command did not modify the app's handwritten Swift files.
|
||||
3. Keep app-specific wiring in the app target; canonical bridge package files
|
||||
are regenerated from upstream and should not be hand-edited.
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -91,5 +95,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`:
|
||||
| Symptom | Action |
|
||||
|---|---|
|
||||
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
|
||||
| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. |
|
||||
| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` |
|
||||
| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. |
|
||||
| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. |
|
||||
| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |
|
||||
|
||||
Reference in New Issue
Block a user