如何用 // @Snapshotable 标记配置 gstack iOS QA 的状态快照:只暴露 Agent 需要的字段并让 token 留在快照外
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
如果你已经把 gstack 的 iOS QA 能力装进了一个 Swift 应用(DebugBridge 已安装、真机通过 USB 连接),下一步要解决的是:Agent 通过GET /state/snapshot读取应用状态时,到底哪些字段会暴露出去。gstack 的答案是按字段显式标记——只有属性正上方带有独立// @Snapshotable注释的字段才会进入快照,未标记的字段(典型如 auth token)默认不会出现在快照 JSON 里,也不会进入/ios-fix记录的回放 fixture。
本文的操作路径:在@Observable状态类上标记字段 → 重新生成 accessor → Debug 构建部署到真机 → 启动 daemon 后请求/state/snapshot核对快照内容。适用于已按 docs/howto-ios-testing-with-gstack.md 完成安装的环境:macOS + Xcode 16.0+(xcrun devicectl --version可执行)、iOS 16+ 真机且已开启 Developer Mode、gstack 已安装(gstack-ios-qa-regen与gstack-ios-qa-daemon在 PATH 上)、Bun 在 PATH 上。
标记字段:独立注释而不是属性包装器
标记方式是写在属性正上方的独立注释行// @Snapshotable。它刻意设计成注释而非 property wrapper,因此能和 Observation 的@Observable宏共存,不影响现有代码编译。@Observable类里没写这个注释的属性永远不会出现在快照里——这正是让 token 留在快照外的机制:
@Observable final class AppState { // @Snapshotable var username: String = "" var authToken: String = "" // never exported }上面是 docs/howto-ios-testing-with-gstack.md 给出的官方示例:username被标记、进入快照;authToken未标记、不导出。注意第二行行尾的// never exported只是普通行尾注释,不会把authToken标记进快照——生成器的词法扫描只认“单独占一行的// @Snapshotable”,字符串、块注释、行尾注释和说明性文字里的@Snapshotable都不生效(实现见 ios-qa/scripts/gen-accessors.ts 的maskSwiftSource)。
仓库里的测试 fixture 展示了一个更完整的真实状态类,标记了Bool、String、Int、String?四类字段,同时保留一个未标记的字典字段并注释说明它“不应通过 /state/snapshot 泄漏”,可参考 test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift:
@Observable final class FixtureAppState { // @Snapshotable var isLoggedIn: Bool = false // @Snapshotable var username: String = "" // @Snapshotable var tapCounter: Int = 0 // @Snapshotable var nickname: String? = nil /// Not snapshotted — ephemeral cache that should never leak via /state/snapshot. var ephemeralCache: [String: String] = [:] init() {} }哪些字段允许标记:生成器的约束
不是任何属性都能标记。生成器按固定契约校验,违反任一条会停止生成并报出源码诊断(见下一节的诊断信息),而不会产出“编译后或在快照时才报错”的 Swift 代码:
- 必须属于文件级
@Observable类;嵌套在其他类型内部的 observable 类不受支持,需要把类型移到文件作用域。 - 必须是可写的实例
var:let、static/class属性、计算属性都不行。 - 必须带显式类型标注(如
var count: Int = 0,不能靠初始值推断)。 - setter 必须是 internal 或 public;
private、fileprivate、private(set)、fileprivate(set)会被拒绝。 - 类型必须是 JSON 原生标量(
String、Bool、有符号/无符号整型宽度、Float、Double、CGFloat)、数组、String 键字典,以及这些类型的 Optional 组合。不支持自定义类型、隐式解包 Optional(T!,需用T?)、嵌套 Optional、非 String 键的字典。 - 快照 key(即字段名)必须在所有 observable 类之间唯一。
ObservableObject、@StateObject等其他观察模型不会产生 accessor,生成器目前只支持文件级@Observable类(见 ios-qa/SKILL.md Phase 1)。
重新生成 accessor 并查看诊断
标记(或移动标记)之后,跑一次确定性的生成命令:
gstack-ios-qa-regen \ --app-source "$PWD/Sources/YourApp" \ --bridge-dir "$PWD/DebugBridge"--app-source传你的应用源码目录(上面是文档示例路径,按实际仓库调整),--bridge-dir是本地的DebugBridgeSwift 包目录。命令会把规范模板复制到本地包、生成DebugBridgeGenerated/StateAccessor.swift、并写入DebugBridgeGenerated/.gstack-version。源码不变时是字节稳定的缓存命中;它同时会移除旧版扁平布局里的遗留生成文件,避免过期的 bridge 源文件遮蔽新包。如果gstack-ios-qa-regen不在 PATH,ios-sync/SKILL.md 中使用的是完整路径~/.claude/skills/gstack/bin/gstack-ios-qa-regen。
每个被标记字段会在生成的 accessor 里以字段名作为 key 注册 read/write(生成逻辑见 ios-qa/templates/StateAccessor.swift.template),所以快照 JSON 的 key 就是字段名。标记写法不合法时,生成会以非零退出并打印诊断,常见诊断及其含义(均为 ios-qa/scripts/gen-accessors.ts 中的原文):
| 诊断(节选) | 说明 |
|---|---|
@Snapshotable field 'x' must be declared var, not let | 字段是let,改成可写var |
@Snapshotable field 'x' cannot be private, fileprivate, private(set), or fileprivate(set) | setter 不可见,放宽为 internal/public |
@Snapshotable field 'x' must be an instance property | 用了static/class属性 |
@Snapshotable field 'x' requires an explicit type annotation | 缺少显式类型 |
@Snapshotable field 'x' cannot use an implicitly unwrapped Optional; use T? instead | 把T!改成T? |
@Snapshotable field 'x' cannot use a nested Optional type | 去掉双层 Optional |
must use String keys for snapshot dictionaries | 字典 key 必须是String |
uses unsupported snapshot type '...'; use JSON scalar, array, or String-keyed dictionary types | 类型不在 JSON 原生集合内 |
snapshot key 'x' is declared by both A and B; keys must be unique across @Observable types | 跨类重名,改名 |
处理方式按 ios-sync/SKILL.md 的失败模式表:要么把声明改到契约内,要么直接删掉该字段的// @Snapshotable标记(未标记即不导出)。
构建、部署到真机并启动 daemon
生成成功后,用 Debug 配置构建并安装(-configuration Debug是必须条件——DebugBridge*目标带.when(configuration: .debug)约束,Release 构建拒绝链接 bridge,这也是快照能力只存在于调试构建的原因):
xcodebuild \ -scheme YourAppScheme \ -configuration Debug \ -destination 'generic/platform=iOS' \ -derivedDataPath /tmp/build \ -allowProvisioningUpdates -allowProvisioningDeviceRegistration \ CODE_SIGN_STYLE=Automatic \ DEVELOPMENT_TEAM=YOUR_TEAM_ID \ buildYourAppScheme与YOUR_TEAM_ID替换为你自己的 scheme 名和 Apple 开发者 team ID(在 Xcode → Settings → Accounts → team 列表里查,是 team ID 不是证书 ID)。然后安装并启动:
UDID=$(xcrun devicectl list devices 2>/dev/null | awk 'NR>2 && $0!="" {print $(NF-2); exit}') xcrun devicectl device install app --device "$UDID" /tmp/build/Build/Products/Debug-iphoneos/YourApp.app xcrun devicectl device process launch --device "$UDID" --terminate-existing your.bundle.idYourApp.app与your.bundle.id换成实际应用名和 bundle id。手机锁屏会得到FBSOpenApplicationServiceErrorDomain error 1 — Locked,解锁重试;首次安装需要在手机上点 Trust,之后重跑。
确认应用源码里已按文档完成@main接线:DebugBridgeUIWiring.installAll()先于 StateServer 启动执行,随后DebugBridgeManager.shared.start(appState: appState, register: AppStateAccessor.register),并把生成器发现的实际类型替换掉示例中的appState/AppStateAccessor(完整代码见 docs/howto-ios-testing-with-gstack.md Step 1)。
最后启动 Mac 侧 daemon:
gstack-ios-qa-daemondaemon 在两个 loopback listener 都绑定后打印READY: port=<n> pid=<pid>,默认端口 9099。同一时刻只会有一个 daemon:它对~/.gstack/ios-qa-daemon.pid持有排他 flock,第二次启动会发现已有实例的端口并直接接入。daemon 启动后会调用POST /auth/rotate把启动 token 换成内存中的新值,约 5 秒后 os_log 里抓到的 boot token 就失效了。
验证:/state/snapshot 只含标记字段
HTTP 面在http://127.0.0.1:9099(或[::1]:9099)。先用无需鉴权的版本探针确认 daemon 活着:
curl -s http://127.0.0.1:9099/healthz然后请求快照端点:
curl -s http://127.0.0.1:9099/state/snapshot \ -H "Authorization: Bearer TOKEN"TOKEN是当前有效的 bearer token:读端点(/screenshot、/elements、GET /state/*)只需要 bearer,写类请求才额外需要X-Session-Id。本地 USB 模式下,跑/ios-qa技能流会自动完成隧道引导、token 轮换和端点调用;如果你手动驱动 HTTP,token 以 daemon 轮换后的当前值为准。
判断结果:
- 返回 JSON 的 key 与标记字段一一对应(fixture 场景下就是
isLoggedIn、username、tapCounter、nickname),authToken、ephemeralCache这类未标记字段不在返回里; - 按 ios-sync/SKILL.md Phase 4 的验证序列,
GET /state/snapshot会返回新的 accessor schema hash——如果你在 accessor 列表里新增过标记字段而 schema hash 没有变化,说明新字段没有独立// @Snapshotable注释,codegen 正确地排除了未标记状态; swift build与xcodebuild -scheme <SchemeName>成功、应用重启后 daemon 连接并完成 token 轮换,是同一次验证里的前置确认项。
后续维护与限制
- 以后新增
@Observable类/字段、或把// @Snapshotable移到别的字段,重跑gstack-ios-qa-regen(或/ios-sync)即可;生成器的复合缓存 key 会判断是否真的需要重新生成。 - 生成器默认排除
DebugBridgeGenerated目录和StateAccessor.swift自身;如果诊断显示扫描到了生成的 bridge 源码,把--app-source收窄到应用源码目录。 - 恢复语义与标记直接相关:
POST /state/restore采用两阶段提交——先对完整输入做全量校验,通过后才在 MainActor 上应用赋值,非法输入不会留下部分恢复的状态。所以标记字段的类型契约不只是读取约束,也是回放 fixture 时的校验边界。 - 发版前记得跑
/ios-clean:它移除DebugBridgeSPM 依赖并清理@main里的#if DEBUG接线。即使忘了清理,Release 构建也因.when(configuration: .debug)条件不会链接 bridge。
参考文档:docs/howto-ios-testing-with-gstack.md、ios-qa/SKILL.md、ios-sync/SKILL.md。
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考