diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen
deleted file mode 100755
index ffaec9ffb..000000000
--- a/bin/gstack-ios-qa-regen
+++ /dev/null
@@ -1,154 +0,0 @@
-#!/usr/bin/env bash
-# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
-# package and the app-owned typed state accessors.
-
-set -euo pipefail
-
-usage() {
- cat <<'EOF'
-Usage: gstack-ios-qa-regen --app-source
--bridge-dir
-
- --app-source Swift source tree to scan for @Observable state
- --bridge-dir Destination for the generated local DebugBridge package
-EOF
-}
-
-APP_SOURCE=""
-BRIDGE_DIR=""
-
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --app-source)
- [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; }
- APP_SOURCE="$2"
- shift 2
- ;;
- --bridge-dir)
- [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; }
- BRIDGE_DIR="$2"
- shift 2
- ;;
- -h|--help)
- usage
- exit 0
- ;;
- *)
- echo "gstack-ios-qa-regen: unknown argument: $1" >&2
- usage >&2
- exit 2
- ;;
- esac
-done
-
-if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then
- echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2
- usage >&2
- exit 2
-fi
-
-if [[ ! -d "$APP_SOURCE" ]]; then
- echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2
- exit 1
-fi
-
-if ! command -v bun >/dev/null 2>&1; then
- echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2
- exit 1
-fi
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
-TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates"
-GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts"
-VERSION_FILE="$GSTACK_ROOT/VERSION"
-GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated"
-
-for required in "$GENERATOR" "$VERSION_FILE"; do
- if [[ ! -f "$required" ]]; then
- echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2
- exit 1
- fi
-done
-
-TMP_FILE=""
-cleanup() {
- if [[ -n "$TMP_FILE" ]]; then
- rm -f "$TMP_FILE"
- fi
-}
-trap cleanup EXIT
-
-# Copy through a sibling temporary file so interruption never leaves a
-# truncated generated source. Preserve an unchanged destination byte-for-byte
-# and metadata-for-metadata on repeated runs.
-install_file() {
- local source="$1"
- local destination="$2"
-
- if [[ ! -f "$source" ]]; then
- echo "gstack-ios-qa-regen: missing template: $source" >&2
- exit 1
- fi
- if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
- return
- fi
-
- mkdir -p "$(dirname "$destination")"
- TMP_FILE="${destination}.tmp.$$"
- cp "$source" "$TMP_FILE"
- mv "$TMP_FILE" "$destination"
- TMP_FILE=""
-}
-
-# Invalidate the completion marker before changing any package source. A
-# failed or interrupted regeneration must never look current to ios-sync.
-mkdir -p "$GENERATED_DIR"
-rm -f -- "$GENERATED_DIR/.gstack-version"
-
-# This is intentionally an allowlist, not a template glob. Wiring belongs to
-# the consuming app and StateAccessor.swift is emitted by the parser below.
-install_file "$TEMPLATE_DIR/Package.swift.template" \
- "$BRIDGE_DIR/Package.swift"
-install_file "$TEMPLATE_DIR/StateServer.swift.template" \
- "$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift"
-install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \
- "$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift"
-install_file "$TEMPLATE_DIR/Bridges.swift.template" \
- "$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift"
-install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \
- "$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift"
-install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \
- "$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m"
-install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \
- "$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h"
-
-# Older ios-sync versions copied the entire template set flat into the app's
-# generated-source directory. Those files can shadow the package modules or
-# make Xcode compile two harness implementations. Remove only the explicit
-# obsolete generated paths; handwritten app sources are never touched.
-for obsolete in \
- "$BRIDGE_DIR/DebugBridgeWiring.swift" \
- "$BRIDGE_DIR/StateAccessor.swift" \
- "$GENERATED_DIR/Package.swift" \
- "$GENERATED_DIR/StateServer.swift" \
- "$GENERATED_DIR/DebugBridgeManager.swift" \
- "$GENERATED_DIR/Bridges.swift" \
- "$GENERATED_DIR/DebugOverlay.swift" \
- "$GENERATED_DIR/DebugBridgeTouch.m" \
- "$GENERATED_DIR/DebugBridgeTouch.h" \
- "$GENERATED_DIR/DebugBridgeWiring.swift"
-do
- if [[ -f "$obsolete" || -L "$obsolete" ]]; then
- rm -f -- "$obsolete"
- echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete"
- fi
-done
-
-bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
-
-# Stamp only after successful accessor generation. ios-sync uses this marker
-# to distinguish a complete current install from an interrupted regeneration.
-install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version"
-
-echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR"
-echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift"
diff --git a/docs/howto-ios-testing-with-gstack.md b/docs/howto-ios-testing-with-gstack.md
index 647a77c98..1187e9a85 100644
--- a/docs/howto-ios-testing-with-gstack.md
+++ b/docs/howto-ios-testing-with-gstack.md
@@ -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; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH).
+- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable).
- 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,49 +30,28 @@ 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: Generate the DebugBridge package
+## Step 1: Add the DebugBridge templates to your iOS app
-Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly:
+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:
-```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:
+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`:
```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
```
@@ -130,16 +109,7 @@ 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 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.
+If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device.
## Step 4: Drive the device
@@ -153,39 +123,15 @@ 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` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier |
+| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer |
+| `POST /state/restore` | Atomically restore a full snapshot. | 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 ` 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 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.
+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.
## Step 5: Make remote agents work (optional)
diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md
index 6c466896b..127649646 100644
--- a/ios-clean/SKILL.md
+++ b/ios-clean/SKILL.md
@@ -821,8 +821,9 @@ 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 standalone `// @Snapshotable` generator marker comments on the
- canonical app state class.
+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).
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
diff --git a/ios-clean/SKILL.md.tmpl b/ios-clean/SKILL.md.tmpl
index 21a1d5495..3a64481a9 100644
--- a/ios-clean/SKILL.md.tmpl
+++ b/ios-clean/SKILL.md.tmpl
@@ -50,8 +50,9 @@ 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 standalone `// @Snapshotable` generator marker comments on the
- canonical app state class.
+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).
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
diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md
index 24624be5f..a5d4575d4 100644
--- a/ios-qa/SKILL.md
+++ b/ios-qa/SKILL.md
@@ -869,33 +869,17 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source `) and identify all `@Observable`
- 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
+ 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 `.
+ 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
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
-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 "" \
- --bridge-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
+1. Add the `DebugBridge` 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
@@ -905,35 +889,27 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
-3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
+2. 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
```
-4. Build + deploy to the device with `xcodebuild -scheme
+3. Build + deploy to the device with `xcodebuild -scheme
-destination 'platform=iOS,id=' build install`.
-5. Launch via `devicectl device process launch --device --console `.
+4. Launch via `devicectl device process launch --device --console `.
Capture the boot token printed to `os_log` on first run.
-6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
+5. 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.
-7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
+6. 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
@@ -941,7 +917,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/` write.
@@ -1005,7 +981,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 ` 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 route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
+| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
| `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 |
diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl
index 78f9454b1..e93d2831a 100644
--- a/ios-qa/SKILL.md.tmpl
+++ b/ios-qa/SKILL.md.tmpl
@@ -97,33 +97,17 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source `) and identify all `@Observable`
- 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
+ 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 `.
+ 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
SPM dependency into their `Package.swift` (one AskUserQuestion).
## Phase 2: Bootstrap the device bridge
-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 "" \
- --bridge-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
+1. Add the `DebugBridge` 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
@@ -133,35 +117,27 @@ fi
The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)`
(transitively pulls in Core + Touch). Release builds refuse to link these
targets.
-3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`:
+2. 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
```
-4. Build + deploy to the device with `xcodebuild -scheme
+3. Build + deploy to the device with `xcodebuild -scheme
-destination 'platform=iOS,id=' build install`.
-5. Launch via `devicectl device process launch --device --console `.
+4. Launch via `devicectl device process launch --device --console `.
Capture the boot token printed to `os_log` on first run.
-6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon
+5. 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.
-7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a
+6. 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
@@ -169,7 +145,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/` write.
@@ -233,7 +209,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 ` 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 route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists |
+| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s |
| `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 |
diff --git a/ios-qa/daemon/src/devicectl.ts b/ios-qa/daemon/src/devicectl.ts
index c17aec751..ee1696eb9 100644
--- a/ios-qa/daemon/src/devicectl.ts
+++ b/ios-qa/daemon/src/devicectl.ts
@@ -13,10 +13,7 @@ export interface DeviceEntry {
identifier: string;
name: string;
model: string;
- platform: string; // "iOS" | "iPadOS" | "visionOS" | ...
- deviceType: string; // "iPhone" | "iPad" | "realityDevice" | ...
state: string; // "connected" | "available" | "available (paired)" | ...
- transport: string; // "wired" on USB devices; empty for stale/unavailable entries
paired: boolean;
}
@@ -86,10 +83,7 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
identifier: String(d.identifier ?? ''),
name: String(props?.name ?? 'unknown'),
model: String(hw?.productType ?? 'unknown'),
- platform: String(hw?.platform ?? ''),
- deviceType: String(hw?.deviceType ?? ''),
state: String(conn?.tunnelState ?? 'unknown'),
- transport: String(conn?.transportType ?? ''),
paired: pairingState === 'paired',
};
});
diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts
index f3a70c89f..82628b136 100644
--- a/ios-qa/daemon/src/index.ts
+++ b/ios-qa/daemon/src/index.ts
@@ -62,36 +62,16 @@ export async function startDaemon(opts: DaemonOptions): Promise | null = null;
+ let cachedTunnelAt = 0;
const getTunnel = async (): Promise => {
- // A successful bootstrap consumes and deletes the app's one-shot boot
- // token. Keep that rotated tunnel for this daemon's lifetime instead of
- // trying to bootstrap it again on a timer. Failed attempts are not cached.
- if (tunnel) return tunnel;
- if (!opts.tunnelProvider) return null;
-
- // Multiple first requests can arrive before bootstrap completes. Share
- // one provider call so they cannot race through independent rotations.
- if (!tunnelInFlight) {
- tunnelInFlight = Promise.resolve()
- .then(() => opts.tunnelProvider!())
- .then((candidate) => {
- if (candidate) tunnel = candidate;
- return candidate;
- })
- .finally(() => {
- tunnelInFlight = null;
- });
+ // Cache the tunnel for 30s; refresh on demand.
+ if (tunnel && Date.now() - cachedTunnelAt < 30_000) return tunnel;
+ if (opts.tunnelProvider) {
+ tunnel = await opts.tunnelProvider();
+ cachedTunnelAt = Date.now();
}
- return tunnelInFlight;
- };
-
- const invalidateTunnel = (failedTunnel: DeviceTunnel): void => {
- // A late response from the old app must not evict a tunnel that another
- // request has already refreshed. Object identity gives each bootstrap a
- // cheap generation token without exposing generation state elsewhere.
- if (tunnel === failedTunnel) tunnel = null;
+ return tunnel;
};
// 2. Tailnet probe (fail-closed).
@@ -106,7 +86,7 @@ export async function startDaemon(opts: DaemonOptions): Promise {
- await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
+ await handleLoopback({ req, res, tokenStore, getTunnel });
});
// Use port 0 for OS-assigned port when test/random port collisions are a risk.
const requestedPort = opts.loopbackPort;
@@ -117,7 +97,7 @@ export async function startDaemon(opts: DaemonOptions): Promise {
- await handleLoopback({ req, res, tokenStore, getTunnel, invalidateTunnel });
+ await handleLoopback({ req, res, tokenStore, getTunnel });
});
let v6Bound = false;
try {
@@ -138,7 +118,6 @@ export async function startDaemon(opts: DaemonOptions): Promise Promise;
- invalidateTunnel: (failedTunnel: DeviceTunnel) => void;
// Explicit security-log + allowlist paths (default to env-derived when undefined).
auditPath?: string;
attemptsPath?: string;
allowlistPath?: string;
}
-type DeviceProxyResponse = Awaited>;
-const RECOVERABLE_SOCKET_ERRORS = new Set([
- 'ECONNABORTED',
- 'ECONNREFUSED',
- 'ECONNRESET',
- 'EHOSTUNREACH',
- 'ENETUNREACH',
- 'EPIPE',
- 'ETIMEDOUT',
-]);
-
-function localProxyError(status: number, error: string): DeviceProxyResponse {
- const body = Buffer.from(JSON.stringify({ error }, sanitizeReplacer));
- return {
- status,
- headers: { 'content-type': 'application/json', 'content-length': String(body.length) },
- body,
- };
-}
-
-async function proxyAttempt(opts: Parameters[0]): Promise {
- try {
- return await proxyToDevice(opts);
- } catch (err) {
- const code = (err as { code?: string }).code;
- // CoreDevice can surface the same stale route as several different socket
- // failures while an app is being relaunched or replaced. Normalize those
- // failures so the cache recovery path below can handle all of them.
- if (code && RECOVERABLE_SOCKET_ERRORS.has(code)) {
- return localProxyError(503, 'device_disconnected');
- }
- throw err;
- }
-}
-
-function shouldRefreshTunnel(upstream: DeviceProxyResponse): boolean {
- // A relaunched app has a new in-memory bearer and rejects the daemon's old
- // rotated token. A redeploy can instead leave the old CoreDevice route
- // refusing connections or timing out. Both cases require a fresh bootstrap.
- if (upstream.status === 401) return true;
- if (upstream.status !== 503 && upstream.status !== 504) return false;
- try {
- const body = JSON.parse(upstream.body.toString('utf-8')) as { error?: string };
- return body.error === 'device_disconnected' || body.error === 'upstream_timeout';
- } catch {
- return false;
- }
-}
-
-function canReplayAfterRefresh(inbound: IncomingMessage, upstream: DeviceProxyResponse): boolean {
- // A 401 proves the stale bearer was rejected before StateServer dispatched
- // the operation, so retrying is safe even for a mutation. Connection loss
- // is ambiguous: the app may have applied a tap/write before its response was
- // lost. Replay only read-only requests in that case to prevent double taps.
- if (upstream.status === 401) return true;
- const method = inbound.method ?? 'GET';
- return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
-}
-
-async function proxyWithTunnelRecovery(opts: {
- inbound: IncomingMessage;
- body: Buffer;
- sessionId: string | null;
- agentIdentity?: string;
- getTunnel: HandlerCtx['getTunnel'];
- invalidateTunnel: HandlerCtx['invalidateTunnel'];
-}): Promise<{ tunnel: DeviceTunnel; upstream: DeviceProxyResponse } | null> {
- let tunnel = await opts.getTunnel();
- if (!tunnel) return null;
-
- const makeAttempt = (candidate: DeviceTunnel) => proxyAttempt({
- inbound: opts.inbound,
- body: opts.body,
- tunnel: candidate,
- sessionId: opts.sessionId,
- agentIdentity: opts.agentIdentity,
- });
-
- let upstream = await makeAttempt(tunnel);
- if (!shouldRefreshTunnel(upstream)) return { tunnel, upstream };
-
- const failedTunnel = tunnel;
- const replaySafe = canReplayAfterRefresh(opts.inbound, upstream);
- opts.invalidateTunnel(tunnel);
- const refreshed = await opts.getTunnel();
- if (!refreshed) return replaySafe ? null : { tunnel: failedTunnel, upstream };
-
- // The replacement is now cached for the next request, but never replay an
- // ambiguous mutation whose response was lost: doing so could double-tap or
- // apply a state transition twice.
- if (!replaySafe) return { tunnel: failedTunnel, upstream };
-
- tunnel = refreshed;
- upstream = await makeAttempt(tunnel);
- // Do not loop forever if the replacement app is itself unavailable. Leave
- // the cache empty so the next independent request can bootstrap again.
- if (shouldRefreshTunnel(upstream)) opts.invalidateTunnel(tunnel);
- return { tunnel, upstream };
-}
-
function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -350,7 +228,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
* loopback bind itself is the boundary).
*/
async function handleLoopback(ctx: HandlerCtx): Promise {
- const { req, res, tokenStore, getTunnel, invalidateTunnel } = ctx;
+ const { req, res, tokenStore, getTunnel } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@@ -384,23 +262,16 @@ async function handleLoopback(ctx: HandlerCtx): Promise {
}
// Other endpoints — proxy to the device.
+ const tunnel = await getTunnel();
+ if (!tunnel) {
+ sendJson(res, 503, { error: 'device_not_connected' });
+ return;
+ }
const body = await readBody(req);
if ('error' in body) { sendJson(res, 413, body); return; }
const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
const agentIdentity = (req.headers['x-agent-identity'] as string | undefined) ?? undefined;
- const proxied = await proxyWithTunnelRecovery({
- inbound: req,
- body,
- sessionId,
- agentIdentity,
- getTunnel,
- invalidateTunnel,
- });
- if (!proxied) {
- sendJson(res, 503, { error: 'device_not_connected' });
- return;
- }
- const { upstream } = proxied;
+ const upstream = await proxyToDevice({ inbound: req, body, tunnel, sessionId, agentIdentity });
res.writeHead(upstream.status, upstream.headers);
res.end(upstream.body);
} catch (err) {
@@ -416,7 +287,7 @@ interface TailnetCtx extends HandlerCtx {
* Tailnet handler — locked allowlist + capability tiers.
*/
async function handleTailnet(ctx: TailnetCtx): Promise {
- const { req, res, tokenStore, getTunnel, invalidateTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
+ const { req, res, tokenStore, getTunnel, whoIsImpl, auditPath, attemptsPath, allowlistPath } = ctx;
const url = parseUrl(req.url ?? '/');
const path = url.pathname ?? '/';
const method = req.method ?? 'GET';
@@ -504,20 +375,19 @@ async function handleTailnet(ctx: TailnetCtx): Promise {
}
// Proxy to device.
- const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
- const proxied = await proxyWithTunnelRecovery({
- inbound: req,
- body,
- sessionId,
- agentIdentity: session.identity,
- getTunnel,
- invalidateTunnel,
- });
- if (!proxied) {
+ const tunnel = await getTunnel();
+ if (!tunnel) {
sendJson(res, 503, { error: 'device_not_connected' });
return;
}
- const { tunnel, upstream } = proxied;
+ const sessionId = (req.headers['x-session-id'] as string | undefined) ?? null;
+ const upstream = await proxyToDevice({
+ inbound: req,
+ body,
+ tunnel,
+ sessionId,
+ agentIdentity: session.identity,
+ });
// Audit the action (mutating endpoints only).
if (requiredCapability !== 'observe') {
diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts
index f1be1ca48..aa6636938 100644
--- a/ios-qa/daemon/src/tunnel-bootstrap.ts
+++ b/ios-qa/daemon/src/tunnel-bootstrap.ts
@@ -5,8 +5,6 @@
// 2. launch the app on it (no-op if already running)
// 3. wait briefly for the in-app StateServer to start
// 4. copy the boot token from the app's sandbox via devicectl copy from
-// If an earlier daemon already consumed it, relaunch the app once to mint
-// a fresh boot token, then verify the relaunched StateServer again.
// 5. POST /auth/rotate to swap boot token → fresh in-memory token
// 6. return a DeviceTunnel pointing at the device's IPv6 with the rotated
// bearer that subsequent proxied requests carry
@@ -16,7 +14,6 @@
// live token, which it scopes per-tailnet-session via /auth/mint.
import { randomBytes } from 'crypto';
-import { spawnSync } from 'child_process';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
@@ -24,13 +21,12 @@ import {
isAppRunning,
launchApp,
copyFileFromAppContainer,
- type DeviceEntry,
type SpawnImpl,
type ResolveImpl,
} from './devicectl';
export interface BootstrapOptions {
- /** Target iPhone UDID. If null, picks the best connected paired iPhone. */
+ /** Target device UDID. If null, picks the first connected paired device. */
udid?: string;
/** Bundle ID of the iOS app hosting the StateServer. */
bundleId: string;
@@ -57,85 +53,10 @@ export type BootstrapErrorReason =
| 'launch_failed'
| 'device_locked'
| 'state_server_unreachable'
- | 'wrong_app'
| 'boot_token_unavailable'
| 'rotate_failed'
| 'resolve_failed';
-function isIPhoneDevice(device: DeviceEntry): boolean {
- const platform = device.platform.trim().toLowerCase();
- const deviceType = device.deviceType.trim().toLowerCase();
- const model = device.model.trim().toLowerCase();
-
- // productType is present even on older CoreDevice versions. Prefer the
- // explicit platform/type fields when available, but retain productType as
- // a compatibility fallback. An explicit non-iOS platform always loses.
- if (platform && platform !== 'ios') return false;
- return deviceType === 'iphone' || model.startsWith('iphone');
-}
-
-function isAvailableDevice(device: Pick): boolean {
- const state = device.state.trim().toLowerCase();
- const transport = device.transport.trim().toLowerCase();
- // Xcode 26.6 / iOS 27 beta can report a USB-reachable iPhone as
- // tunnelState=disconnected until the next devicectl command establishes
- // the CoreDevice tunnel. The wired transport is the authoritative signal
- // in that transitional state. Stale devices have no wired transport.
- return state === 'connected'
- || state.startsWith('available')
- || (state === 'disconnected' && transport === 'wired');
-}
-
-function defaultDeviceRank(device: DeviceEntry): number {
- if (!device.paired || !isIPhoneDevice(device) || !isAvailableDevice(device)) return -1;
-
- const state = device.state.trim().toLowerCase();
- const transport = device.transport.trim().toLowerCase();
- // Prefer the USB-connected phone the user is actively working with. Then
- // prefer an established CoreDevice tunnel over a merely available device.
- return (transport === 'wired' ? 100 : 0)
- + (state === 'connected' ? 10 : 0)
- + (state.startsWith('available') ? 1 : 0);
-}
-
-function pickDefaultDevice(devices: DeviceEntry[]): DeviceEntry | undefined {
- let best: DeviceEntry | undefined;
- let bestRank = -1;
- for (const device of devices) {
- const rank = defaultDeviceRank(device);
- if (rank > bestRank) {
- best = device;
- bestRank = rank;
- }
- }
- return best;
-}
-
-const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, {
- stdio: 'pipe',
- timeout: 60_000,
-});
-
-function relaunchApp(
- udid: string,
- bundleId: string,
- spawn: SpawnImpl = defaultSpawn,
-): { ok: true } | { ok: false; error: 'device_locked' | 'launch_failed'; detail?: string } {
- const r = spawn('xcrun', [
- 'devicectl', 'device', 'process', 'launch',
- '--device', udid,
- '--terminate-existing',
- bundleId,
- ]);
- if (r.status === 0) return { ok: true };
-
- const detail = `${r.stderr?.toString() ?? ''}${r.stdout?.toString() ?? ''}`.trim();
- if (detail.includes('was not, or could not be, unlocked')) {
- return { ok: false, error: 'device_locked', detail };
- }
- return { ok: false, error: 'launch_failed', detail };
-}
-
/**
* Bootstrap a real CoreDevice tunnel to an iOS app's StateServer. Used by
* the daemon's default tunnelProvider when GSTACK_IOS_TARGET_UDID is set
@@ -156,39 +77,9 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise d.identifier === opts.udid)
- : pickDefaultDevice(devices);
+ : devices.find((d) => d.paired) ?? devices[0];
if (!target) {
- if (opts.udid) {
- return { ok: false, error: 'device_not_found', detail: opts.udid };
- }
- const pairedIPhone = devices.find((d) => d.paired && isIPhoneDevice(d));
- if (pairedIPhone) {
- return {
- ok: false,
- error: 'device_not_found',
- detail: `paired iPhone ${pairedIPhone.name} (${pairedIPhone.identifier}) is ${pairedIPhone.state}; connect it over USB and unlock it`,
- };
- }
- const firstIPhone = devices.find(isIPhoneDevice);
- if (!firstIPhone) {
- return {
- ok: false,
- error: 'device_not_found',
- detail: 'no iPhone is connected; non-iOS devices are not eligible for iOS QA',
- };
- }
- return {
- ok: false,
- error: 'no_paired_device',
- detail: `device ${firstIPhone.name} (${firstIPhone.identifier}) is ${firstIPhone.state}; run \`xcrun devicectl manage pair --device ${firstIPhone.identifier}\` and tap Trust on the iPhone`,
- };
- }
- if (!isIPhoneDevice(target)) {
- return {
- ok: false,
- error: 'device_not_found',
- detail: `device ${target.name} (${target.identifier}) is ${target.platform || target.model}, not an iPhone`,
- };
+ return { ok: false, error: 'device_not_found', detail: opts.udid };
}
if (!target.paired) {
return {
@@ -197,13 +88,6 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise => {
- const deadline = Date.now() + startupTimeoutMs;
- while (Date.now() < deadline) {
- try {
- const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
- signal: AbortSignal.timeout(2_000),
- });
- if (r.ok) {
- const health = await r.json().catch(() => null) as { bundle_id?: string } | null;
- // Older bridges did not identify their bundle. Preserve compatibility,
- // but reject an explicit mismatch from current bridges: another debug
- // app already owns the fixed StateServer port on this device.
- if (health?.bundle_id && health.bundle_id !== opts.bundleId) {
- return {
- ok: false,
- error: 'wrong_app',
- detail: `expected ${opts.bundleId} but StateServer port ${port} belongs to ${health.bundle_id}; terminate the other debug app`,
- };
- }
- return null;
- }
- } catch { /* retry */ }
- await new Promise((res) => setTimeout(res, 250));
- }
- return {
- ok: false,
- error: 'state_server_unreachable',
- detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms`,
- };
- };
+ const deadline = Date.now() + startupTimeoutMs;
+ let healthOK = false;
+ while (Date.now() < deadline) {
+ try {
+ const r = await fetchFn(`http://[${ipv6}]:${port}/healthz`, {
+ signal: AbortSignal.timeout(2_000),
+ });
+ if (r.ok) { healthOK = true; break; }
+ } catch { /* retry */ }
+ await new Promise((res) => setTimeout(res, 250));
+ }
+ if (!healthOK) {
+ return { ok: false, error: 'state_server_unreachable', detail: `no /healthz response from [${ipv6}]:${port} within ${startupTimeoutMs}ms` };
+ }
- const healthFailure = await waitForStateServer();
- if (healthFailure) return healthFailure;
-
- const readBootToken = () => copyFileFromAppContainer({
+ const bootToken = copyFileFromAppContainer({
udid: target.identifier,
bundleId: opts.bundleId,
sourceRelativePath: tokenPath,
spawn,
});
-
- let bootToken = readBootToken();
if (!bootToken) {
- // A healthy running app can lack a boot token when an earlier daemon
- // already rotated it. A new daemon has no way to recover that in-memory
- // bearer, so restart exactly once to make StateServer mint a fresh one.
- // The explicit bundle check above prevents disrupting an unrelated app
- // that happens to own the fixed StateServer port.
- const relaunched = relaunchApp(target.identifier, opts.bundleId, spawn);
- if (!relaunched.ok) {
- return { ok: false, error: relaunched.error, detail: relaunched.detail };
- }
-
- // The token is written before StateServer opens its listener. Waiting for
- // it first prevents a stale response from the terminating process from
- // being mistaken for readiness of the replacement process.
- const tokenDeadline = Date.now() + startupTimeoutMs;
- while (!bootToken && Date.now() < tokenDeadline) {
- bootToken = readBootToken();
- if (!bootToken) await new Promise((res) => setTimeout(res, 250));
- }
- if (!bootToken) {
- return {
- ok: false,
- error: 'boot_token_unavailable',
- detail: `couldn't read ${tokenPath} from ${opts.bundleId} after relaunch`,
- };
- }
-
- const relaunchedHealthFailure = await waitForStateServer();
- if (relaunchedHealthFailure) return relaunchedHealthFailure;
+ return { ok: false, error: 'boot_token_unavailable', detail: `couldn't read ${tokenPath} from ${opts.bundleId}` };
}
// Step 5: rotate the boot token to a fresh in-memory-only one.
diff --git a/ios-qa/daemon/test/daemon-integration.test.ts b/ios-qa/daemon/test/daemon-integration.test.ts
index b41144ecd..b131cc3ff 100644
--- a/ios-qa/daemon/test/daemon-integration.test.ts
+++ b/ios-qa/daemon/test/daemon-integration.test.ts
@@ -132,258 +132,6 @@ describe('daemon — loopback listener', () => {
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
});
- test('concurrent first requests share one tunnel bootstrap', async () => {
- let bootstraps = 0;
- let releaseBootstrap!: () => void;
- let markBootstrapStarted!: () => void;
- const bootstrapStarted = new Promise((resolve) => { markBootstrapStarted = resolve; });
- const bootstrapGate = new Promise((resolve) => { releaseBootstrap = resolve; });
- const tunnel: DeviceTunnel = {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: stub.port,
- bootTokenRotated: STATE_SERVER_TOKEN,
- };
- const d = await startDaemon({
- loopbackPort: 0,
- tailnetEnabled: false,
- pidfilePath: join(workDir, 'daemon-concurrent-bootstrap.pid'),
- tunnelProvider: async () => {
- bootstraps++;
- markBootstrapStarted();
- await bootstrapGate;
- return tunnel;
- },
- });
- if ('error' in d) throw new Error(d.error);
-
- try {
- const base = `http://127.0.0.1:${d.loopbackPort}`;
- const requests = [
- fetchWith('GET', `${base}/screenshot`),
- fetchWith('GET', `${base}/screenshot`),
- fetchWith('GET', `${base}/screenshot`),
- ];
- await bootstrapStarted;
- await new Promise((resolve) => setTimeout(resolve, 25));
- releaseBootstrap();
- const responses = await Promise.all(requests);
- expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
- expect(bootstraps).toBe(1);
- } finally {
- releaseBootstrap();
- await d.close();
- }
- });
-
- test('reuses a healthy rotated tunnel beyond the old 30-second boundary', async () => {
- let bootstraps = 0;
- const tunnel: DeviceTunnel = {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: stub.port,
- bootTokenRotated: STATE_SERVER_TOKEN,
- };
- const d = await startDaemon({
- loopbackPort: 0,
- tailnetEnabled: false,
- pidfilePath: join(workDir, 'daemon-one-shot-bootstrap.pid'),
- tunnelProvider: async () => {
- bootstraps++;
- if (bootstraps > 1) throw new Error('one-shot boot token was already consumed');
- return tunnel;
- },
- });
- if ('error' in d) throw new Error(d.error);
-
- const realNow = Date.now;
- const firstRequestAt = realNow();
- try {
- const base = `http://127.0.0.1:${d.loopbackPort}`;
- const first = await fetchWith('GET', `${base}/screenshot`);
- expect(first.status).toBe(200);
-
- Date.now = () => firstRequestAt + 30_001;
- const later = await fetchWith('GET', `${base}/screenshot`);
- expect(later.status).toBe(200);
- expect(bootstraps).toBe(1);
- } finally {
- Date.now = realNow;
- await d.close();
- }
- });
-
- test('401 after app relaunch invalidates the token and concurrent requests share one rebootstrap', async () => {
- let bootstraps = 0;
- let markRefreshStarted!: () => void;
- let releaseRefresh!: () => void;
- const refreshStarted = new Promise((resolve) => { markRefreshStarted = resolve; });
- const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; });
- const staleTunnel: DeviceTunnel = {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: stub.port,
- bootTokenRotated: 'expired-after-relaunch',
- };
- const refreshedTunnel: DeviceTunnel = {
- ...staleTunnel,
- bootTokenRotated: STATE_SERVER_TOKEN,
- };
- const d = await startDaemon({
- loopbackPort: 0,
- tailnetEnabled: false,
- pidfilePath: join(workDir, 'daemon-relaunch-refresh.pid'),
- tunnelProvider: async () => {
- bootstraps++;
- if (bootstraps === 1) return staleTunnel;
- if (bootstraps === 2) {
- markRefreshStarted();
- await refreshGate;
- return refreshedTunnel;
- }
- throw new Error('concurrent 401s caused duplicate bootstraps');
- },
- });
- if ('error' in d) throw new Error(d.error);
-
- const requestStart = stub.receivedRequests.length;
- try {
- const base = `http://127.0.0.1:${d.loopbackPort}`;
- const requests = [
- fetchWith('GET', `${base}/screenshot`),
- fetchWith('GET', `${base}/screenshot`),
- fetchWith('GET', `${base}/screenshot`),
- ];
- await refreshStarted;
- await new Promise((resolve) => setTimeout(resolve, 25));
- expect(bootstraps).toBe(2);
- releaseRefresh();
-
- const responses = await Promise.all(requests);
- expect(responses.map((response) => response.status)).toEqual([200, 200, 200]);
- const attempts = stub.receivedRequests.slice(requestStart);
- expect(attempts.filter((request) => request.headers.authorization === 'Bearer expired-after-relaunch')).toHaveLength(3);
- expect(attempts.filter((request) => request.headers.authorization === `Bearer ${STATE_SERVER_TOKEN}`)).toHaveLength(3);
-
- const healthyReuse = await fetchWith('GET', `${base}/screenshot`);
- expect(healthyReuse.status).toBe(200);
- expect(bootstraps).toBe(2);
- } finally {
- releaseRefresh();
- await d.close();
- }
- });
-
- test('connection failure after redeploy reboots the tunnel once and keeps the replacement cached', async () => {
- const deadPort = await new Promise((resolve, reject) => {
- const reservation = createServer();
- reservation.once('error', reject);
- reservation.listen(0, '127.0.0.1', () => {
- const address = reservation.address();
- const port = typeof address === 'object' && address ? address.port : 0;
- reservation.close((err) => err ? reject(err) : resolve(port));
- });
- });
- let bootstraps = 0;
- const d = await startDaemon({
- loopbackPort: 0,
- tailnetEnabled: false,
- pidfilePath: join(workDir, 'daemon-redeploy-refresh.pid'),
- tunnelProvider: async () => {
- bootstraps++;
- if (bootstraps === 1) {
- return {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: deadPort,
- bootTokenRotated: 'old-deploy-token',
- };
- }
- if (bootstraps === 2) {
- return {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: stub.port,
- bootTokenRotated: STATE_SERVER_TOKEN,
- };
- }
- throw new Error('healthy replacement tunnel was not reused');
- },
- });
- if ('error' in d) throw new Error(d.error);
-
- try {
- const base = `http://127.0.0.1:${d.loopbackPort}`;
- const recovered = await fetchWith('GET', `${base}/screenshot`);
- expect(recovered.status).toBe(200);
- expect(JSON.parse(recovered.bodyText)).toEqual({ png_base64: 'abc=' });
- expect(bootstraps).toBe(2);
-
- const healthyReuse = await fetchWith('GET', `${base}/screenshot`);
- expect(healthyReuse.status).toBe(200);
- expect(bootstraps).toBe(2);
- } finally {
- await d.close();
- }
- });
-
- test('connection failure refreshes but never replays an ambiguous tap', async () => {
- const deadPort = await new Promise((resolve, reject) => {
- const reservation = createServer();
- reservation.once('error', reject);
- reservation.listen(0, '127.0.0.1', () => {
- const address = reservation.address();
- const port = typeof address === 'object' && address ? address.port : 0;
- reservation.close((err) => err ? reject(err) : resolve(port));
- });
- });
- let bootstraps = 0;
- const d = await startDaemon({
- loopbackPort: 0,
- tailnetEnabled: false,
- pidfilePath: join(workDir, 'daemon-mutation-no-replay.pid'),
- tunnelProvider: async () => {
- bootstraps++;
- return bootstraps === 1
- ? {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: deadPort,
- bootTokenRotated: 'old-deploy-token',
- }
- : {
- udid: 'STUB-UDID',
- ipv6Addr: '127.0.0.1',
- port: stub.port,
- bootTokenRotated: STATE_SERVER_TOKEN,
- };
- },
- });
- if ('error' in d) throw new Error(d.error);
-
- const beforeTaps = stub.receivedRequests.filter((request) => request.path === '/tap').length;
- try {
- const base = `http://127.0.0.1:${d.loopbackPort}`;
- const ambiguous = await fetchWith('POST', `${base}/tap`, {
- headers: { 'x-session-id': 'old-session', 'content-type': 'application/json' },
- body: JSON.stringify({ x: 10, y: 20 }),
- });
- expect(ambiguous.status).toBe(503);
- expect(bootstraps).toBe(2);
- expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps);
-
- const explicitRetry = await fetchWith('POST', `${base}/tap`, {
- headers: { 'x-session-id': 'new-session', 'content-type': 'application/json' },
- body: JSON.stringify({ x: 10, y: 20 }),
- });
- expect(explicitRetry.status).toBe(200);
- expect(stub.receivedRequests.filter((request) => request.path === '/tap')).toHaveLength(beforeTaps + 1);
- expect(bootstraps).toBe(2);
- } finally {
- await d.close();
- }
- });
-
test('returns 503 when no device tunnel is provided', async () => {
// Force tunnel provider to return null by closing + restarting with null provider.
await daemon.close();
diff --git a/ios-qa/daemon/test/tunnel-bootstrap.test.ts b/ios-qa/daemon/test/tunnel-bootstrap.test.ts
index 37af481be..188659b78 100644
--- a/ios-qa/daemon/test/tunnel-bootstrap.test.ts
+++ b/ios-qa/daemon/test/tunnel-bootstrap.test.ts
@@ -105,219 +105,6 @@ describe('bootstrapTunnel', () => {
}
});
- test('never auto-selects a connected paired Vision Pro ahead of an iPhone', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [
- {
- identifier: 'VISION',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'Vision Pro' },
- hardwareProperties: {
- productType: 'RealityDevice17,1',
- platform: 'visionOS',
- deviceType: 'realityDevice',
- },
- },
- {
- identifier: 'IPHONE',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired', transportType: 'wired' },
- deviceProperties: { name: 'USB iPhone' },
- hardwareProperties: {
- productType: 'iPhone17,1',
- platform: 'iOS',
- deviceType: 'iPhone',
- },
- },
- ] },
- },
- },
- {
- argsMatch: /devicectl device info processes -d IPHONE/,
- jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
- },
- {
- argsMatch: /devicectl device info details --device IPHONE/,
- jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::17' } } },
- },
- {
- argsMatch: /devicectl device copy from --device IPHONE/,
- destOutput: 'TOKEN\n',
- },
- ]);
-
- const r = await bootstrapTunnel({
- bundleId: 'com.test',
- spawnImpl: spawn,
- fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
- });
-
- expect(r.ok).toBe(true);
- if (r.ok) expect(r.tunnel.udid).toBe('IPHONE');
- });
-
- test('fails clearly when only non-iPhone platforms are connected', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [
- {
- identifier: 'VISION',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'Vision Pro' },
- hardwareProperties: {
- productType: 'RealityDevice17,1',
- platform: 'visionOS',
- deviceType: 'realityDevice',
- },
- },
- {
- identifier: 'IPAD',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'iPad' },
- hardwareProperties: {
- productType: 'iPad16,6',
- platform: 'iOS',
- deviceType: 'iPad',
- },
- },
- ] },
- },
- },
- ]);
-
- const r = await bootstrapTunnel({ bundleId: 'com.test', spawnImpl: spawn });
- expect(r.ok).toBe(false);
- if (!r.ok) {
- expect(r.error).toBe('device_not_found');
- expect(r.detail).toContain('no iPhone');
- }
- });
-
- test('rejects an explicitly targeted non-iPhone device', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [{
- identifier: 'VISION',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'Vision Pro' },
- hardwareProperties: {
- productType: 'RealityDevice17,1',
- platform: 'visionOS',
- deviceType: 'realityDevice',
- },
- }] },
- },
- },
- ]);
-
- const r = await bootstrapTunnel({ bundleId: 'com.test', udid: 'VISION', spawnImpl: spawn });
- expect(r.ok).toBe(false);
- if (!r.ok) {
- expect(r.error).toBe('device_not_found');
- expect(r.detail).toContain('not an iPhone');
- }
- });
-
- test('skips an unavailable paired device for a connected or available paired device', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [
- {
- identifier: 'STALE',
- connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
- deviceProperties: { name: 'Stale iPhone' },
- hardwareProperties: { productType: 'iPhone17,1' },
- },
- {
- identifier: 'WIRED',
- connectionProperties: { tunnelState: 'available', pairingState: 'paired', transportType: 'wired' },
- deviceProperties: { name: 'Wired iPhone' },
- hardwareProperties: { productType: 'iPhone18,2' },
- },
- ] },
- },
- },
- {
- argsMatch: /devicectl device info processes -d WIRED/,
- jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
- },
- {
- argsMatch: /devicectl device info details --device WIRED/,
- jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::2' } } },
- },
- {
- argsMatch: /devicectl device copy from --device WIRED/,
- destOutput: 'TOKEN\n',
- },
- ]);
-
- const r = await bootstrapTunnel({
- bundleId: 'com.test',
- spawnImpl: spawn,
- fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
- });
-
- expect(r.ok).toBe(true);
- if (r.ok) expect(r.tunnel.udid).toBe('WIRED');
- });
-
- test('accepts a wired iPhone whose tunnel is disconnected until devicectl wakes it', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [
- {
- identifier: 'STALE-VISION',
- connectionProperties: { tunnelState: 'unavailable', pairingState: 'paired' },
- deviceProperties: { name: 'Stale Vision Pro' },
- hardwareProperties: { productType: 'RealityDevice14,1' },
- },
- {
- identifier: 'WIRED-DISCONNECTED',
- connectionProperties: {
- tunnelState: 'disconnected',
- pairingState: 'paired',
- transportType: 'wired',
- },
- deviceProperties: { name: 'USB iPhone' },
- hardwareProperties: { productType: 'iPhone18,2' },
- },
- ] },
- },
- },
- {
- argsMatch: /devicectl device info processes -d WIRED-DISCONNECTED/,
- jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
- },
- {
- argsMatch: /devicectl device info details --device WIRED-DISCONNECTED/,
- jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::27' } } },
- },
- {
- argsMatch: /devicectl device copy from --device WIRED-DISCONNECTED/,
- destOutput: 'TOKEN\n',
- },
- ]);
-
- const r = await bootstrapTunnel({
- bundleId: 'com.test',
- spawnImpl: spawn,
- fetchImpl: (async () => new Response('{"ok":true}', { status: 200 })) as typeof fetch,
- });
-
- expect(r.ok).toBe(true);
- if (r.ok) expect(r.tunnel.udid).toBe('WIRED-DISCONNECTED');
- });
-
test('returns device_locked when launchApp errors due to lock', async () => {
const spawn = makeSpawn([
{
@@ -379,48 +166,6 @@ describe('bootstrapTunnel', () => {
if (!r.ok) expect(r.error).toBe('state_server_unreachable');
});
- test('rejects a StateServer owned by a different app bundle', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [{
- identifier: 'TEST',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'Test' },
- hardwareProperties: { productType: 'iPhone18,2' },
- }] },
- },
- },
- {
- argsMatch: /devicectl device info processes/,
- jsonOutput: { result: { runningProcesses: [] } },
- },
- {
- argsMatch: /devicectl device process launch/,
- },
- {
- argsMatch: /devicectl device info details/,
- jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::9' } } },
- },
- ]);
-
- const r = await bootstrapTunnel({
- bundleId: 'com.expected.app',
- spawnImpl: spawn,
- fetchImpl: (async () => new Response(
- JSON.stringify({ bundle_id: 'com.other.debug-app' }),
- { status: 200, headers: { 'content-type': 'application/json' } },
- )) as typeof fetch,
- });
-
- expect(r.ok).toBe(false);
- if (!r.ok) {
- expect(r.error).toBe('wrong_app');
- expect(r.detail).toContain('com.other.debug-app');
- }
- });
-
test('happy path: returns DeviceTunnel with rotated token', async () => {
const spawn = makeSpawn([
{
@@ -489,125 +234,6 @@ describe('bootstrapTunnel', () => {
expect(fetchCalls[fetchCalls.length - 1]?.url).toContain('/auth/rotate');
});
- test('relaunches once when a prior daemon consumed the running app boot token', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [{
- identifier: 'TEST-UDID',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'Test Device' },
- hardwareProperties: { productType: 'iPhone18,2' },
- }] },
- },
- },
- {
- argsMatch: /devicectl device info processes/,
- jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
- },
- {
- argsMatch: /devicectl device info details/,
- jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
- },
- {
- // A prior daemon rotated the one-use token, so StateServer deleted it.
- argsMatch: /devicectl device copy from/,
- exitCode: 1,
- stderr: 'source does not exist',
- },
- {
- argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
- },
- {
- argsMatch: /devicectl device copy from/,
- destOutput: 'FRESH-BOOT-TOKEN\n',
- },
- ]);
- const fetchCalls: Array<{ url: string; authorization?: string }> = [];
-
- const r = await bootstrapTunnel({
- bundleId: 'com.test',
- spawnImpl: spawn,
- fetchImpl: (async (url, init) => {
- const u = String(url);
- const headers = init?.headers as Record | undefined;
- fetchCalls.push({ url: u, authorization: headers?.Authorization });
- if (u.endsWith('/healthz')) {
- return new Response(JSON.stringify({ bundle_id: 'com.test' }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- }
- if (u.endsWith('/auth/rotate') && headers?.Authorization === 'Bearer FRESH-BOOT-TOKEN') {
- return new Response('{"ok":true}', { status: 200 });
- }
- return new Response('unauthorized', { status: 401 });
- }) as typeof fetch,
- startupTimeoutMs: 1_000,
- });
-
- expect(r.ok).toBe(true);
- expect(fetchCalls.filter((call) => call.url.endsWith('/healthz'))).toHaveLength(2);
- expect(fetchCalls.at(-1)?.authorization).toBe('Bearer FRESH-BOOT-TOKEN');
- });
-
- test('rechecks bundle ownership after recovering a consumed boot token', async () => {
- const spawn = makeSpawn([
- {
- argsMatch: /devicectl list devices/,
- jsonOutput: {
- result: { devices: [{
- identifier: 'TEST-UDID',
- connectionProperties: { tunnelState: 'connected', pairingState: 'paired' },
- deviceProperties: { name: 'Test Device' },
- hardwareProperties: { productType: 'iPhone18,2' },
- }] },
- },
- },
- {
- argsMatch: /devicectl device info processes/,
- jsonOutput: { result: { runningProcesses: [{ executable: 'file:\/\/\/var\/containers\/Bundle\/Application\/X\/com.test.app\/com.test' }] } },
- },
- {
- argsMatch: /devicectl device info details/,
- jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
- },
- { argsMatch: /devicectl device copy from/, exitCode: 1 },
- {
- argsMatch: /devicectl device process launch --device TEST-UDID --terminate-existing com\.test/,
- },
- { argsMatch: /devicectl device copy from/, destOutput: 'FRESH-BOOT-TOKEN\n' },
- ]);
- let healthChecks = 0;
- let rotateCalls = 0;
-
- const r = await bootstrapTunnel({
- bundleId: 'com.test',
- spawnImpl: spawn,
- fetchImpl: (async (url) => {
- const u = String(url);
- if (u.endsWith('/healthz')) {
- healthChecks++;
- return new Response(JSON.stringify({
- bundle_id: healthChecks === 1 ? 'com.test' : 'com.other.debug-app',
- }), {
- status: 200,
- headers: { 'content-type': 'application/json' },
- });
- }
- rotateCalls++;
- return new Response('{"ok":true}', { status: 200 });
- }) as typeof fetch,
- startupTimeoutMs: 1_000,
- });
-
- expect(r.ok).toBe(false);
- if (!r.ok) expect(r.error).toBe('wrong_app');
- expect(healthChecks).toBe(2);
- expect(rotateCalls).toBe(0);
- });
-
test('resolve_failed when hostname cant be resolved to an IPv6', async () => {
const spawn = makeSpawn([
{
diff --git a/ios-qa/scripts/gen-accessors-tool/Package.swift b/ios-qa/scripts/gen-accessors-tool/Package.swift
index 477aa61a0..f71c58793 100644
--- a/ios-qa/scripts/gen-accessors-tool/Package.swift
+++ b/ios-qa/scripts/gen-accessors-tool/Package.swift
@@ -1,8 +1,8 @@
// swift-tools-version:5.9
//
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
-// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
-// fields, and emits StateAccessor.swift for each one.
+// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
+// and emits StateAccessor.swift for each one.
//
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
// Subsequent runs are content-hash-cached and finish in ~50ms.
diff --git a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
index c47e3cae3..8287350cb 100644
--- a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
+++ b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
@@ -1,7 +1,6 @@
// gen-accessors entry point. Walks the input dir for *.swift files, parses
-// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
-// source-marker comments (plus the legacy attribute form), and emits
-// StateAccessor.swift for each.
+// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
+// properties, and emits StateAccessor.swift for each.
//
// Output goes to --output (default: same dir as input). Cache key is
// computed from a composite hash and stored at
@@ -16,8 +15,6 @@ struct AccessorSpec {
let fields: [(name: String, typeText: String)]
}
-private let generatorFormatVersion = "accessor-generator-v5"
-
@main
struct GenAccessors {
static func main() async {
@@ -35,84 +32,39 @@ struct GenAccessors {
}()
// Walk + collect *.swift files
- guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
+ guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
exit(3)
}
- // Parse + validate before consulting the cache. Invalid marked fields
- // must fail deterministically rather than being hidden by an older
- // cached output.
- var specs: [AccessorSpec] = []
- var diagnostics: [String] = []
- for path in swiftFiles {
- guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
- let tree = Parser.parse(source: source)
- let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
- visitor.walk(tree)
- specs.append(contentsOf: visitor.specs)
- diagnostics.append(contentsOf: visitor.diagnostics)
- }
- var snapshotKeyOwners: [String: String] = [:]
- for spec in specs {
- for field in spec.fields {
- if let previous = snapshotKeyOwners[field.name] {
- diagnostics.append(
- "snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
- + "keys must be unique across @Observable types"
- )
- } else {
- snapshotKeyOwners[field.name] = spec.className
- }
- }
- }
- if !diagnostics.isEmpty {
- let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
- + diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
- FileHandle.standardError.write(Data(message.utf8))
- exit(4)
- }
-
- let buildId = detectedBuildId()
- let accessorHash = computeAccessorHash(specs: specs)
- // Cache identity includes build provenance and generator ABI. The
- // separately computed accessorHash is schema-only and remains stable
- // across checkout paths, unrelated sources, and app rebuilds.
- let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
- let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
- ?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
+ // Composite cache key — codex catch (source content alone misses
+ // generator-logic changes).
+ let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
+ let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
- do {
- try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
- } catch {
- FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
- exit(5)
- }
if FileManager.default.fileExists(atPath: cachedOutput) {
// Cache hit. Copy to output dir.
- let finalOutput = "\(outputDir)/StateAccessor.swift"
- do {
- if FileManager.default.fileExists(atPath: finalOutput) {
- try FileManager.default.removeItem(atPath: finalOutput)
- }
- try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
- } catch {
- FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
- exit(5)
- }
+ try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
+ try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
print("gen-accessors: cache hit (\(cacheKey))")
return
}
- // Emit
- let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
- do {
- try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
- } catch {
- FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
- exit(5)
+ // Parse + extract specs
+ var specs: [AccessorSpec] = []
+ for path in swiftFiles {
+ guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
+ let tree = Parser.parse(source: source)
+ let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
+ visitor.walk(tree)
+ specs.append(contentsOf: visitor.specs)
}
+ // Emit
+ let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
+ try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
+ try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
+
// Populate cache
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
try? output.write(toFile: cachedOutput, atomically: true, encoding: .utf8)
@@ -120,154 +72,46 @@ struct GenAccessors {
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
}
- static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
- let inputURL = URL(fileURLWithPath: path).standardizedFileURL
- let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
- var isDirectory: ObjCBool = false
- guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
- isDirectory.boolValue,
- let enumerator = FileManager.default.enumerator(
- at: inputURL,
- includingPropertiesForKeys: [.isDirectoryKey],
- options: []
- ) else { return nil }
-
- // When --output is the input root, scan the root but still exclude
- // StateAccessor.swift. For a nested output, skip the entire subtree.
- let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
+ static func collectSwiftFiles(at path: String) -> [String]? {
+ guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
var files: [String] = []
- for case let fileURL as URL in enumerator {
- let normalized = fileURL.standardizedFileURL
- let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
- if values?.isDirectory == true {
- if normalized.lastPathComponent == "DebugBridgeGenerated"
- || (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
- enumerator.skipDescendants()
- }
- continue
- }
- guard normalized.pathExtension == "swift",
- normalized.lastPathComponent != "StateAccessor.swift" else { continue }
- files.append(normalized.path)
+ for case let f as String in enumerator {
+ if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
}
return files.sorted()
}
- static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
+ static func computeCacheKey(swiftFiles: [String]) -> String {
// Codex-flagged: hash must include Swift version, tool git rev, platform.
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
- let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
- #if arch(arm64)
- let platform = "darwin-arm64"
- #elseif arch(x86_64)
- let platform = "darwin-x86_64"
- #else
- let platform = "darwin-unknown"
- #endif
- var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
+ let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
+ let platform = "darwin-arm64" // simplified for the test harness
+ var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
for path in swiftFiles {
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
- // Do not include absolute checkout paths in cache identity.
- combined.append(Data("\(data.count):".utf8))
- combined.append(data)
- combined.append(Data("|".utf8))
+ combined += "\(path):\(data.count):\(data.sha256())|"
}
}
- return combined.sha256()
- }
-
- static func computeAccessorHash(specs: [AccessorSpec]) -> String {
- var signature = "snapshot-schema-v1\n"
- for spec in specs {
- signature += "C\(spec.className.utf8.count):\(spec.className)\n"
- for field in spec.fields {
- signature += "F\(field.name.utf8.count):\(field.name)"
- signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
- }
- signature += "E\n"
- }
- return signature.sha256()
+ return combined.data(using: .utf8)!.sha256()
}
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
- out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
- if !specs.isEmpty {
- // JSONSerialization produces Foundation bridge objects. Direct
- // `as?` casts let NSNumber cross-cast between Bool and numeric
- // Swift types. Round-tripping through JSONDecoder enforces the
- // declared Codable shape (including every collection element)
- // and preserves a successful Optional nil as a double Optional.
- out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
- out += " private struct Box: Decodable {\n"
- out += " let value: Value\n"
- out += " }\n\n"
- out += " static func decode(_ value: Any, as _: Value.Type) -> Value? {\n"
- out += " let object: [String: Any] = [\"value\": value]\n"
- out += " guard JSONSerialization.isValidJSONObject(object),\n"
- out += " let data = try? JSONSerialization.data(withJSONObject: object) else {\n"
- out += " return nil\n"
- out += " }\n"
- out += " do {\n"
- out += " return try JSONDecoder().decode(Box.self, from: data).value\n"
- out += " } catch {\n"
- out += " return nil\n"
- out += " }\n"
- out += " }\n"
- out += "}\n\n"
- }
+ out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
for spec in specs {
- // Accessors live in the app target beside its usually-internal
- // state types. A public signature cannot expose an internal type.
- out += "@MainActor\nenum \(spec.className)Accessor {\n"
- out += " static func register(_ state: \(spec.className)) {\n"
+ out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
+ out += " public static func register(_ state: \(spec.className)) {\n"
out += " StateServer.shared.register(\n"
- out += " buildId: {\n"
- out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
- out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
- out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
- out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
- out += " }(),\n"
- out += " accessorHash: \(String(reflecting: accessorHash)),\n"
- out += " atomicRestore: { keys, apply in\n"
- out += " // Validate every key and value before assignment.\n"
- out += " // StateServer invokes every model once with apply=false,\n"
- out += " // then applies every validated model with apply=true.\n"
- for (index, field) in spec.fields.enumerated() {
- let (name, typeText) = field
- out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
- out += " return .missingKey(\"\(name)\")\n"
- out += " }\n"
- out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
- out += " return .typeMismatch(\"\(name)\")\n"
- out += " }\n"
- }
- out += " if apply {\n"
- for (index, field) in spec.fields.enumerated() {
- out += " state.\(field.name) = restored\(index)\n"
- }
- out += " }\n"
- out += " return .ok\n"
- out += " }\n"
+ out += " buildId: \"\(buildId)\",\n"
+ out += " accessorHash: \"\(accessorHash)\",\n"
+ out += " atomicRestore: { _ in .ok }\n"
out += " )\n"
- for (name, typeText) in spec.fields {
- let wrapped = optionalWrappedType(typeText)
+ for (name, _) in spec.fields {
out += " StateServer.shared.registerAccessor(\n"
out += " key: \"\(name)\",\n"
- out += " type: \"\(typeText)\",\n"
- if wrapped != nil {
- out += " read: {\n"
- out += " guard let value = state.\(name) else { return NSNull() }\n"
- out += " return value as Any\n"
- out += " },\n"
- } else {
- out += " read: { state.\(name) as Any? },\n"
- }
- out += " write: { value in\n"
- out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
- out += " state.\(name) = typed\n"
- out += " return true\n"
- out += " }\n"
+ out += " type: \"Any\",\n"
+ out += " read: { state.\(name) as Any? },\n"
+ out += " write: { _ in false }\n"
out += " )\n"
}
out += " }\n}\n\n"
@@ -279,59 +123,6 @@ struct GenAccessors {
final class ObservableClassVisitor: SyntaxVisitor {
var specs: [AccessorSpec] = []
- var diagnostics: [String] = []
- private let sourcePath: String
-
- init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
- self.sourcePath = sourcePath
- super.init(viewMode: viewMode)
- }
-
- private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
- // Preserve compatibility with source that defines a custom attribute
- // or wrapper, even though that form conflicts with @Observable in
- // ordinary app models.
- if declaration.attributes.contains(where: { attribute in
- guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
- return attribute.attributeName.trimmedDescription == "Snapshotable"
- }) {
- return true
- }
-
- // Match a standalone ordinary line-comment trivia piece exactly.
- // Avoid declaration.description/contains: both would accept prose
- // such as `// do not expose through @Snapshotable`.
- return declaration.leadingTrivia.contains { piece in
- guard case .lineComment(let text) = piece else { return false }
- return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
- }
- }
-
- private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
- var ancestor = Syntax(node).parent
- while let current = ancestor {
- if let declaration = current.as(ClassDeclSyntax.self) {
- return "class \(declaration.name.text)"
- }
- if let declaration = current.as(StructDeclSyntax.self) {
- return "struct \(declaration.name.text)"
- }
- if let declaration = current.as(EnumDeclSyntax.self) {
- return "enum \(declaration.name.text)"
- }
- if let declaration = current.as(ActorDeclSyntax.self) {
- return "actor \(declaration.name.text)"
- }
- if let declaration = current.as(ExtensionDeclSyntax.self) {
- return "extension \(declaration.extendedType.trimmedDescription)"
- }
- if current.is(CodeBlockSyntax.self) {
- return "a local scope"
- }
- ancestor = current.parent
- }
- return nil
- }
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
// Look for @Observable attribute
@@ -342,88 +133,23 @@ final class ObservableClassVisitor: SyntaxVisitor {
guard isObservable else { return .visitChildren }
let className = node.name.text
- let markedMembers = node.memberBlock.members.compactMap {
- $0.decl.as(VariableDeclSyntax.self)
- }.filter(hasSnapshotableMarker)
- if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
- diagnostics.append(
- "\((sourcePath as NSString).lastPathComponent): nested @Observable class "
- + "\(className) inside \(enclosingScope) is unsupported; "
- + "move snapshot-enabled models to file scope"
- )
- // Continue walking so a more deeply nested marked model is also
- // diagnosed rather than silently omitted.
- return .visitChildren
- }
var fields: [(String, String)] = []
for member in node.memberBlock.members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
- // Field must opt in with the source marker (or legacy attribute).
- guard hasSnapshotableMarker(varDecl) else { continue }
-
- let bindingName = varDecl.bindings.first?
- .pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? ""
- let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
- if varDecl.bindingSpecifier.text == "let" {
- diagnostics.append("\(context) must be declared var, not let")
- continue
- }
- let modifierNames = varDecl.modifiers.map { modifier -> String in
- let detail = modifier.detail?.trimmedDescription ?? ""
- return modifier.name.text + detail
- }
- if modifierNames.contains(where: {
- $0 == "private" || $0 == "fileprivate"
- || $0 == "private(set)" || $0 == "fileprivate(set)"
- }) {
- diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
- continue
- }
- if modifierNames.contains("static") || modifierNames.contains("class") {
- diagnostics.append("\(context) must be an instance property")
- continue
- }
- if varDecl.bindings.count != 1 {
- diagnostics.append("\(context) declaration must contain exactly one binding")
- continue
- }
+ // Field must be marked @Snapshotable to be included
+ let isSnapshotable = varDecl.attributes.contains(where: { attr in
+ guard let attr = attr.as(AttributeSyntax.self) else { return false }
+ return attr.attributeName.trimmedDescription == "Snapshotable"
+ })
+ guard isSnapshotable else { continue }
for binding in varDecl.bindings {
- guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
- diagnostics.append("\(context) only supports an identifier binding")
- continue
+ if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
+ let name = pattern.identifier.text
+ let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
+ fields.append((name, typeText))
}
- // An initializer plus an accessor block is a stored property
- // with observers. Without an initializer, conservatively
- // reject the block rather than emitting a setter for a
- // computed/read-only declaration.
- if binding.accessorBlock != nil && binding.initializer == nil {
- diagnostics.append("\(context) must be stored and writable")
- continue
- }
- guard let annotation = binding.typeAnnotation else {
- diagnostics.append("\(context) requires an explicit type annotation")
- continue
- }
- let name = pattern.identifier.text
- let typeText = annotation.type.trimmedDescription
- .split(whereSeparator: { $0.isWhitespace })
- .joined(separator: " ")
- switch parseJSONSnapshotType(typeText) {
- case .valid:
- break
- case .implicitlyUnwrappedOptional:
- diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
- continue
- case .nestedOptional:
- diagnostics.append("\(context) cannot use a nested Optional type")
- continue
- case .unsupported:
- diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
- continue
- }
- fields.append((name, typeText))
}
}
@@ -434,233 +160,10 @@ final class ObservableClassVisitor: SyntaxVisitor {
}
}
-private indirect enum JSONSnapshotType {
- case scalar
- case optional(JSONSnapshotType)
- case array(JSONSnapshotType)
- case dictionary(JSONSnapshotType)
-
- var isOptional: Bool {
- if case .optional = self { return true }
- return false
- }
-}
-
-private enum JSONSnapshotTypeParseResult {
- case valid(JSONSnapshotType)
- case implicitlyUnwrappedOptional
- case nestedOptional
- case unsupported
-}
-
-/// Parse the deliberately small set of Swift types that can make a lossless
-/// trip through JSONSerialization and JSONDecoder without application-defined
-/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
-/// the bridge has no compiler/type-checker context in which to prove that they
-/// are JSON-native.
-private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
- let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !type.isEmpty else { return .unsupported }
-
- if type.hasSuffix("!") {
- return .implicitlyUnwrappedOptional
- }
-
- if type.hasSuffix("?") {
- let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- switch parseJSONSnapshotType(wrappedText) {
- case .valid(let wrapped):
- return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
- case let issue:
- return issue
- }
- }
-
- if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
- let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
- if dictionaryParts.count == 1 {
- switch parseJSONSnapshotType(dictionaryParts[0]) {
- case .valid(let element): return .valid(.array(element))
- case let issue: return issue
- }
- }
- guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
- return .unsupported
- }
- switch parseJSONSnapshotType(dictionaryParts[1]) {
- case .valid(let value): return .valid(.dictionary(value))
- case let issue: return issue
- }
- }
-
- if let generic = genericTypeParts(type) {
- let base = compactTypeName(generic.base)
- if base == "Optional" || base == "Swift.Optional" {
- guard generic.arguments.count == 1 else { return .unsupported }
- switch parseJSONSnapshotType(generic.arguments[0]) {
- case .valid(let wrapped):
- return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
- case let issue:
- return issue
- }
- }
- if base == "Array" || base == "Swift.Array" {
- guard generic.arguments.count == 1 else { return .unsupported }
- switch parseJSONSnapshotType(generic.arguments[0]) {
- case .valid(let element): return .valid(.array(element))
- case let issue: return issue
- }
- }
- if base == "Dictionary" || base == "Swift.Dictionary" {
- guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
- return .unsupported
- }
- switch parseJSONSnapshotType(generic.arguments[1]) {
- case .valid(let value): return .valid(.dictionary(value))
- case let issue: return issue
- }
- }
- return .unsupported
- }
-
- let scalar = compactTypeName(type)
- let supportedScalars: Set = [
- "String", "Swift.String",
- "Bool", "Swift.Bool",
- "Int", "Swift.Int", "Int8", "Swift.Int8", "Int16", "Swift.Int16",
- "Int32", "Swift.Int32", "Int64", "Swift.Int64",
- "UInt", "Swift.UInt", "UInt8", "Swift.UInt8", "UInt16", "Swift.UInt16",
- "UInt32", "Swift.UInt32", "UInt64", "Swift.UInt64",
- "Float", "Swift.Float", "Double", "Swift.Double",
- "CGFloat", "CoreGraphics.CGFloat",
- ]
- return supportedScalars.contains(scalar) ? .valid(.scalar) : .unsupported
-}
-
-private func isStringType(_ source: String) -> Bool {
- let type = compactTypeName(source)
- return type == "String" || type == "Swift.String"
-}
-
-private func compactTypeName(_ source: String) -> String {
- source.filter { !$0.isWhitespace }
-}
-
-private func outerDelimitedContents(_ source: String, open: Character, close: Character) -> String? {
- let characters = Array(source)
- guard characters.first == open, characters.last == close else { return nil }
- var depth = 0
- for (index, character) in characters.enumerated() {
- if character == open {
- depth += 1
- } else if character == close {
- depth -= 1
- guard depth >= 0 else { return nil }
- if depth == 0, index != characters.count - 1 { return nil }
- }
- }
- guard depth == 0 else { return nil }
- return String(characters.dropFirst().dropLast())
-}
-
-private func genericTypeParts(_ source: String) -> (base: String, arguments: [String])? {
- let characters = Array(source)
- guard let openIndex = characters.firstIndex(of: "<") else { return nil }
- var depth = 0
- var closeIndex: Int?
- for index in openIndex.." {
- depth -= 1
- guard depth >= 0 else { return nil }
- if depth == 0 {
- closeIndex = index
- break
- }
- }
- }
- guard let closeIndex, closeIndex == characters.count - 1 else { return nil }
- let base = String(characters[.. [String] {
- let characters = Array(source)
- var angleDepth = 0
- var squareDepth = 0
- var parenDepth = 0
- var start = 0
- var parts: [String] = []
-
- for (index, character) in characters.enumerated() {
- switch character {
- case "<": angleDepth += 1
- case ">": angleDepth -= 1
- case "[": squareDepth += 1
- case "]": squareDepth -= 1
- case "(": parenDepth += 1
- case ")": parenDepth -= 1
- default: break
- }
- if character == separator, angleDepth == 0, squareDepth == 0, parenDepth == 0 {
- parts.append(
- String(characters[start.. String? {
ProcessInfo.processInfo.environment[key]
}
-func detectedBuildId() -> String {
- if let explicit = getEnv("APP_BUILD_ID"), !explicit.isEmpty { return explicit }
- let marketing = getEnv("MARKETING_VERSION")
- let build = getEnv("CURRENT_PROJECT_VERSION")
- if let marketing, let build, !marketing.isEmpty, !build.isEmpty {
- return "\(marketing) (\(build))"
- }
- if let build, !build.isEmpty { return build }
- return "unknown"
-}
-
-func optionalWrappedType(_ typeText: String) -> String? {
- let type = typeText.trimmingCharacters(in: .whitespacesAndNewlines)
- if type.hasSuffix("?") {
- let wrapped = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
- return wrapped.isEmpty ? nil : wrapped
- }
-
- guard let optionalRange = type.range(of: #"^Optional\s*<"#, options: .regularExpression),
- let open = type[optionalRange].lastIndex(of: "<") else { return nil }
- let openIndex = type.distance(from: type.startIndex, to: open)
- var depth = 0
- for (offset, char) in type.enumerated() where offset >= openIndex {
- if char == "<" { depth += 1 }
- else if char == ">" {
- depth -= 1
- if depth == 0 {
- let close = type.index(type.startIndex, offsetBy: offset)
- guard type[type.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
- return nil
- }
- let wrappedStart = type.index(after: open)
- let wrapped = String(type[wrappedStart..30d entries removed, recent kept
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
-import { spawnSync } from 'child_process';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
@@ -16,11 +13,9 @@ import {
collectSwiftFiles,
parseSwift,
computeCacheKey,
- computeAccessorHash,
generate,
pruneCache,
render,
- AccessorGenerationError,
type AccessorSpec,
} from './gen-accessors';
@@ -35,14 +30,12 @@ afterEach(() => {
});
describe('parseSwift — fork regex-failure-mode fixtures', () => {
- test('parses @Observable class with source-marker comments', () => {
+ test('parses @Observable class with simple @Snapshotable fields', () => {
const src = `
@Observable
final class AppState {
- // @Snapshotable
- var isLoggedIn: Bool = false
- // @Snapshotable
- var username: String = ""
+ @Snapshotable var isLoggedIn: Bool = false
+ @Snapshotable var username: String = ""
var notSnapshotable: Int = 0
}
`;
@@ -53,100 +46,12 @@ final class AppState {
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
});
- test('retains legacy @Snapshotable attribute parsing', () => {
- const specs = parseSwift(`
-@Observable
-final class LegacyState {
- @Snapshotable var counter: Int = 0
-}
-`);
- expect(specs).toEqual([{
- className: 'LegacyState',
- fields: [{ name: 'counter', typeText: 'Int' }],
- }]);
- });
-
- test('does not treat documentation prose as a source marker', () => {
- const specs = parseSwift(`
-@Observable
-final class PrivateState {
- /// Do not expose this field through @Snapshotable.
- var token: String = "secret"
-}
-`);
- expect(specs).toHaveLength(0);
- });
-
- test('does not let a trailing marker comment bleed into the next field', () => {
- const specs = parseSwift(`
-@Observable
-final class PrivateState {
- var oldValue: Int = 0 // @Snapshotable
- var nextValue: Int = 1
-}
-`);
- expect(specs).toHaveLength(0);
- });
-
- test('ignores exact-looking markers inside nested block comments', () => {
- const specs = parseSwift(`
-@Observable
-final class PrivateState {
- /*
- /* // @Snapshotable */
- // @Snapshotable
- var leaked: String = "secret"
- */
- // @Snapshotable
- var visible: Int = 1
-}
-`);
- expect(specs).toEqual([{
- className: 'PrivateState',
- fields: [{ name: 'visible', typeText: 'Int' }],
- }]);
- });
-
- test('ignores declarations and markers inside multiline and raw strings', () => {
- const specs = parseSwift(`
-let ordinary = """
-@Observable class FakeA {
- // @Snapshotable
- var leaked: Int = 0
-}
-"""
-let raw = #"""
-@Observable class FakeB { @Snapshotable var leaked: String = "" }
-"""#
-@Observable
-final class RealState {
- // @Snapshotable
- var safe: Bool = true
-}
-`);
- expect(specs).toEqual([{
- className: 'RealState',
- fields: [{ name: 'safe', typeText: 'Bool' }],
- }]);
- });
-
- test('ignores marker words in standalone trailing prose', () => {
- const specs = parseSwift(`
-@Observable
-final class PrivateState {
- // @Snapshotable fields are intentionally disabled here.
- var token: String = "secret"
-}
-`);
- expect(specs).toHaveLength(0);
- });
-
test('handles @Snapshotable on multi-line type signatures', () => {
const src = `
@Observable
class Cart {
@Snapshotable var items:
- [Dictionary]
+ [CartItem]
= []
var unrelated: Int = 0
}
@@ -155,67 +60,20 @@ class Cart {
expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items');
- expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
+ expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
});
- test('handles JSON-compatible generic types in property signatures', () => {
+ test('handles generic types in property signatures', () => {
const src = `
@Observable
class Repo {
- @Snapshotable var pages: Dictionary]> = [:]
+ @Snapshotable var pages: Dictionary]> = [:]
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
- expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
- });
-
- test('accepts qualified JSON-native scalar and collection spellings', () => {
- const specs = parseSwift(`
-@Observable
-class Metrics {
- // @Snapshotable
- var ratio: CoreGraphics.CGFloat = 0
- // @Snapshotable
- var counts: Swift.Array = []
-}
-`);
- expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
- 'CoreGraphics.CGFloat',
- 'Swift.Array',
- ]);
- });
-
- test('rejects custom values that Foundation JSON cannot serialize', () => {
- expect(() => parseSwift(`
-@Observable
-class Repo {
- // @Snapshotable
- var pages: Dictionary]> = [:]
-}
-`)).toThrow("unsupported snapshot type 'Result- '");
- });
-
- test('rejects nested observable types instead of emitting an unqualified reference', () => {
- expect(() => parseSwift(`
-enum Namespace {
- @Observable
- class State {
- // @Snapshotable
- var count: Int = 0
- }
-}
-`)).toThrow('nested @Observable types are not supported');
- });
-
- test('ignores nested observable types that expose no snapshot fields', () => {
- expect(parseSwift(`
-enum Namespace {
- @Observable
- class State { var transient: Int = 0 }
-}
-`)).toEqual([]);
+ expect(specs[0]!.fields[0]!.typeText).toContain('Result');
});
test('ignores fields without @Snapshotable marker', () => {
@@ -256,7 +114,10 @@ class B {
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
});
- test('diagnoses fields with computed body braces', () => {
+ test('skips fields with computed body braces', () => {
+ // Codex flagged "Properties with computed getters / didSet blocks" as a
+ // failure mode of the fork's regex. We deliberately exclude them here —
+ // computed properties are not snapshot-eligible.
const src = `
@Observable
class M {
@@ -266,50 +127,9 @@ class M {
}
}
`;
- expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
- });
-
- test.each([
- ['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
- ['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
- ['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
- ['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
- ['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
- ['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
- ['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
- ['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
- ['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
- ['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
- ['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
- ['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
- ])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
- expect(() => parseSwift(`
-@Observable
-final class InvalidState {
- ${declaration}
-}
-`)).toThrow(expected);
- });
-
- test('aggregates invalid declaration diagnostics with class and line context', () => {
- try {
- parseSwift(`
-@Observable
-final class InvalidState {
- // @Snapshotable
- let first: Int = 1
- // @Snapshotable
- private var second: String = ""
-}
-`);
- throw new Error('expected parseSwift to fail');
- } catch (error) {
- expect(error).toBeInstanceOf(AccessorGenerationError);
- const generationError = error as AccessorGenerationError;
- expect(generationError.diagnostics).toHaveLength(2);
- expect(generationError.message).toContain('InvalidState (line 4)');
- expect(generationError.message).toContain('InvalidState (line 6)');
- }
+ const specs = parseSwift(src);
+ expect(specs).toHaveLength(1);
+ expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
});
});
@@ -424,68 +244,6 @@ describe('computeCacheKey', () => {
});
expect(k1).not.toBe(k2);
});
-
- test('app build provenance invalidates the cache key', () => {
- const f = join(workDir, 'a.swift');
- writeFileSync(f, '@Observable class A {}');
- const shared = {
- swiftFiles: [f],
- swiftVersion: '6.0.0',
- toolGitRev: 'abc',
- platformTriple: 'darwin-arm64',
- };
- expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
- computeCacheKey({ ...shared, buildId: '101' }),
- );
- });
-
- test('equivalent source content does not depend on its absolute checkout path', () => {
- const firstDir = join(workDir, 'checkout-a');
- const secondDir = join(workDir, 'checkout-b');
- mkdirSync(firstDir);
- mkdirSync(secondDir);
- const first = join(firstDir, 'State.swift');
- const second = join(secondDir, 'State.swift');
- writeFileSync(first, '@Observable class A {}');
- writeFileSync(second, '@Observable class A {}');
- const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
- expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
- computeCacheKey({ swiftFiles: [second], ...versioning }),
- );
- });
-});
-
-describe('computeAccessorHash', () => {
- const base: AccessorSpec[] = [{
- className: 'AppState',
- fields: [
- { name: 'count', typeText: 'Int' },
- { name: 'nickname', typeText: 'String?' },
- ],
- }];
-
- test('is deterministic for the same ordered accessor signatures', () => {
- expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
- });
-
- test('changes when field order, name, or type changes', () => {
- const reordered: AccessorSpec[] = [{
- className: 'AppState',
- fields: [...base[0]!.fields].reverse(),
- }];
- const renamed: AccessorSpec[] = [{
- className: 'AppState',
- fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
- }];
- const retyped: AccessorSpec[] = [{
- className: 'AppState',
- fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
- }];
- const hash = computeAccessorHash(base);
- expect(computeAccessorHash(reordered)).not.toBe(hash);
- expect(computeAccessorHash(renamed)).not.toBe(hash);
- expect(computeAccessorHash(retyped)).not.toBe(hash);
- });
});
describe('generate', () => {
@@ -525,35 +283,6 @@ class AppState {
expect(r1.cacheKey).toBe(r2.cacheKey);
});
- test('custom nested output subtree cannot poison the second-run cache key', () => {
- const inputDir = join(workDir, 'src');
- const outputDir = join(inputDir, 'generated', 'accessors');
- mkdirSync(inputDir);
- writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
- const cacheRoot = join(workDir, 'cache');
-
- const r1 = generate({
- inputDir,
- outputDir,
- cacheRoot,
- swiftVersion: '6',
- toolGitRev: 't',
- platformTriple: 'p',
- });
- writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
- const r2 = generate({
- inputDir,
- outputDir,
- cacheRoot,
- swiftVersion: '6',
- toolGitRev: 't',
- platformTriple: 'p',
- });
-
- expect(r2.cacheHit).toBe(true);
- expect(r2.cacheKey).toBe(r1.cacheKey);
- });
-
test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
@@ -566,62 +295,6 @@ class AppState {
expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false);
});
-
- test('unmarked source churn changes cache identity but not schema identity', () => {
- const inputDir = join(workDir, 'src');
- mkdirSync(inputDir);
- const state = join(inputDir, 'state.swift');
- const unrelated = join(inputDir, 'unrelated.swift');
- writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
- writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
- const cacheRoot = join(workDir, 'cache');
- const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
- const first = generate(options);
- writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
- const second = generate(options);
- expect(second.cacheKey).not.toBe(first.cacheKey);
- expect(second.accessorHash).toBe(first.accessorHash);
- });
-
- test('buildId changes cannot reuse generated fallback provenance', () => {
- const inputDir = join(workDir, 'src');
- mkdirSync(inputDir);
- writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
- const cacheRoot = join(workDir, 'cache');
- const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
- const first = generate({ ...shared, buildId: 'build-100' });
- const second = generate({ ...shared, buildId: 'build-101' });
- expect(second.cacheKey).not.toBe(first.cacheKey);
- expect(second.cacheHit).toBe(false);
- expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
- });
-
- test('CLI exits 4 with actionable diagnostics and no generated output', () => {
- const inputDir = join(workDir, 'src');
- const outputDir = join(workDir, 'generated');
- mkdirSync(inputDir);
- writeFileSync(join(inputDir, 'state.swift'), `
-@Observable final class InvalidState {
- // @Snapshotable
- private let secret = "nope"
-}
-`);
- const result = spawnSync('bun', [
- join(import.meta.dir, 'gen-accessors.ts'),
- '--input', inputDir,
- '--output', outputDir,
- ], {
- encoding: 'utf8',
- env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
- });
- expect(result.status).toBe(4);
- // `let` is diagnosed first; either way the class/property is named and
- // generation never emits an accessor that will fail in xcodebuild.
- expect(result.stderr).toContain('InvalidState');
- expect(result.stderr).toContain("field 'secret' must be declared var, not let");
- expect(result.stderr).not.toContain('AccessorGenerationError:');
- expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
- });
});
describe('pruneCache', () => {
@@ -659,395 +332,14 @@ describe('render', () => {
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}];
const out = render(specs, 'build-1.2.3', 'hash-abc');
- expect(out).toContain('enum AppStateAccessor');
- expect(out).not.toContain('public enum AppStateAccessor');
- expect(out).toContain('static func register(_ state: AppState)');
- expect(out).not.toContain('public static func register(_ state: AppState)');
+ expect(out).toContain('public enum AppStateAccessor');
expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"');
- expect(out).toContain('return .missingKey("a")');
- expect(out).toContain('return .typeMismatch("b")');
- expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
- expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
- expect(out).toContain('atomicRestore: { keys, apply in');
- expect(out).toContain('if apply {');
- expect(out).toContain('state.a = restored0');
- expect(out).toContain('state.b = restored1');
- expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
- expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
- expect(out).toContain('state.a = typed');
- expect(out).toContain('return true');
- expect(out).not.toContain('atomicRestore: { _ in .ok }');
- expect(out).not.toContain('write: { _ in false }');
- expect(out).toContain('CFBundleShortVersionString');
- expect(out).toContain('CFBundleVersion');
- expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
+ expect(out).toContain('buildId: "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"');
- expect(out).toContain('import DebugBridgeCore');
- expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif');
});
-
- test('emits explicit NSNull round-trip handling for Optional fields', () => {
- const out = render([{
- className: 'AppState',
- fields: [
- { name: 'nickname', typeText: 'String?' },
- { name: 'selection', typeText: 'Optional' },
- ],
- }], 'build', 'schema');
-
- expect(out).toContain('let restored0: String?');
- expect(out).toContain('if raw0 is NSNull');
- expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw0, as: String.self)');
- expect(out).toContain('let restored1: Optional');
- expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw1, as: Int.self)');
- expect(out).toContain('guard let value = state.nickname else { return NSNull() }');
- expect(out).toContain('if value is NSNull');
- expect(out).toContain('state.nickname = nil');
- expect(out).not.toContain('value as? String?');
- });
-
- test('rejects duplicate snapshot keys across observable models', () => {
- expect(() => render([
- { className: 'FirstState', fields: [{ name: 'count', typeText: 'Int' }] },
- { className: 'SecondState', fields: [{ name: 'count', typeText: 'Int' }] },
- ], 'build', 'schema')).toThrow("snapshot key 'count' is declared by both FirstState and SecondState");
- });
-
- test('typechecks beside an internal @Observable app state using a comment marker', () => {
- if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
-
- const coreSource = join(workDir, 'DebugBridgeCore.swift');
- const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
- writeFileSync(coreSource, `
-public typealias JSONDict = [String: Any]
-
-@MainActor
-public final class StateServer {
- public static let shared = StateServer()
- public enum RestoreResult {
- case ok
- case missingKey(String)
- case typeMismatch(String)
- }
- private init() {}
- public func register(
- buildId: String,
- accessorHash: String,
- atomicRestore: @escaping (JSONDict, Bool) -> RestoreResult
- ) {}
- public func registerAccessor(
- key: String,
- type: String,
- read: @escaping () -> Any?,
- write: @escaping (Any) -> Bool
- ) {}
-}
-`);
- const emitModule = spawnSync('swiftc', [
- '-emit-module',
- '-parse-as-library',
- '-module-name', 'DebugBridgeCore',
- coreSource,
- '-emit-module-path', coreModule,
- ], { encoding: 'utf8' });
- if (emitModule.status !== 0) {
- throw new Error(`failed to build DebugBridgeCore test stub:\n${emitModule.stderr}`);
- }
-
- const appSource = join(workDir, 'AppState.swift');
- writeFileSync(appSource, `import Observation
-
-@Observable
-final class AppState {
- // @Snapshotable
- var counter: Int = 0
-}
-
-${render([{
- className: 'AppState',
- fields: [{ name: 'counter', typeText: 'Int' }],
- }], 'build-test', 'hash-test')}`);
- const typecheck = spawnSync('swiftc', [
- '-typecheck',
- '-D', 'DEBUG',
- '-I', workDir,
- appSource,
- ], { encoding: 'utf8' });
- if (typecheck.status !== 0) {
- throw new Error(`generated accessor failed Swift type checking:\n${typecheck.stderr}`);
- }
- });
-
- test('strict JSON typing and cross-model validate-before-apply restore run correctly', () => {
- if (process.platform !== 'darwin') return;
- if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
-
- const coreSource = join(workDir, 'DebugBridgeCore.swift');
- const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
- const coreLibrary = join(workDir, 'libDebugBridgeCore.dylib');
- writeFileSync(coreSource, `
-public typealias JSONDict = [String: Any]
-
-@MainActor
-public final class StateServer {
- public typealias Restore = (JSONDict, Bool) -> RestoreResult
- public enum RestoreResult { case ok, missingKey(String), typeMismatch(String) }
- public static let shared = StateServer()
- public var restores: [Restore] = []
- public var reads: [String: () -> Any?] = [:]
- public var writes: [String: (Any) -> Bool] = [:]
- private init() {}
- public func register(buildId: String, accessorHash: String, atomicRestore: @escaping Restore) {
- restores.append(atomicRestore)
- }
- public func registerAccessor(
- key: String,
- type: String,
- read: @escaping () -> Any?,
- write: @escaping (Any) -> Bool
- ) {
- reads[key] = read
- writes[key] = write
- }
- public func restoreAll(_ keys: JSONDict) -> RestoreResult {
- for restore in restores {
- let result = restore(keys, false)
- guard case .ok = result else { return result }
- }
- for restore in restores {
- let result = restore(keys, true)
- guard case .ok = result else { return result }
- }
- return .ok
- }
-}
-`);
- const emitCore = spawnSync('swiftc', [
- '-emit-library', '-emit-module', '-parse-as-library',
- '-module-name', 'DebugBridgeCore', coreSource,
- '-emit-module-path', coreModule,
- '-o', coreLibrary,
- ], { encoding: 'utf8' });
- if (emitCore.status !== 0) throw new Error(`failed to build runtime stub:\n${emitCore.stderr}`);
-
- const appSource = join(workDir, 'OptionalRoundTrip.swift');
- writeFileSync(appSource, `
-import Foundation
-import Observation
-import DebugBridgeCore
-
-@Observable
-final class AppState {
- // @Snapshotable
- var nickname: String? = nil
- // @Snapshotable
- var count: Int = 1
-}
-
-@Observable
-final class FeatureState {
- // @Snapshotable
- var enabled: Bool = false
-}
-
-${render([
- {
- className: 'AppState',
- fields: [
- { name: 'nickname', typeText: 'String?' },
- { name: 'count', typeText: 'Int' },
- ],
- },
- {
- className: 'FeatureState',
- fields: [{ name: 'enabled', typeText: 'Bool' }],
- },
- ], 'fallback-build', 'schema-hash')}
-
-@main
-struct Runner {
- @MainActor static func main() {
- let state = AppState()
- let feature = FeatureState()
- AppStateAccessor.register(state)
- FeatureStateAccessor.register(feature)
- guard StateServer.shared.reads["nickname"]?() is NSNull else { fatalError("nil read") }
- guard StateServer.shared.writes["nickname"]?("Ada") == true, state.nickname == "Ada" else {
- fatalError("optional write")
- }
- guard StateServer.shared.writes["nickname"]?(NSNull()) == true, state.nickname == nil else {
- fatalError("null write")
- }
- guard StateServer.shared.writes["count"]?(true) == false, state.count == 1 else {
- fatalError("boolean must not coerce to integer")
- }
- guard StateServer.shared.writes["enabled"]?(1) == false, feature.enabled == false else {
- fatalError("integer must not coerce to boolean")
- }
- let valid = try! JSONSerialization.jsonObject(
- with: Data(#"{"nickname":"Grace","count":7,"enabled":true}"#.utf8)
- ) as! JSONDict
- switch StateServer.shared.restoreAll(valid) {
- case .ok: break
- default: fatalError("valid restore")
- }
- guard state.nickname == "Grace", state.count == 7, feature.enabled else { fatalError("restore values") }
- switch StateServer.shared.restoreAll(["nickname": NSNull(), "count": 8, "enabled": false]) {
- case .ok: break
- default: fatalError("null restore")
- }
- guard state.nickname == nil, state.count == 8, !feature.enabled else { fatalError("null restore values") }
- state.nickname = "unchanged"
- state.count = 9
- feature.enabled = false
- switch StateServer.shared.restoreAll(["nickname": "would-partially-apply", "count": 10, "enabled": 1]) {
- case .typeMismatch("enabled"): break
- default: fatalError("expected mismatch")
- }
- guard state.nickname == "unchanged", state.count == 9, !feature.enabled else { fatalError("cross-model partial mutation") }
- }
-}
-`);
- const executable = join(workDir, 'optional-round-trip');
- const compile = spawnSync('swiftc', [
- '-D', 'DEBUG', '-I', workDir, '-L', workDir, '-lDebugBridgeCore',
- '-parse-as-library', appSource, '-o', executable,
- ], { encoding: 'utf8' });
- if (compile.status !== 0) throw new Error(`generated Optional accessor failed compilation:\n${compile.stderr}`);
- const run = spawnSync(executable, [], {
- encoding: 'utf8',
- env: { ...process.env, DYLD_LIBRARY_PATH: workDir },
- });
- if (run.status !== 0) throw new Error(`generated Optional accessor failed at runtime:\n${run.stderr}`);
- });
-});
-
-describe('SwiftSyntax generator parity', () => {
- test('isolates canonical markers and rejects inaccessible fields', () => {
- if (process.platform !== 'darwin') return;
- if (spawnSync('swift', ['--version'], { encoding: 'utf8' }).status !== 0) return;
-
- const packageDir = join(import.meta.dir, 'gen-accessors-tool');
- const inputDir = join(workDir, 'swift-syntax-input');
- const outputDir = join(workDir, 'swift-syntax-output');
- const cacheRoot = join(workDir, 'swift-syntax-cache');
- mkdirSync(inputDir);
- writeFileSync(join(inputDir, 'State.swift'), `
-import Observation
-@Observable
-final class ToolState {
- let documentation = """
- // @Snapshotable
- var stringLeak: String = "no"
- """
- /* // @Snapshotable */
- var blockLeak: Int = 1
- var trailingLeak: Int = 2 // @Snapshotable
- // prose about @Snapshotable must not opt in
- var proseLeak: Int = 3
- // @Snapshotable
- var nickname: String? = nil
- // @Snapshotable
- var names:
- [String]
- = []
- // @Snapshotable
- var count: Int = 0
- // @Snapshotable
- var enabled: Bool = false
-}
-`);
- const env = {
- ...process.env,
- GSTACK_IOS_CACHE_ROOT: cacheRoot,
- APP_BUILD_ID: 'syntax-test-build',
- GEN_ACCESSORS_REV: 'syntax-test-v5',
- };
- const run = spawnSync('swift', [
- 'run', '--package-path', packageDir, 'gen-accessors',
- '--input', inputDir, '--output', outputDir,
- ], { encoding: 'utf8', env, timeout: 180_000 });
- if (run.status !== 0) throw new Error(`SwiftSyntax generator failed:\n${run.stderr}`);
- const output = readFileSync(join(outputDir, 'StateAccessor.swift'), 'utf8');
- expect(output).toContain('key: "nickname"');
- expect(output).toContain('key: "names"');
- expect(output).toContain('key: "count"');
- expect(output).toContain('key: "enabled"');
- expect(output).toContain('_GStackDebugBridgeSnapshotJSON.decode(raw0');
- expect(output).toContain('atomicRestore: { keys, apply in');
- expect(output).toContain('if apply {');
- expect(output).not.toMatch(/raw\d+ as\?/);
- expect(output).toContain('CFBundleShortVersionString');
- expect(output).toContain(`accessorHash: "${computeAccessorHash([{
- className: 'ToolState',
- fields: [
- { name: 'nickname', typeText: 'String?' },
- { name: 'names', typeText: '[String]' },
- { name: 'count', typeText: 'Int' },
- { name: 'enabled', typeText: 'Bool' },
- ],
- }])}"`);
- expect(output).not.toContain('key: "stringLeak"');
- expect(output).not.toContain('key: "blockLeak"');
- expect(output).not.toContain('key: "trailingLeak"');
- expect(output).not.toContain('key: "proseLeak"');
-
- const invalidInput = join(workDir, 'swift-syntax-invalid');
- const invalidOutput = join(workDir, 'swift-syntax-invalid-output');
- mkdirSync(invalidInput);
- writeFileSync(join(invalidInput, 'Invalid.swift'), `
-import Observation
-@Observable
-final class InvalidState {
- // @Snapshotable
- public private(set) var token: String = "secret"
- // @Snapshotable
- let immutable: Int = 1
- // @Snapshotable
- var inferred = 2
- // @Snapshotable
- var legacy: String! = nil
- // @Snapshotable
- var custom: Date = .now
-}
-
-enum Namespace {
- @Observable
- final class NestedState {
- // @Snapshotable
- var nestedValue: Int = 0
- }
-}
-
-@Observable
-final class FirstState {
- // @Snapshotable
- var shared: String = "first"
-}
-
-@Observable
-final class DuplicateState {
- // @Snapshotable
- var shared: String = "duplicate"
-}
-`);
- const invalid = spawnSync('swift', [
- 'run', '--package-path', packageDir, 'gen-accessors',
- '--input', invalidInput, '--output', invalidOutput,
- ], { encoding: 'utf8', env, timeout: 180_000 });
- expect(invalid.status).toBe(4);
- expect(invalid.stderr).toContain('InvalidState.token cannot be private');
- expect(invalid.stderr).toContain('InvalidState.immutable must be declared var, not let');
- expect(invalid.stderr).toContain('InvalidState.inferred requires an explicit type annotation');
- expect(invalid.stderr).toContain('InvalidState.legacy cannot use an implicitly unwrapped Optional type');
- expect(invalid.stderr).toContain("InvalidState.custom uses unsupported non-JSON snapshot type 'Date'");
- expect(invalid.stderr).toContain('nested @Observable class NestedState');
- expect(invalid.stderr).toContain("snapshot key 'shared' is declared by both FirstState and DuplicateState");
- expect(existsSync(join(invalidOutput, 'StateAccessor.swift'))).toBe(false);
- }, 180_000);
});
describe('collectSwiftFiles', () => {
@@ -1063,17 +355,4 @@ describe('collectSwiftFiles', () => {
const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort());
});
-
- test('excludes the configured output subtree and every StateAccessor.swift', () => {
- const source = join(workDir, 'App.swift');
- const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
- const outputDir = join(workDir, 'custom-output');
- mkdirSync(join(workDir, 'old'));
- mkdirSync(outputDir);
- writeFileSync(source, 'struct App {}');
- writeFileSync(staleAccessor, '// stale generated output');
- writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
-
- expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
- });
});
diff --git a/ios-qa/scripts/gen-accessors.ts b/ios-qa/scripts/gen-accessors.ts
index 738e08364..05475f918 100644
--- a/ios-qa/scripts/gen-accessors.ts
+++ b/ios-qa/scripts/gen-accessors.ts
@@ -5,16 +5,15 @@
// first time. Also exercised by tests so we can verify the cache + parse
// behavior without a Swift toolchain.
//
-// The TS fast path uses a lightweight Swift lexical scanner — it understands:
+// The TS port uses a stricter regex than the fork's original — it understands:
// - @Observable class declarations
-// - `// @Snapshotable` property markers (only marked fields are exported)
-// - Legacy @Snapshotable attributes for existing integrations
+// - @Snapshotable property markers (only marked fields are exported)
// - Multi-line type signatures (collapses whitespace before matching)
-// - JSON-native generic arrays and String-keyed dictionaries
+// - Generic type parameters (matched as opaque text inside the type)
//
-// Invalid marked declarations (computed/immutable/inaccessible/untyped,
-// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
-// Swift that only fails later in xcodebuild or at snapshot time.
+// What it does NOT handle (deferred to the SwiftPM tool):
+// - Computed properties with bodies (regex can mis-parse braces)
+// - Property wrappers other than @Snapshotable
//
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
// platform_triple || source_content_hash. Source-only hash misses generator
@@ -36,16 +35,6 @@ export interface AccessorSpec {
fields: AccessorField[];
}
-export class AccessorGenerationError extends Error {
- readonly diagnostics: string[];
-
- constructor(diagnostics: string[]) {
- super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
- this.name = 'AccessorGenerationError';
- this.diagnostics = diagnostics;
- }
-}
-
export interface GenInputs {
inputDir: string;
outputDir?: string;
@@ -59,105 +48,56 @@ export interface GenInputs {
export interface GenResult {
outputPath: string;
cacheKey: string;
- accessorHash: string;
specs: AccessorSpec[];
cacheHit: boolean;
}
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
-const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
-const JSON_SCALAR_TYPES = new Set([
- 'String',
- 'Bool',
- 'Int', 'Int8', 'Int16', 'Int32', 'Int64',
- 'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
- 'Float', 'Double', 'CGFloat',
-]);
-
-export function collectSwiftFiles(
- dir: string,
- opts: { excludeGenerated?: boolean; outputDir?: string } = {},
-): string[] {
+export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
const out: string[] = [];
const excludeGenerated = opts.excludeGenerated ?? true;
- const root = resolve(dir);
- const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
-
- function walk(currentDir: string): void {
- for (const name of readdirSync(currentDir)) {
- const full = join(currentDir, name);
- const s = statSync(full);
- if (s.isDirectory()) {
- // The generated subtree must never participate in its own cache key.
- // Keep the well-known-name guard for direct collectSwiftFiles callers,
- // and use the actual --output path when generate() supplies one.
- const isOutputSubtree = outputDir !== undefined
- && outputDir !== root
- && resolve(full) === outputDir;
- if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
- walk(full);
- } else if (name.endsWith('.swift')) {
- // Skip generated accessor files wherever they live. Otherwise moving
- // an old copy outside the output directory poisons the next cache key.
- if (excludeGenerated && name === 'StateAccessor.swift') continue;
- out.push(full);
- }
+ for (const name of readdirSync(dir)) {
+ const full = join(dir, name);
+ const s = statSync(full);
+ if (s.isDirectory()) {
+ // Skip generated output dir (when it lives under the input dir)
+ if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
+ out.push(...collectSwiftFiles(full, opts));
+ } else if (name.endsWith('.swift')) {
+ // Skip the codegen output file. Otherwise the second run picks it up,
+ // changes the cache key, and the cache never hits.
+ if (excludeGenerated && name === 'StateAccessor.swift') continue;
+ out.push(full);
}
}
-
- walk(root);
return out.sort();
}
export function parseSwift(source: string): AccessorSpec[] {
const specs: AccessorSpec[] = [];
- const diagnostics: string[] = [];
- const masked = maskSwiftSource(source);
// Find `@Observable\n(public )?(final )?class ` followed by a brace
// block. We then scan inside that block for @Snapshotable fields.
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
- for (const match of masked.matchAll(classPattern)) {
+ let match: RegExpExecArray | null;
+ while ((match = classPattern.exec(source)) !== null) {
const className = match[1]!;
- const matchOffset = match.index!;
- const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
- const startIdx = openBraceOffset + 1;
- const endIdx = findMatchingBrace(masked, startIdx - 1);
+ const startIdx = classPattern.lastIndex;
+ const endIdx = findMatchingBrace(source, startIdx - 1);
if (endIdx === -1) continue;
- const body = masked.slice(startIdx, endIdx);
+ const body = source.slice(startIdx, endIdx);
- const parsed = parseFields(body, source, startIdx, className);
- if (braceDepthAt(masked, matchOffset) !== 0) {
- if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
- const line = source.slice(0, matchOffset).split(/\r?\n/).length;
- diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
- }
- continue;
- }
- diagnostics.push(...parsed.diagnostics);
- const fields = parsed.fields;
+ const fields = parseFields(body);
if (fields.length > 0) {
specs.push({ className, fields });
}
}
-
- if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
return specs;
}
-function braceDepthAt(masked: string, offset: number): number {
- let depth = 0;
- for (let i = 0; i < offset; i++) {
- if (masked[i] === '{') depth++;
- else if (masked[i] === '}') depth = Math.max(0, depth - 1);
- }
- return depth;
-}
-
function findMatchingBrace(s: string, openIdx: number): number {
- // Strings and comments have already been blanked by maskSwiftSource, so
- // braces here are syntax rather than prose or literal content.
+ // openIdx points at '{'. Return idx of matching '}', or -1.
let depth = 0;
for (let i = openIdx; i < s.length; i++) {
const c = s[i];
@@ -165,305 +105,48 @@ function findMatchingBrace(s: string, openIdx: number): number {
else if (c === '}') {
depth--;
if (depth === 0) return i;
+ } else if (c === '"' || c === "'") {
+ // skip string literal
+ const quote = c;
+ i++;
+ while (i < s.length && s[i] !== quote) {
+ if (s[i] === '\\') i++;
+ i++;
+ }
+ } else if (c === '/' && s[i + 1] === '/') {
+ // skip line comment
+ while (i < s.length && s[i] !== '\n') i++;
+ } else if (c === '/' && s[i + 1] === '*') {
+ i += 2;
+ while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
+ i++;
}
}
return -1;
}
-/**
- * Blank comments and string literals while preserving byte offsets/newlines.
- * A canonical standalone `// @Snapshotable` comment is rewritten to the
- * equivalent attribute token. This is intentionally lexical rather than a
- * whole-file regexp: markers inside nested block comments, normal/triple/raw
- * strings, trailing comments, and prose comments must never opt a field in.
- */
-function maskSwiftSource(source: string): string {
- const out = source.split('');
-
- const blank = (start: number, end: number): void => {
- for (let j = start; j < end; j++) {
- if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
- }
- };
-
- let i = 0;
- while (i < source.length) {
- if (source[i] === '/' && source[i + 1] === '/') {
- let end = i + 2;
- while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
- const lineStart = source.lastIndexOf('\n', i - 1) + 1;
- const standalone = source.slice(lineStart, i).trim().length === 0;
- const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
- blank(i, end);
- if (isMarker) {
- const marker = '@Snapshotable';
- for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
- }
- i = end;
- continue;
- }
-
- if (source[i] === '/' && source[i + 1] === '*') {
- const start = i;
- i += 2;
- let depth = 1;
- while (i < source.length && depth > 0) {
- if (source[i] === '/' && source[i + 1] === '*') {
- depth++;
- i += 2;
- } else if (source[i] === '*' && source[i + 1] === '/') {
- depth--;
- i += 2;
- } else {
- i++;
- }
- }
- blank(start, i);
- continue;
- }
-
- // Swift strings may be ordinary, multiline, or raw (`#"..."#` and
- // `#"""..."""#`). Mask the entire literal, including interpolation;
- // declarations cannot legally originate inside a literal.
- let hashCount = 0;
- while (source[i + hashCount] === '#') hashCount++;
- const quoteIdx = i + hashCount;
- if (source[quoteIdx] === '"') {
- const start = i;
- const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
- const quoteCount = triple ? 3 : 1;
- i = quoteIdx + quoteCount;
- while (i < source.length) {
- if (hashCount === 0 && source[i] === '\\') {
- i += 2;
- continue;
- }
- const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
- const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
- if (quoteRun && hashesMatch) {
- i += quoteCount + hashCount;
- break;
- }
- i++;
- }
- blank(start, i);
- continue;
- }
-
- i++;
- }
- return out.join('');
-}
-
-const DECL_MODIFIERS = new Set([
- 'public', 'internal', 'package', 'open', 'private', 'fileprivate',
- 'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
- 'nonisolated', 'isolated', 'borrowing', 'consuming',
-]);
-
-function parseFields(
- body: string,
- fullSource: string,
- bodyOffset: number,
- className: string,
-): { fields: AccessorField[]; diagnostics: string[] } {
+function parseFields(body: string): AccessorField[] {
+ // Look for @Snapshotable followed by var/let declarations. Allow attribute
+ // ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
+ // Multi-line types are handled by greedy non-newline match in the type, but
+ // we collapse adjacent whitespace first to avoid false negatives.
+ const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
+ const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
const fields: AccessorField[] = [];
- const diagnostics: string[] = [];
- let braceDepth = 0;
-
- const lineFor = (localOffset: number): number => {
- const absolute = bodyOffset + localOffset;
- let line = 1;
- for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
- return line;
- };
- const fail = (offset: number, message: string): void => {
- diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
- };
- const skipWhitespace = (start: number): number => {
- let at = start;
- while (at < body.length && /\s/.test(body[at]!)) at++;
- return at;
- };
- const identifierAt = (start: number): { text: string; end: number } | undefined => {
- const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
- return m ? { text: m[0], end: start + m[0].length } : undefined;
- };
- const skipBalancedParens = (start: number): number => {
- if (body[start] !== '(') return start;
- let depth = 0;
- for (let at = start; at < body.length; at++) {
- if (body[at] === '(') depth++;
- else if (body[at] === ')' && --depth === 0) return at + 1;
- }
- return body.length;
- };
-
- for (let i = 0; i < body.length; i++) {
- if (body[i] === '{') {
- braceDepth++;
- continue;
- }
- if (body[i] === '}') {
- braceDepth--;
- continue;
- }
- if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
- const before = i === 0 ? '' : body[i - 1]!;
- const after = body[i + '@Snapshotable'.length] ?? '';
- if (/\w/.test(before) || /\w/.test(after)) continue;
-
- const markerOffset = i;
- let at = skipWhitespace(i + '@Snapshotable'.length);
- const modifiers: string[] = [];
- let bindingKind: 'var' | 'let' | undefined;
-
- // Permit other Swift attributes between the marker and declaration. They
- // remain the compiler's responsibility; this scanner only owns the
- // snapshot contract. Parenthesized attribute arguments are skipped.
- while (body[at] === '@') {
- const attribute = identifierAt(at + 1);
- if (!attribute) break;
- at = skipWhitespace(attribute.end);
- if (body[at] === '(') at = skipBalancedParens(at);
- at = skipWhitespace(at);
- }
-
- while (at < body.length) {
- const token = identifierAt(at);
- if (!token) break;
- if (token.text === 'var' || token.text === 'let') {
- bindingKind = token.text;
- at = token.end;
- break;
- }
- if (!DECL_MODIFIERS.has(token.text)) break;
- let modifier = token.text;
- at = skipWhitespace(token.end);
- if (body[at] === '(') {
- const end = skipBalancedParens(at);
- modifier += body.slice(at, end).replace(/\s+/g, '');
- at = skipWhitespace(end);
- }
- modifiers.push(modifier);
- }
-
- if (!bindingKind) {
- fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
- continue;
- }
-
- at = skipWhitespace(at);
- const identifier = identifierAt(at);
- const fieldName = identifier?.text ?? '';
- if (!identifier) {
- fail(markerOffset, '@Snapshotable only supports a single identifier binding');
- continue;
- }
- at = skipWhitespace(identifier.end);
-
- if (bindingKind === 'let') {
- fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
- continue;
- }
- if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
- fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
- continue;
- }
- if (modifiers.some(m => m === 'static' || m === 'class')) {
- fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
- continue;
- }
- if (body[at] !== ':') {
- fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
- continue;
- }
-
- at = skipWhitespace(at + 1);
- const typeStart = at;
- let parens = 0;
- let brackets = 0;
- let angles = 0;
- let typeEnd = at;
- let delimiter = '';
- for (; at < body.length; at++) {
- const c = body[at]!;
- if (c === '(') parens++;
- else if (c === ')') parens--;
- else if (c === '[') brackets++;
- else if (c === ']') brackets--;
- else if (c === '<') angles++;
- else if (c === '>') angles = Math.max(0, angles - 1);
- const topLevel = parens === 0 && brackets === 0 && angles === 0;
- if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
- delimiter = c;
- break;
- }
- if (topLevel && (c === '\n' || c === '\r')) {
- const soFar = body.slice(typeStart, at).trim();
- if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
- }
- typeEnd = at + 1;
- }
- const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
- if (typeText.length === 0) {
- fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
- continue;
- }
- if (delimiter === '{') {
- fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
- continue;
- }
- if (delimiter === ',') {
- fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
- continue;
- }
- if (delimiter === '=') {
- // A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
- // before its binding comma. Scan the initializer at syntax depth zero
- // so commas in arrays/calls/closures remain valid.
- let initializerAt = at + 1;
- let expressionParens = 0;
- let expressionBrackets = 0;
- let expressionBraces = 0;
- let hasSecondBinding = false;
- for (; initializerAt < body.length; initializerAt++) {
- const c = body[initializerAt]!;
- if (c === '(') expressionParens++;
- else if (c === ')') expressionParens--;
- else if (c === '[') expressionBrackets++;
- else if (c === ']') expressionBrackets--;
- else if (c === '{') expressionBraces++;
- else if (c === '}') {
- if (expressionBraces === 0) break;
- expressionBraces--;
- }
- const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
- if (topLevel && c === ',') {
- hasSecondBinding = true;
- break;
- }
- if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
- }
- if (hasSecondBinding) {
- fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
- continue;
- }
- }
- const wrappedOptional = optionalWrappedType(typeText);
- if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
- fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
- continue;
- }
- const typeIssue = snapshotTypeIssue(typeText);
- if (typeIssue !== undefined) {
- fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
- continue;
- }
-
- fields.push({ name: fieldName, typeText });
+ let m: RegExpExecArray | null;
+ while ((m = fieldPattern.exec(normalized)) !== null) {
+ // Codex catch: skip fields that have a computed body (`{ get ... }` or
+ // `{ didSet ... }` after the type). The match boundary stops before `{`,
+ // so we peek at what comes after the type in the original body.
+ const afterMatchIdx = m.index + m[0].length;
+ const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
+ // If the next non-space character is `{`, this is a computed property.
+ // We're conservative: snapshot-eligible fields must be stored properties
+ // (initialized with `=` or just declared).
+ if (afterMatch.startsWith('{')) continue;
+ fields.push({ name: m[1]!, typeText: m[2]!.trim() });
}
- return { fields, diagnostics };
+ return fields;
}
export function computeCacheKey(inputs: {
@@ -471,263 +154,35 @@ export function computeCacheKey(inputs: {
swiftVersion: string;
toolGitRev: string;
platformTriple: string;
- buildId?: string;
}): string {
const h = createHash('sha256');
- h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
+ h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
for (const f of inputs.swiftFiles) {
const content = readFileSync(f);
- // Cache identity is content-based. Absolute checkout paths must not make
- // equivalent source trees produce different generated output.
- h.update(`${content.length}:`);
+ h.update(`${f}:${content.length}:`);
h.update(content);
h.update('|');
}
return h.digest('hex');
}
-/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
- * source paths, app build provenance, and unmarked source. Field/class order is
- * source order because restore payload compatibility is an ordered contract. */
-export function computeAccessorHash(specs: AccessorSpec[]): string {
- let signature = 'snapshot-schema-v1\n';
- for (const spec of specs) {
- signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
- for (const field of spec.fields) {
- signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
- signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
- }
- signature += 'E\n';
- }
- return createHash('sha256').update(signature).digest('hex');
-}
-
-/** Return the wrapped type for one top-level Optional spelling. */
-export function optionalWrappedType(typeText: string): string | undefined {
- const type = typeText.trim();
- if (type.endsWith('?')) {
- const wrapped = type.slice(0, -1).trim();
- return wrapped.length > 0 ? wrapped : undefined;
- }
-
- const optional = /^(?:Swift\.)?Optional\s*') {
- depth--;
- if (depth === 0) {
- if (type.slice(i + 1).trim().length > 0) return undefined;
- const wrapped = type.slice(open + 1, i).trim();
- return wrapped.length > 0 ? wrapped : undefined;
- }
- }
- }
- return undefined;
-}
-
-function splitTopLevel(type: string, delimiter: string): string[] {
- const parts: string[] = [];
- let start = 0;
- let angle = 0;
- let square = 0;
- let paren = 0;
- for (let i = 0; i < type.length; i++) {
- const char = type[i]!;
- if (char === '<') angle++;
- else if (char === '>') angle--;
- else if (char === '[') square++;
- else if (char === ']') square--;
- else if (char === '(') paren++;
- else if (char === ')') paren--;
- else if (char === delimiter && angle === 0 && square === 0 && paren === 0) {
- parts.push(type.slice(start, i));
- start = i + 1;
- }
- }
- parts.push(type.slice(start));
- return parts;
-}
-
-function genericArgument(type: string, name: string): string | undefined {
- const prefix = `${name}<`;
- if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined;
- let depth = 0;
- for (let i = name.length; i < type.length; i++) {
- if (type[i] === '<') depth++;
- else if (type[i] === '>') {
- depth--;
- if (depth === 0 && i !== type.length - 1) return undefined;
- if (depth < 0) return undefined;
- }
- }
- return depth === 0 ? type.slice(prefix.length, -1) : undefined;
-}
-
-/** Return a user-facing suffix when a Swift type cannot round-trip through
- * Foundation JSON without custom encoding. The accepted grammar is deliberately
- * narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional
- * compositions of those types. */
-export function snapshotTypeIssue(typeText: string): string | undefined {
- const type = typeText.replace(/\s+/g, '');
- if (type.length === 0) return 'requires a JSON-compatible type';
- if (type.endsWith('!')) {
- return 'cannot use an implicitly unwrapped Optional; use T? instead';
- }
-
- if (type.endsWith('?')) {
- const wrapped = type.slice(0, -1);
- if (wrapped.endsWith('?')
- || genericArgument(wrapped, 'Optional') !== undefined
- || genericArgument(wrapped, 'Swift.Optional') !== undefined) {
- return 'cannot use a nested Optional type';
- }
- return snapshotTypeIssue(wrapped);
- }
- const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional');
- if (optional !== undefined) {
- if (optional.endsWith('?')
- || genericArgument(optional, 'Optional') !== undefined
- || genericArgument(optional, 'Swift.Optional') !== undefined) {
- return 'cannot use a nested Optional type';
- }
- return snapshotTypeIssue(optional);
- }
-
- const scalar = type === 'CoreGraphics.CGFloat'
- ? 'CGFloat'
- : (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type);
- if (JSON_SCALAR_TYPES.has(scalar)) return undefined;
-
- if (type.startsWith('[') && type.endsWith(']')) {
- const inner = type.slice(1, -1);
- const dictionaryParts = splitTopLevel(inner, ':');
- if (dictionaryParts.length === 1) return snapshotTypeIssue(inner);
- if (dictionaryParts.length === 2) {
- const key = dictionaryParts[0]!.replace(/^Swift\./, '');
- if (key !== 'String') return 'must use String keys for snapshot dictionaries';
- return snapshotTypeIssue(dictionaryParts[1]!);
- }
- return 'requires a JSON-compatible array or dictionary type';
- }
-
- const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array');
- if (array !== undefined) return snapshotTypeIssue(array);
- const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary');
- if (dictionary !== undefined) {
- const parts = splitTopLevel(dictionary, ',');
- if (parts.length !== 2) return 'requires a JSON-compatible dictionary type';
- const key = parts[0]!.replace(/^Swift\./, '');
- if (key !== 'String') return 'must use String keys for snapshot dictionaries';
- return snapshotTypeIssue(parts[1]!);
- }
-
- return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`;
-}
-
-export function validateAccessorSpecs(specs: AccessorSpec[]): void {
- const owners = new Map();
- const diagnostics: string[] = [];
- for (const spec of specs) {
- for (const field of spec.fields) {
- const previous = owners.get(field.name);
- if (previous !== undefined) {
- diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
- } else {
- owners.set(field.name, spec.className);
- }
- }
- }
- if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
-}
-
-function swiftStringLiteral(value: string): string {
- return JSON.stringify(value);
-}
-
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
- validateAccessorSpecs(specs);
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
- out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
+ out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
for (const spec of specs) {
- // Accessors compile in the app target beside its usually-internal state
- // types. Making this API public would make valid internal models fail
- // Swift type checking (a public signature cannot expose an internal type).
- out += `@MainActor\nenum ${spec.className}Accessor {\n`;
- out += ` private static func decodeSnapshotValue(_ value: Any, as type: T.Type) -> T? {\n`;
- out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
- out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
- out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
- out += ` return decoded["value"]\n`;
- out += ` }\n\n`;
- out += ` static func register(_ state: ${spec.className}) {\n`;
+ out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
+ out += ` public static func register(_ state: ${spec.className}) {\n`;
out += ` StateServer.shared.register(\n`;
- out += ` buildId: {\n`;
- out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
- out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
- out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
- out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
- out += ` }(),\n`;
- out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
- out += ` atomicRestore: { keys, apply in\n`;
- out += ` // Validate every key and value before assignment.\n`;
- out += ` // Successful assignments are sequential on MainActor.\n`;
- spec.fields.forEach((field, index) => {
- out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
- out += ` return .missingKey("${field.name}")\n`;
- out += ` }\n`;
- const wrapped = optionalWrappedType(field.typeText);
- if (wrapped !== undefined) {
- out += ` let restored${index}: ${field.typeText}\n`;
- out += ` if raw${index} is NSNull {\n`;
- out += ` restored${index} = nil\n`;
- out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
- out += ` restored${index} = typed\n`;
- out += ` } else {\n`;
- out += ` return .typeMismatch("${field.name}")\n`;
- out += ` }\n`;
- } else {
- out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
- out += ` return .typeMismatch("${field.name}")\n`;
- out += ` }\n`;
- }
- });
- out += ` if apply {\n`;
- spec.fields.forEach((field, index) => {
- out += ` state.${field.name} = restored${index}\n`;
- });
- out += ` }\n`;
- out += ` return .ok\n`;
- out += ` }\n`;
+ out += ` buildId: "${buildId}",\n`;
+ out += ` accessorHash: "${accessorHash}",\n`;
+ out += ` atomicRestore: { _ in .ok }\n`;
out += ` )\n`;
for (const field of spec.fields) {
- const wrapped = optionalWrappedType(field.typeText);
out += ` StateServer.shared.registerAccessor(\n`;
out += ` key: "${field.name}",\n`;
out += ` type: "${field.typeText}",\n`;
- if (wrapped !== undefined) {
- out += ` read: {\n`;
- out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
- out += ` return value as Any\n`;
- out += ` },\n`;
- } else {
- out += ` read: { state.${field.name} as Any? },\n`;
- }
- out += ` write: { value in\n`;
- if (wrapped !== undefined) {
- out += ` if value is NSNull {\n`;
- out += ` state.${field.name} = nil\n`;
- out += ` return true\n`;
- out += ` }\n`;
- out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
- } else {
- out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
- }
- out += ` state.${field.name} = typed\n`;
- out += ` return true\n`;
- out += ` }\n`;
+ out += ` read: { state.${field.name} as Any? },\n`;
+ out += ` write: { _ in false }\n`;
out += ` )\n`;
}
out += ` }\n}\n\n`;
@@ -760,15 +215,6 @@ function detectToolGitRev(): string {
}
}
-function detectBuildId(): string {
- if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
- const marketing = process.env.MARKETING_VERSION;
- const build = process.env.CURRENT_PROJECT_VERSION;
- if (marketing && build) return `${marketing} (${build})`;
- if (build) return build;
- return 'unknown';
-}
-
export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
}
@@ -777,25 +223,13 @@ export function generate(inputs: GenInputs): GenResult {
const inputDir = resolve(inputs.inputDir);
const outputDir = resolve(inputs.outputDir ?? inputDir);
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
- const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
- const buildId = inputs.buildId ?? detectBuildId();
-
- // Parse before cache lookup. This keeps diagnostics deterministic even when
- // an older cache entry exists, and gives us the schema-only accessor hash.
- const allSpecs: AccessorSpec[] = [];
- for (const f of swiftFiles) {
- const src = readFileSync(f, 'utf-8');
- allSpecs.push(...parseSwift(src));
- }
- validateAccessorSpecs(allSpecs);
- const accessorHash = computeAccessorHash(allSpecs);
+ const swiftFiles = collectSwiftFiles(inputDir);
const cacheKey = computeCacheKey({
swiftFiles,
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
- buildId,
});
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
@@ -808,13 +242,18 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
- accessorHash,
- specs: allSpecs,
+ specs: [], // intentionally empty on cache hit (no need to re-parse)
cacheHit: true,
};
}
- const rendered = render(allSpecs, buildId, accessorHash);
+ // Parse + render fresh
+ const allSpecs: AccessorSpec[] = [];
+ for (const f of swiftFiles) {
+ const src = readFileSync(f, 'utf-8');
+ allSpecs.push(...parseSwift(src));
+ }
+ const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
writeFileSync(finalOutput, rendered);
// Populate cache (best-effort — cache failures don't break codegen).
@@ -828,7 +267,6 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
- accessorHash,
specs: allSpecs,
cacheHit: false,
};
@@ -862,18 +300,10 @@ if (import.meta.main) {
const inputDir = args[inputIdx + 1]!;
const outputIdx = args.indexOf('--output');
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
- try {
- const result = generate({ inputDir, outputDir });
- process.stdout.write(
- result.cacheHit
- ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
- : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
- );
- } catch (error) {
- if (error instanceof AccessorGenerationError) {
- process.stderr.write(`${error.message}\n`);
- process.exit(4);
- }
- throw error;
- }
+ const result = generate({ inputDir, outputDir });
+ process.stdout.write(
+ result.cacheHit
+ ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
+ : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
+ );
}
diff --git a/ios-qa/templates/Bridges.swift.template b/ios-qa/templates/Bridges.swift.template
index 4a91142de..bf7af6e3f 100644
--- a/ios-qa/templates/Bridges.swift.template
+++ b/ios-qa/templates/Bridges.swift.template
@@ -27,35 +27,12 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
- // KIF turns on accessibility automation before walking SwiftUI's AX
- // tree. Without it SwiftUI exposes only the hosting shell and taps
- // can report success without invoking Button.action.
- DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
-/// Return the children UIKit exposes specifically to accessibility automation.
-/// iOS 17 added `automationElements`; unlike the older container APIs it
-/// preserves identified SwiftUI descendants inside accessibility groups.
-@MainActor
-private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
- if #available(iOS 17.0, *),
- let automation = element.automationElements,
- !automation.isEmpty {
- return automation.compactMap { $0 as? NSObject }
- }
- if let accessibility = element.accessibilityElements,
- !accessibility.isEmpty {
- return accessibility.compactMap { $0 as? NSObject }
- }
- let count = element.accessibilityElementCount()
- guard count > 0, count < 512 else { return [] }
- return (0.. Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
- let format = UIGraphicsImageRendererFormat.default()
- // /tap consumes UIKit window points. Render at 1x so screenshot pixels
- // use that same coordinate space on 2x/3x devices.
- format.scale = 1
- let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
+ let renderer = UIGraphicsImageRenderer(bounds: bounds)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -88,10 +61,7 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- let windows = scene.windows.filter { window in
- !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
- }
- return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
+ scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
@@ -106,40 +76,21 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
- var visited = Set()
- var remaining = 2_048
- collect(
- view: window,
- parentPath: "",
- window: window,
- visited: &visited,
- remaining: &remaining,
- into: &elements
- )
+ collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
return elements
}
- private static func collect(
- view: UIView,
- parentPath: String,
- window: UIWindow,
- visited: inout Set,
- remaining: inout Int,
- into elements: inout [JSONDict]
- ) {
- guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
- remaining -= 1
-
+ private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
- let frameInWindow = view.convert(view.bounds, to: window)
- if !window.bounds.intersects(frameInWindow) { return }
+ let frameInWindow = view.convert(view.bounds, to: nil)
+ if !windowBounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
- let traits = NSNumber(value: view.accessibilityTraits.rawValue)
+ let traits = Int(view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -169,98 +120,66 @@ enum ElementsBridgeImpl {
])
}
- // Walk automation children before raw subviews. On iOS 17+ this
- // exposes identified SwiftUI controls nested inside GroupBox/List.
- for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
- if let child = element as? UIView {
- collect(
- view: child,
- parentPath: path,
- window: window,
- visited: &visited,
- remaining: &remaining,
- into: &elements
- )
- } else {
- appendSynthetic(
- element,
- path: "\(path) > ",
- window: window,
- visited: &visited,
- remaining: &remaining,
- into: &elements
- )
+ // Recurse into accessibility-elements first (some custom views vend
+ // synthetic children), then UIView subviews. SwiftUI's host views
+ // populate accessibilityElements lazily — many return nil before
+ // VoiceOver triggers them. Force population by reading accessibilityElementCount.
+ _ = view.accessibilityElementCount()
+ if let axElements = view.accessibilityElements {
+ for case let element as NSObject in axElements {
+ if let v = element as? UIView {
+ collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
+ } else {
+ // Synthetic accessibility element (no UIView). Capture frame in screen coords.
+ let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
+ elements.append([
+ "path": "\(path) > ",
+ "class": "AccessibilityElement",
+ "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
+ "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
+ "value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
+ "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
+ "frame": [
+ "x": Int(af.origin.x),
+ "y": Int(af.origin.y),
+ "w": Int(af.size.width),
+ "h": Int(af.size.height),
+ ],
+ "is_user_interaction_enabled": true,
+ ])
+ }
+ }
+ } else {
+ // accessibilityElements is nil — iterate by index. SwiftUI uses
+ // this dynamic protocol pattern; many AX elements only respond
+ // to accessibilityElementCount + accessibilityElement(at:).
+ let count = view.accessibilityElementCount()
+ for i in 0.. ",
+ "class": String(describing: type(of: element)),
+ "label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
+ "identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
+ "value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
+ "traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
+ "frame": [
+ "x": Int(af.origin.x),
+ "y": Int(af.origin.y),
+ "w": Int(af.size.width),
+ "h": Int(af.size.height),
+ ],
+ "is_user_interaction_enabled": true,
+ ])
+ }
}
}
for sub in view.subviews {
- collect(
- view: sub,
- parentPath: path,
- window: window,
- visited: &visited,
- remaining: &remaining,
- into: &elements
- )
- }
- }
-
- private static func appendSynthetic(
- _ element: NSObject,
- path: String,
- window: UIWindow,
- visited: inout Set,
- remaining: inout Int,
- into elements: inout [JSONDict]
- ) {
- guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
- remaining -= 1
-
- let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
- let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
- let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
- let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
- let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
- let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
- if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
- elements.append([
- "path": path,
- "class": String(describing: type(of: element)),
- "label": label,
- "identifier": identifier,
- "value": value,
- "traits": NSNumber(value: traits),
- "frame": [
- "x": Int(frame.origin.x),
- "y": Int(frame.origin.y),
- "w": Int(frame.size.width),
- "h": Int(frame.size.height),
- ],
- "is_user_interaction_enabled": true,
- ])
- }
-
- // Synthetic SwiftUI nodes are themselves accessibility containers.
- // Recurse even when this grouping node has no metadata of its own.
- for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
- if let childView = child as? UIView {
- collect(
- view: childView,
- parentPath: path,
- window: window,
- visited: &visited,
- remaining: &remaining,
- into: &elements
- )
- } else {
- appendSynthetic(
- child,
- path: "\(path) > ",
- window: window,
- visited: &visited,
- remaining: &remaining,
- into: &elements
- )
- }
+ collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
}
}
@@ -272,10 +191,7 @@ enum ElementsBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- let windows = scene.windows.filter { window in
- !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
- }
- return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
+ scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
@@ -294,62 +210,21 @@ enum MutationBridgeImpl {
}
}
- /// Tap at (x, y) in window coordinates. Prefer accessibility activation,
- /// which is stable for SwiftUI buttons across OS releases, then fall back
- /// to KIF-derived UITouch synthesis for gesture-only/custom controls.
+ /// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
+ /// (KIF-derived in-process touch synthesis). The Obj-C target builds a
+ /// real UITouch + IOHIDEvent + UIEvent and dispatches via
+ /// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
+ /// This works for UIControl, SwiftUI Button (via iOS 18+
+ /// `_UIHitTestContext`), gesture recognizers, and anything else that
+ /// listens to the real event-dispatch path.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
- if let element = findActivatableAXElement(at: point, in: window),
- element.accessibilityActivate() {
- return true
- }
return DebugBridgeTouch.sendTap(at: point, in: window)
}
- private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
- let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
- var best: NSObject?
- var bestArea: CGFloat = .infinity
- var visited = Set()
- var remaining = 2_048
-
- func consider(frame: CGRect, traits: UInt64, element: NSObject) {
- guard frame.contains(screenPoint),
- (traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
- let area = frame.width * frame.height
- if area > 0 && area < bestArea {
- best = element
- bestArea = area
- }
- }
-
- func visit(_ element: NSObject) {
- guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
- remaining -= 1
-
- if let view = element as? UIView {
- guard !view.isHidden, view.alpha >= 0.01,
- view.convert(view.bounds, to: window).contains(point) else { return }
- if view.isAccessibilityElement {
- consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
- }
- for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
- for child in view.subviews { visit(child) }
- } else {
- let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
- let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
- consider(frame: frame, traits: traits, element: element)
- for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
- }
- }
-
- visit(window)
- return best
- }
-
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -391,10 +266,7 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
- // Automation commands return synchronously; do not report
- // success while the target is still moving underneath the
- // next tap coordinate.
- scroll.setContentOffset(off, animated: false)
+ scroll.setContentOffset(off, animated: true)
return true
}
node = cur.superview
@@ -429,10 +301,7 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
- let windows = scene.windows.filter { window in
- !window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
- }
- return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
+ scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
}
}
diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template
index 8dfb96d88..e18e2fb05 100644
--- a/ios-qa/templates/DebugBridgeManager.swift.template
+++ b/ios-qa/templates/DebugBridgeManager.swift.template
@@ -14,16 +14,12 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
- /// Register app-owned generated accessors, then start the server. The
- /// registration closure is passed in from the consuming app because the
- /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
- /// call DebugBridgeUIWiring.installAll() before this method so a warm
- /// daemon cannot reach uninitialized resolvers during listener startup.
- public func start(appState: State, register: (State) -> Void) {
- register(appState)
+ public func start(appState: AppState) {
+ // 1. Register the canonical AppState struct + accessor wiring.
+ // AppStateAccessor.register(_:) is generated by gen-accessors-tool.
+ AppStateAccessor.register(appState)
- // Boot only after registration so the first snapshot has a real build
- // id, schema hash, and key set.
+ // 2. Boot the StateServer.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@@ -35,4 +31,19 @@ public final class DebugBridgeManager {
}
}
+// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
+// to the app's canonical state struct. Apps that haven't run codegen get a
+// stub that registers no accessors (snapshot is empty, restore returns
+// missing-key for every key).
+@MainActor
+public enum AppStateAccessor {
+ public static var register: (Any) -> Void = { _ in }
+}
+
+// Apps declare their canonical state struct; codegen reads it and emits
+// AppStateAccessor.register. The app's struct must be `@Observable` and
+// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
+@MainActor
+public protocol AppState: AnyObject {}
+
#endif // DEBUG
diff --git a/ios-qa/templates/DebugBridgeTouch.h.template b/ios-qa/templates/DebugBridgeTouch.h.template
index 7106791bf..e44034530 100644
--- a/ios-qa/templates/DebugBridgeTouch.h.template
+++ b/ios-qa/templates/DebugBridgeTouch.h.template
@@ -23,10 +23,6 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
-/// Enable the in-process accessibility automation tree used by SwiftUI and
-/// UIKit. Call once while installing the debug bridge, before /elements.
-+ (void)prepareForAutomation;
-
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
diff --git a/ios-qa/templates/DebugBridgeTouch.m.template b/ios-qa/templates/DebugBridgeTouch.m.template
index 2c664f08a..a8b413d85 100644
--- a/ios-qa/templates/DebugBridgeTouch.m.template
+++ b/ios-qa/templates/DebugBridgeTouch.m.template
@@ -131,36 +131,6 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
-// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
-// accessibility tree. Without this switch SwiftUI exposes only its hosting
-// shell: /elements has no controls and a synthesized tap can return YES while
-// Button.action never fires. Keep this DEBUG-only with the rest of this file.
-typedef int (*DBTAXSAutomationEnabledFn)(void);
-typedef void (*DBTAXSSetAutomationEnabledFn)(int);
-static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
-static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
-static int _DBTInitialAutomationState = -1;
-
-static void DBT_RestoreAccessibilityAutomation(void) {
- if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
- _DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
- }
-}
-
-static void DBT_EnableAccessibilityAutomation(void) {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
- if (!handle) return;
- _DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
- _DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
- if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
- _DBTInitialAutomationState = _DBTAXSAutomationEnabled();
- if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
- atexit(DBT_RestoreAccessibilityAutomation);
- });
-}
-
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -207,7 +177,6 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
-- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -265,10 +234,6 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
-+ (void)prepareForAutomation {
- DBT_EnableAccessibilityAutomation();
-}
-
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -287,17 +252,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
- } else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
- [touch _setIsTapToClick:YES];
- Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
- if (flagsIvar) {
- ptrdiff_t offset = ivar_getOffset(flagsIvar);
- char *flags = (__bridge void *)touch + offset;
- *flags |= (char)0x01;
- }
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
- if ([touch respondsToSelector:@selector(setGestureView:)]) {
+ if ([touch respondsToSelector:@selector(setGestureView:)] &&
+ [hit isKindOfClass:[UIView class]]) {
[touch setGestureView:(UIView *)hit];
}
diff --git a/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template
index bf7016d87..009a70861 100644
--- a/ios-qa/templates/DebugBridgeWiring.swift.template
+++ b/ios-qa/templates/DebugBridgeWiring.swift.template
@@ -6,35 +6,22 @@
// the DebugBridge target.
#if DEBUG
-import Foundation
-import DebugBridgeCore
-#if canImport(UIKit)
-import DebugBridgeUI
-#endif
+import DebugBridge
@MainActor
-func startGstackDebugBridge(
- appState: State,
- register: (State) -> Void
-) {
+func startGstackDebugBridge(appState: AppState) {
// Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
- // Install the UI resolvers before opening the listener. A warm daemon can
- // issue its first request as soon as StateServer starts; it must never see
- // the default empty screenshot/elements/mutation handlers.
- #if canImport(UIKit)
- DebugBridgeUIWiring.installAll()
- #endif
+ // Install accessibility + screenshot + mutation bridges before starting
+ // the server so the first authenticated request can use them.
+ ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
+ ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
+ MutationBridge.resolver = { op, payload in
+ MutationDispatcher.shared.run(op: op, payload: payload)
+ }
- // Generated typed accessors live in the app target, so pass their register
- // function into the package instead of asking DebugBridgeCore to import
- // app-owned types.
- DebugBridgeManager.shared.start(appState: appState, register: register)
-
- #if canImport(UIKit)
- DebugOverlayWindow.shared.install(recording: recording)
- #endif
+ DebugBridgeManager.shared.start(appState: appState, recording: recording)
}
#endif
@@ -46,10 +33,7 @@ func startGstackDebugBridge(
//
// init() {
// #if DEBUG
-// startGstackDebugBridge(
-// appState: appState,
-// register: MyAppStateAccessor.register
-// )
+// startGstackDebugBridge(appState: appState)
// #endif
// }
//
diff --git a/ios-qa/templates/StateAccessor.swift.template b/ios-qa/templates/StateAccessor.swift.template
index a9a87b6f8..07de99e19 100644
--- a/ios-qa/templates/StateAccessor.swift.template
+++ b/ios-qa/templates/StateAccessor.swift.template
@@ -3,12 +3,12 @@
//
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
// are filled per-class from swift-syntax AST inspection of the app's
-// @Observable types. Only properties preceded by `// @Snapshotable` are emitted.
+// @Observable types. Only properties marked with @Snapshotable are emitted.
//
// {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
-// {{ACCESSORS}} — generated register/read/write blocks per marked field
+// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
#if DEBUG
@@ -21,16 +21,13 @@ public enum {{CLASS_NAME}}Accessor {
StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}",
- atomicRestore: { keys, apply in
- // Validate every key + type before mutating any state. Invalid
- // input therefore cannot leave a partially restored model.
+ atomicRestore: { keys in
+ // Validate every key + type FIRST, then apply in one struct
+ // assignment so SwiftUI observers see exactly one change.
var snapshot = state.snapshotable
{{VALIDATION_BLOCK}}
- // StateServer validates every model first, then invokes the
- // same handlers with apply=true for a cross-model commit.
- if apply {
- state.snapshotable = snapshot
- }
+ // Apply atomically.
+ state.snapshotable = snapshot
return .ok
}
)
diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template
index b8441df16..803bedf31 100644
--- a/ios-qa/templates/StateServer.swift.template
+++ b/ios-qa/templates/StateServer.swift.template
@@ -59,20 +59,18 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
- // Validated-restore hooks. Every generated model registers one two-phase
- // handler. The server validates all models before it lets any model mutate,
- // so invalid input can never cause a cross-model partial restore.
- // Valid restores apply properties on MainActor; observers may receive one
- // change notification per property because arbitrary @Observable models do
- // not expose a general single-assignment transaction API.
- public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
+ // Atomic-restore hook. Codegen wires this to the canonical AppState struct.
+ // Restore replaces the entire struct in one assignment so SwiftUI's Combine
+ // pipeline observes exactly one change notification — true observable
+ // atomicity. @MainActor alone doesn't guarantee that.
+ public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
- private var atomicRestores: [AtomicRestoreFn] = []
+ private var atomicRestore: AtomicRestoreFn?
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@@ -111,7 +109,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
- self.atomicRestores.append(atomicRestore)
+ self.atomicRestore = atomicRestore
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -286,7 +284,6 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
- "bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -479,36 +476,22 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
- guard !atomicRestores.isEmpty else {
+ guard let restore = atomicRestore else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
- // Phase one validates every registered model without assignment.
- for restore in atomicRestores {
- switch restore(keys, false) {
- case .ok:
- continue
- case .missingKey(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
- return
- case .typeMismatch(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
- return
- case .schemaMismatch(let expected, let got):
- send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
- return
- }
+ // Validate-then-apply via the codegen-supplied closure. The closure does
+ // a single struct-assignment so SwiftUI sees one change notification.
+ switch restore(keys) {
+ case .ok:
+ send(connection: connection, status: 200, body: ["ok": true])
+ case .missingKey(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
+ case .typeMismatch(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
+ case .schemaMismatch(let expected, let got):
+ send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
}
-
- // Phase two applies only after every model accepted the immutable input.
- // A valid multi-field restore may notify once per property.
- for restore in atomicRestores {
- guard case .ok = restore(keys, true) else {
- send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
- return
- }
- }
- send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -539,19 +522,9 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
- let responseStatus: Int
- let json: Data
- if JSONSerialization.isValidJSONObject(body),
- let encoded = try? JSONSerialization.data(withJSONObject: body) {
- responseStatus = status
- json = encoded
- } else {
- logger.error("Refusing to send a non-JSON response body")
- responseStatus = 500
- json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
- }
+ let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
let statusText: String
- switch responseStatus {
+ switch status {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -564,7 +537,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
- let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
+ let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md
index 95e0bca0a..2f689c4d6 100644
--- a/ios-sync/SKILL.md
+++ b/ios-sync/SKILL.md
@@ -806,53 +806,49 @@ 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` generator marker comment to a different field.
+3. Move the `@Snapshotable` marker to a different field.
This skill regenerates the relevant artifacts in place.
-**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.
+**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.
## Phase 1: Detect installed version
1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version".
-2. Read upstream version from `$GSTACK_ROOT/VERSION`.
+2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
+ value baked into the installed gstack binary).
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
-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:
+Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
```bash
-~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
- --app-source "$APP_SOURCE_DIR" \
- --bridge-dir "$APP_SOURCE_DIR/DebugBridge"
+swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
+ gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
```
-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: Review the generated diff
+## Phase 3: Update templated Swift files in place
-1. Review changes under `/DebugBridge/` and
- `/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.
+For each file that comes from `ios-qa/templates/*.swift.template`:
+
+1. Read the current installed file at
+ `/DebugBridgeGenerated/.swift`.
+2. Read the upstream template at
+ `$GSTACK_HOME/ios-qa/templates/.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).
## Phase 4: Verify
@@ -866,6 +862,5 @@ and platform triple all match the cache, this is a ~50ms no-op.
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
-| 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`. |
+| 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` |
diff --git a/ios-sync/SKILL.md.tmpl b/ios-sync/SKILL.md.tmpl
index add54c3d8..156a33c4c 100644
--- a/ios-sync/SKILL.md.tmpl
+++ b/ios-sync/SKILL.md.tmpl
@@ -35,53 +35,49 @@ 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` generator marker comment to a different field.
+3. Move the `@Snapshotable` marker to a different field.
This skill regenerates the relevant artifacts in place.
-**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.
+**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.
## Phase 1: Detect installed version
1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa
during install). If missing, treat the install as "unknown old version".
-2. Read upstream version from `$GSTACK_ROOT/VERSION`.
+2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the
+ value baked into the installed gstack binary).
3. If versions match AND no new `@Observable` classes were added, exit
early with "already up to date".
## Phase 2: Regenerate codegen output
-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:
+Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly):
```bash
-~/.claude/skills/gstack/bin/gstack-ios-qa-regen \
- --app-source "$APP_SOURCE_DIR" \
- --bridge-dir "$APP_SOURCE_DIR/DebugBridge"
+swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \
+ gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated"
```
-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: Review the generated diff
+## Phase 3: Update templated Swift files in place
-1. Review changes under `/DebugBridge/` and
- `/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.
+For each file that comes from `ios-qa/templates/*.swift.template`:
+
+1. Read the current installed file at
+ `/DebugBridgeGenerated/.swift`.
+2. Read the upstream template at
+ `$GSTACK_HOME/ios-qa/templates/.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).
## Phase 4: Verify
@@ -95,6 +91,5 @@ and platform triple all match the cache, this is a ~50ms no-op.
| Symptom | Action |
|---|---|
| Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error |
-| 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`. |
+| 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` |
diff --git a/test/skill-e2e-ios-swift-build.test.ts b/test/skill-e2e-ios-swift-build.test.ts
index d6e4bf0e1..4347e2c05 100644
--- a/test/skill-e2e-ios-swift-build.test.ts
+++ b/test/skill-e2e-ios-swift-build.test.ts
@@ -26,8 +26,31 @@ import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates');
+const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift');
+// Parity: canonical Obj-C touch templates must match the fixture's working
+// copy. The fixture is the only place the .m / .h are exercised end-to-end
+// on a real device, so any divergence means consuming apps would ship a
+// stale, untested version of the SwiftUI hit-test fix.
describe('template ↔ fixture parity', () => {
+ test('DebugBridgeTouch.h.template matches fixture include', () => {
+ const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8');
+ const fixture = readFileSync(
+ join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
+ 'utf-8',
+ );
+ expect(tmpl).toBe(fixture);
+ });
+
+ test('DebugBridgeTouch.m.template matches fixture .m', () => {
+ const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
+ const fixture = readFileSync(
+ join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
+ 'utf-8',
+ );
+ expect(tmpl).toBe(fixture);
+ });
+
test('Package.swift.template declares all 3 DebugBridge targets', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
// Each target must be present as a library product AND a target definition.
@@ -39,6 +62,19 @@ describe('template ↔ fixture parity', () => {
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
});
+ test('generated Swift packages only reference shipped test directories', () => {
+ const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
+ const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
+
+ expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
+ expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
+ });
+
+ test('Package.swift.template keeps swift-tools-version on the first line', () => {
+ const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
+ expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
+ });
+
test('KIF-derived touch source preserves Apache-2.0 attribution', () => {
const implementation = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
expect(implementation).toContain('Copyright 2011-2016 Square, Inc. and KIF contributors.');
@@ -146,18 +182,9 @@ describeIfSwift('swift build invariants', () => {
const fixtureDir = join(workDir, 'FixtureApp');
try {
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
- writeFileSync(
- join(fixtureDir, 'Package.swift'),
- readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'),
- );
- writeFileSync(
- join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'),
- readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'),
- );
- writeFileSync(
- join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'),
- readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'),
- );
+ writeFileSync(join(fixtureDir, 'Package.swift'), readFileSync(join(TEMPLATES_PATH, 'Package.swift.template')));
+ writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template')));
+ writeFileSync(join(fixtureDir, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template')));
expect(spawnSync('xcodegen', [
'generate', '--spec', join(fixtureDir, 'project.yml'),
'--project', fixtureDir, '--project-root', fixtureDir,
diff --git a/test/skill-e2e-ios.test.ts b/test/skill-e2e-ios.test.ts
index 56211637a..8d8f09c56 100644
--- a/test/skill-e2e-ios.test.ts
+++ b/test/skill-e2e-ios.test.ts
@@ -171,12 +171,9 @@ describe('ios-qa E2E (no-device path)', () => {
writeFileSync(join(srcDir, 'AppState.swift'), `
@Observable
class AppState {
- // @Snapshotable
- var isLoggedIn: Bool = false
- // @Snapshotable
- var username: String = ""
- // @Snapshotable
- var counter: Int = 0
+ @Snapshotable var isLoggedIn: Bool = false
+ @Snapshotable var username: String = ""
+ @Snapshotable var counter: Int = 0
var ephemeralCache: [String: Any] = [:]
}
`);
@@ -192,8 +189,7 @@ class AppState {
expect(result.specs).toHaveLength(1);
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
const generatedSwift = readFileSync(result.outputPath, 'utf-8');
- expect(generatedSwift).toContain('enum AppStateAccessor');
- expect(generatedSwift).not.toContain('public enum AppStateAccessor');
+ expect(generatedSwift).toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('key: "isLoggedIn"');
expect(generatedSwift).toContain('key: "counter"');
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable