gstack ios-qa:用视觉驱动 Agent 循环在真机上测试 iOS 应用的完整机制
【免费下载链接】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
本篇基于 ios-qa/SKILL.md 展开,讲清 gstack 的 ios-qa 技能如何在真实 iPhone 上执行 QA:通过 USB CoreDevice IPv6 隧道连接设备,读取 Swift 源码生成类型化状态访问器,部署仅 Debug 配置生效的 DebugBridge,再运行"截图 → 分析 → 决策 → 操作 → 验证"的闭环循环;读完后你将掌握这套免模拟器、免 XCTest、免 WebDriverAgent 的真机测试链路的架构、四个 Phase 的操作流程、Tailnet 远程控制的权限分层,以及各故障码的恢复手段。
要解决的问题
ios-qa 技能直接驱动一台通过 USB 连接的真实 iPhone,而不是模拟器。Agent 会先读取你的 Swift 源码理解每一屏的语义,生成类型化状态访问器,部署一个调试桥(DebugBridge),然后进入 find→fix→verify 闭环。技能文档开头明确界定了它的三条"不依赖":No simulator, no XCTest, no WebDriverAgent。
触发方式上,frontmatter 声明了ios qa、test the iphone app、find bugs on the device、qa the ios app等自然语言触发词,允许的工具集为 Bash、Read、Write、Edit、Grep、Glob、AskUserQuestion。
整体架构:双监听器 Daemon + Loopback-only StateServer
技能文档给出的架构如下:
┌──────────────────────┐ USB CoreDevice (IPv6) ┌──────────────────┐ │ gstack-ios-qa daemon │ ────────────────────────▶ │ iOS app │ │ (Mac, bun/TS) │ bearer + X-Session-Id │ StateServer │ │ │ │ (loopback only) │ │ - boot token rotate │ │ - /tap /swipe │ │ - session minting │ │ - /type /state │ │ - audit + redact │ │ - /snapshot │ └──────────────────────┘ └──────────────────┘ ▲ │ Tailscale (optional, --tailnet) │ ┌──────────────────────┐ │ Remote agent │ │ (OpenClaw, etc.) │ └──────────────────────┘三层信任边界的划分是整个设计的核心:
- iOS 应用内的 StateServer 永远只绑 loopback(
::1+127.0.0.1)。从 StateServer.swift.template 可以看到它同时持有 IPv6 与 IPv4 两个NWListener(注释说明单监听器的 IPv6-only 绑定曾在评审中被判定为不完整),且文件整体包在#if DEBUG里。 - Tailnet 入站流量完全是 Mac 侧 daemon 的职责,iOS 应用本身不感知 Tailscale。
- Mac daemon 负责身份校验与令牌铸造:通过本地
tailscaledsocket 的 WhoIs 端点规范化 Tailscale 身份,再为远程 Agent 铸造短命会话令牌(默认 1 小时)。
daemon 本体是 ios-qa/daemon/src/index.ts,一个用 bun/TypeScript 写的进程,启动流程在源码中清晰可查:
- 单实例强制(index.ts):通过
tryClaim对~/.gstack/ios-qa-daemon.pid取排他 flock;若已有 daemon 存活,新调用直接打印READY: port=<existing> pid=<pid>并退出,调用方转而连接已存在的端口。这就是技能文档中"Daemon acquires an exclusive flock … If another daemon is alive, the second invocation discovers its port and connects"的底层实现。 - Loopback 监听器(全功能面):先绑
127.0.0.1,再在同一端口尝试绑::1(index.ts)。loopback 不做鉴权,因为"绑定本身即边界"。 - Tailnet 监听器(可选,fail-closed):只有同时满足"用户传了
--tailnet"且"tailscaled LocalAPI socket 探测成功"两个条件才会打开;socket 缺失、权限拒绝或 WhoIs 响应不可解析时,daemon 打印tailnet binding refused但 loopback 侧照常运行(index.ts)。 - READY 行协议:daemon 就绪后向 stdout 输出
READY: port=<port> pid=<pid>,由调用方spawnAndWaitReady解析。CLI 入口支持GSTACK_IOS_DAEMON_PORT(默认 9099)、GSTACK_IOS_TARGET_UDID等环境变量(index.ts)。
一个值得注意的运维细节:Xcode 26 的 CoreDevice 只在 devicectl 命令执行期间维持 IPv6 隧道,因此 daemon 在成功 bootstrap 后会周期性调用devicectl info details来"戳"隧道保活(index.ts 中startTunnelKeepalive)。
前置条件
技能文档列出的硬性前提:
- macOS(daemon 依赖 Xcode 的
devicectl); - iPhone 通过 USB 连接、已配对且已信任;
- 安装 Xcode + Swift 工具链(
swift --version>= 5.9); - 应用源码在磁盘上,且至少有一个
@Observable类; - 远程控制模式需要:已安装 Tailscale 且用户已登录。
Phase 0:会话热启动(可选)
如果~/.gstack/ios-qa-session.json存在且设备仍连接,可跳过 Phase 1-2 直接进 Phase 3。会话缓存保存了轮换后的令牌、UDID、隧道地址和 accessor hash。三种情况下缓存失效:
- 用户传
--cold强制完整 bootstrap; - 首次 state 查询时检测到 accessor hash 不匹配;
- daemon 报告缓存的 UDID 已不在线。
文档给出的探测脚本(节选):
SESSION="$HOME/.gstack/ios-qa-session.json" if [ -f "$SESSION" ] && [ "$COLD" != "1" ]; then CACHED_UDID=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['udid'])") CACHED_PORT=$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('$SESSION'))); print(d['daemon_port'])") if curl -sf "http://127.0.0.1:$CACHED_PORT/healthz" > /dev/null; then echo "Warm start: daemon alive, device $CACHED_UDID connected" fi fi热启动成立的依据是 daemon 的/healthz端点在 loopback 侧公开可用(index.ts 返回{ version, mode: "loopback" })。
Phase 1:读源码,规划代码生成
这一步决定 DebugBridge 能否被安全地接入你的工程,技能文档给出了明确的兼容性红线:
- 生成器目前只支持文件作用域的
@Observable类;ObservableObject、@StateObject等其他观察模型不会产生访问器。 - 依赖接入假设的是 SwiftPM 工程清单。对于
.xcodeproj/.xcworkspace,不得臆造 package 或 target 接线。 - 任一条件不满足时,停止 bridge bootstrap 且不修改应用,保留已安装的 Production/TestFlight 构建;优先复用已有的真机 XCUITest harness;确需独立 QA 构建时,使用隔离的 bundle identifier 与非生产 entitlement,使 QA 构建可与生产应用共存。
源码扫描阶段,Agent 遍历--source <dir>下的应用源码,找出所有@Observable类,并记录紧跟生成器标记注释// @Snapshotable的属性——这些是快照候选字段。标记用注释表达是为了与@Observable宏组合使用。每个被标记字段必须满足:属于文件作用域 observable 类、是可写的实例var、有显式类型、setter 为 internal 或 public。快照类型限于 JSON 原生标量(String、Bool、各整型宽度、Float、Double、CGFloat)、数组、String 键字典及其 Optional 组合,且 key 必须在所有 observable 类之间唯一。任何约束被违反时,代码生成以源码诊断信息停止,而不是产出破损或有损的 harness——这在 gen-accessors.ts 中对应AccessorGenerationError,会把每条诊断(计算属性/不可变/不可访问/无类型/嵌套/重复 key/非 JSON 类型)逐条列出。
生成器有两套实现:SwiftPM 版本(gen-accessors-tool/Sources/GenAccessors/main.swift,基于 swift-syntax,首次构建需 2-5 分钟)和 TS 快速路径(gen-accessors.ts),后者用轻量 Swift 词法扫描器识别@Observable类声明、// @Snapshotable标记(以及存量集成的遗留@Snapshotable属性)、多行类型签名和 JSON 原生泛型。缓存 key 是swift_version || tool_git_rev || platform_triple || source_content_hash的复合值,只按源码内容哈希会漏掉生成器自身逻辑变更。扫描时还会显式排除DebugBridgeGenerated子树与StateAccessor.swift生成文件,防止生成物污染下一次缓存 key。
最后,向用户展示访问器清单,并用一次 AskUserQuestion 询问是否把 DebugBridge SPM 依赖装入Package.swift。
Phase 2:引导设备桥
一条确定性命令生成桥:
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ --app-source "<source-dir>" \ --bridge-dir "<source-dir>/DebugBridge"该命令一次生成规范本地桥包、类型化访问器和已安装版本标记。regen 还会清除旧版 ios-sync 创建的过期扁平文件集,防止陈旧的第二套 harness 残留在 app target 里。
将生成的
DebugBridge本地 SPM 依赖加入Package.swift。从 Package.swift.template 可以看到包提供三个仅 Debug 配置的库产物:DebugBridgeCore(Swift,跨平台):StateServer + 桥协议;DebugBridgeTouch(Objective-C,仅 iOS):KIF 衍生的进程内触摸合成,iOS 18+ 用_UIHitTestContext完成 SwiftUI 命中测试;DebugBridgeUI(Swift,仅 iOS):Screenshot / Elements / Mutation 桥实现。
app target 以
.when(configuration: .debug)依赖DebugBridgeUI(传递引入 Core + Touch)。模板注释里还记录了一次真实事故的防护:曾有 Release 构建把DebugBridgeTouch.m链接进可发布二进制,nm -j检出 15 个 DebugBridge 符号与IOHIDEventCreateDigitizer字符串,构成 App Store 指南 2.5.1 风险。因此防护分两层——所有源文件#if DEBUG守卫(真正生效的那层)+ 消费方的 configuration 条件,并配有 CI 不变式:swift build -c release后nm -j build/Release/<binary> | grep -q DebugBridge && exit 1。在
@mainApp init 中接线,#if DEBUG门控:#if DEBUG import DebugBridgeCore #if canImport(UIKit) import DebugBridgeUI // Install resolvers before StateServer opens its listener. DebugBridgeUIWiring.installAll() #endif // Replace AppState/AppStateAccessor with the type discovered in Phase 1. DebugBridgeManager.shared.start( appState: appState, register: AppStateAccessor.register ) #endif对应的模板文件是 DebugBridgeWiring.swift.template 与 DebugBridgeManager.swift.template。
构建并部署到设备:
xcodebuild -scheme <SchemeName> -destination 'platform=iOS,id=<UDID>' build install。启动应用:
devicectl device process launch --device <UDID> --console <bundle-id>,从os_log捕获首次运行打印的 boot token。按需拉起 Mac 侧 daemon:
gstack-ios-qa-daemon。令牌轮换:daemon 立即向 iOS StateServer 发
POST /auth/rotate,换成全新的仅存内存的令牌,boot token 约 5 秒后作废——此后任何再抓取os_log或磁盘 token 文件的行为拿到的都是死凭证。
第 7 步的完整流程在 tunnel-bootstrap.ts 中实现为一个六步编排:用devicectl list devices找到配对设备(内置设备排序:USB 直连优先,其次已建隧道优先)、启动应用(已在运行则幂等跳过)、解析隧道 IPv6(先devicectl info details,再 mDNSdns.lookup,最后dns.resolve6兜底)、从应用沙盒tmp/gstack-ios-qa.token读取 boot token、必要时重启一次应用再取令牌、最后POST /auth/rotate完成轮换。其中"重启一次"的触发条件值得注意:如果前一个 daemon 已经消费了那个一次性 boot token,新 daemon 无法恢复已轮换的内存 bearer,就--terminate-existing重启应用让 StateServer 重新铸造一枚(tunnel-bootstrap.ts)。轮换时新令牌是 32 字节base64url随机值(tunnel-bootstrap.ts)。
StateServer 侧的实现细节(StateServer.swift.template):boot token 初始为UUID().uuidString,同时写入NSTemporaryDirectory()下权限0600的文件作为 os_log 抓取的兜底;服务器还维护一个 5 分钟 TTL 的会话锁(sliding window on mutations only),以及供代码生成器注册的读/写 handler 表和 restore 校验钩子——restore 是两阶段校验,任何模型校验失败时不允许部分 restore 生效,schema 不匹配会返回schemaMismatch(expected, got),这正是后文409 schema_mismatch故障的来源。
Phase 3:视觉驱动 Agent 循环
每次迭代固定八步:
GET /screenshot(经 daemon 代理)→ 保存 PNG;GET /elements→ 无障碍树;GET /state/snapshot(只含// @Snapshotable字段)→ 当前状态;- 基于"屏幕所见 vs 测试目标"决定下一步动作;
POST /session/acquire抢占设备锁;- 执行
POST /tap、/swipe、/type,或POST /state/<key>状态写入; - 重新截图、对比,若发现 bug 记录 finding;
- 迭代结束
POST /session/release。
设备锁对应 StateServer 内那个 5 分钟孤儿超时的会话结构(StateServer.swift.template),防止两个 Agent 并发操作同一台设备。
安全侧:每个经过 tailnet 监听器的已认证变更请求都会向~/.gstack/security/ios-qa-audit.jsonl写一行审计记录。从 audit.ts 与 types.ts 可见,审计行包含时间戳、规范化身份、设备 UDID、端点、session_id、能力层与状态码;而被拒绝的请求(无令牌、过期、身份未放行、限流命中等)写入attempts.jsonl,且身份以加盐哈希存储,不落原始值。
运行模式与安全模型
Local-USB 模式(默认):daemon 只绑 loopback,不需要 Tailscale,调用方技能拿到全功能面。适合单人开发。
Tailnet 模式(--tailnet):daemon 额外绑定 Tailscale 接口(永不0.0.0.0)。要求本机tailscaled在运行且 daemon 能读/var/run/tailscale.sock;socket 缺失、权限拒绝或 WhoIs 不可解析时 fail-closed。远程 Agent 经 tailnet 打POST /auth/mint,daemon 通过 WhoIs 规范化身份、查 allowlist 文件、铸造会话令牌。完整操作文档在 ios-qa/docs/tailscale-acl-example.md。
能力分层(tailnet 模式):铸造令牌默认interact(可 tap/swipe/type),更高层级需属主显式铸造。层级有序observe<interact<mutate<restore,授予高层隐含低层:
- observe:
/screenshot、/elements、GET /state/*、/healthz、/session/heartbeat; - interact:observe +
/tap、/swipe、/type; - mutate:interact +
POST /state/<key>; - restore:mutate +
POST /state/restore。
属主在 Mac 上执行gstack-ios-qa-mint --remote <identity> --capability <tier>铸造;tailnet 自助铸造仅对已在 allowlist 中的身份成功。层级判定逻辑在 session-tokens.ts 的validate中,端点与最小能力层的映射表见 types.ts。
allowlist 文件(~/.gstack/ios-qa-allowlist.json,v1 schema)示例:
{ "version": 1, "entries": [ { "identity": "you@example.com", "capabilities": ["restore"], "expires_at": null, "note": "Owner — full access" }, { "identity": "ci@example.com", "capabilities": ["mutate"], "expires_at": "2026-12-31T00:00:00Z", "note": "CI runner — can write state but not full restore" }, { "identity": "tag:claude-readonly", "capabilities": ["observe"], "expires_at": null, "note": "Agents that should only read" } ] }身份经 WhoIs 规范化:用户 OAuth 为user@example.com(无acct:前缀),带 tag 节点为tag:<tagname>(小写),节点密钥为node:<nodekey-hex>(少见,建议用 tag)。文档还建议第二道防线——Tailscale ACL 本身限制谁甚至能到达 daemon 端口,例如只放行ci@example.com访问ios-qa-mac:9999,末尾默认drop。
令牌生命周期参数(与 session-tokens.ts 的常量一致):daemon 铸造的会话令牌默认 TTL 1 小时、--tailnet-session-ttl上限 24 小时;POST /session/heartbeat可滑动续期(封顶在原最大值);boot token 存活约 5 秒。限流:/auth/mint每身份 10 次/60 秒滑动窗口,第 11 次返回 429;每个 tailnet 请求 body 1MB 硬上限(超出 413,对应 index.ts 的readBody默认maxBytes = 1_048_576);screenshot 响应 10MB 上限。
审计行样例:
{"ts":"2026-05-18T14:23:00Z","identity":"ci@example.com","device_udid":"00008101-XXXX","endpoint":"/tap","session_id":"abc...","capability":"interact","request_id":"req_001","status":200}Recording 模式(--recording):DebugOverlay 在角落渲染一个小巧斜向 "AGENT DEMO" 水印,让录屏中一眼可辨设备正被 Agent 驱动。
Demo 模式
当用户说 "demo"、"show me"、"I want to see it working" 时进入DEMO MODE,其规则覆盖一切其他规则:Agent 必须把所有动作走可见 UI(/tap、/swipe、/type),绝不用POST /state/*跳步——观众要看到 Agent 逐字输入、逐个点击。设备上的 DebugOverlay 归属芯片显示 "Driven by Claude Code (demo)" 或远程 Agent 身份。demo 模式下截图帧率提升到 4fps,让录制有"实时感"。
故障模式与恢复
| 症状 | 可能原因 | 处置 |
|---|---|---|
到 daemon 的curl: connection refused | daemon 崩了 | 重跑/ios-qa;spawn-race 锁会 fail closed |
/auth/mint返回403 identity_not_allowed | 身份不在 allowlist | 在 Mac 上跑gstack-ios-qa-mint --remote <identity> |
/state/restore返回409 schema_mismatch | 快照来自更旧的应用构建 | 丢弃快照,重新捕获 |
代理返回503 device_disconnected | USB 路由丢失或应用重启 | daemon 作废陈旧隧道并做一次全新 bootstrap;若持续则重连/解锁 iPhone |
/auth/mint返回429 rate_limited | 同一身份 >10 mints/min | 等 60 秒;查审计日志有无异常 |
/state/restore返回413 body_too_large | 快照 >1MB | 调大--max-body或裁剪快照 |
daemon 侧的隧道恢复逻辑(index.ts)值得展开:代理请求遇到可恢复 socket 错误(ECONNREFUSED、ECONNRESET、EPIPE等)统一归一为 503device_disconnected;当上游返回 401(应用重启后新内存 bearer 拒绝旧令牌)或 503/504 的device_disconnected/upstream_timeout时,daemon 作废隧道、重新 bootstrap,然后有条件地重放请求——只有 401(证明旧 bearer 在分发前就被拒绝,重放安全)或 GET/HEAD/OPTIONS 读请求才重放,变更请求绝不重放,避免 double-tap 或状态跳转两次。
清理:/ios-clean 与 Release 护栏
用 ios-clean 技能在 Release 构建前移除 DebugBridge SPM 依赖与所有#if DEBUG接线。但要清楚:这是一条便利路径,结构性的 Release 护栏才是安全关键路径——即Package.swift的.when(configuration: .debug)条件加上 CI 里swift build -c release+nm -j符号扫描的组合检查(见 Package.swift.template 注释中记录的防护分级)。
可验证性:测试覆盖
这套链路的关键行为大多有对应测试,便于读者按图索骥验证上文结论:
- daemon-integration.test.ts:daemon 端到端集成;
- session-tokens.test.ts:令牌铸造、校验、限流;
- tunnel-bootstrap.test.ts:设备选择、boot token 重启恢复;
- auth-mint.test.ts 与 allowlist.test.ts:tailnet 身份放行;
- proxy-classify.test.ts:端点能力分层判定;
- single-instance.test.ts、tailscale-localapi.test.ts、audit.test.ts:锁、socket 探测、审计写入;
- gen-accessors.test.ts:访问器生成的缓存与解析行为。
小结
ios-qa 的工程设计可以概括为三条主线:信任边界最小化(StateServer 只活在设备 loopback,daemon 独占 tailnet 面且 fail-closed);凭证短命化(boot token ~5 秒、会话令牌默认 1 小时、全内存不落地);操作可审计(变更请求写 audit、拒绝写 attempts、身份哈希化)。配合 Phase 0-3 的工作流与能力分层,它把"远程 Agent 操作一台真机 iPhone"从一个危险设想变成一套有明确权限模型和恢复手册的工程方案。
【免费下载链接】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),仅供参考