news 2026/9/7 19:32:34

gstack ios-qa:用视觉驱动 Agent 循环在真机上测试 iOS 应用的完整机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
gstack ios-qa:用视觉驱动 Agent 循环在真机上测试 iOS 应用的完整机制

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 qatest the iphone appfind bugs on the deviceqa 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.) │ └──────────────────────┘

三层信任边界的划分是整个设计的核心:

  1. iOS 应用内的 StateServer 永远只绑 loopback::1+127.0.0.1)。从 StateServer.swift.template 可以看到它同时持有 IPv6 与 IPv4 两个NWListener(注释说明单监听器的 IPv6-only 绑定曾在评审中被判定为不完整),且文件整体包在#if DEBUG里。
  2. Tailnet 入站流量完全是 Mac 侧 daemon 的职责,iOS 应用本身不感知 Tailscale。
  3. 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 能否被安全地接入你的工程,技能文档给出了明确的兼容性红线:

  1. 生成器目前只支持文件作用域的@ObservableObservableObject@StateObject等其他观察模型不会产生访问器。
  2. 依赖接入假设的是 SwiftPM 工程清单。对于.xcodeproj/.xcworkspace,不得臆造 package 或 target 接线。
  3. 任一条件不满足时,停止 bridge bootstrap 且不修改应用,保留已安装的 Production/TestFlight 构建;优先复用已有的真机 XCUITest harness;确需独立 QA 构建时,使用隔离的 bundle identifier 与非生产 entitlement,使 QA 构建可与生产应用共存。

源码扫描阶段,Agent 遍历--source <dir>下的应用源码,找出所有@Observable类,并记录紧跟生成器标记注释// @Snapshotable的属性——这些是快照候选字段。标记用注释表达是为了与@Observable宏组合使用。每个被标记字段必须满足:属于文件作用域 observable 类、是可写的实例var、有显式类型、setter 为 internal 或 public。快照类型限于 JSON 原生标量(StringBool、各整型宽度、FloatDoubleCGFloat)、数组、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:引导设备桥

  1. 一条确定性命令生成桥

    ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ --app-source "<source-dir>" \ --bridge-dir "<source-dir>/DebugBridge"

    该命令一次生成规范本地桥包、类型化访问器和已安装版本标记。regen 还会清除旧版 ios-sync 创建的过期扁平文件集,防止陈旧的第二套 harness 残留在 app target 里。

  2. 将生成的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 releasenm -j build/Release/<binary> | grep -q DebugBridge && exit 1

  3. @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。

  4. 构建并部署到设备xcodebuild -scheme <SchemeName> -destination 'platform=iOS,id=<UDID>' build install

  5. 启动应用devicectl device process launch --device <UDID> --console <bundle-id>,从os_log捕获首次运行打印的 boot token。

  6. 按需拉起 Mac 侧 daemongstack-ios-qa-daemon

  7. 令牌轮换: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 循环

每次迭代固定八步:

  1. GET /screenshot(经 daemon 代理)→ 保存 PNG;
  2. GET /elements→ 无障碍树;
  3. GET /state/snapshot(只含// @Snapshotable字段)→ 当前状态;
  4. 基于"屏幕所见 vs 测试目标"决定下一步动作;
  5. POST /session/acquire抢占设备锁;
  6. 执行POST /tap/swipe/type,或POST /state/<key>状态写入;
  7. 重新截图、对比,若发现 bug 记录 finding;
  8. 迭代结束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/elementsGET /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 refuseddaemon 崩了重跑/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_disconnectedUSB 路由丢失或应用重启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 错误(ECONNREFUSEDECONNRESETEPIPE等)统一归一为 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 19:31:26

老旧SCADA系统无损新增告警:旁路加装边缘计算网关的完整实践

干了这么多年工控项目&#xff0c;我最怕听到领导轻描淡写来一句&#xff1a;“系统跑得好好的&#xff0c;你敢动吗&#xff1f;”尤其当对象是一套用了十几年、连原厂都找不到人的老旧SCADA系统——中控室大屏还是老组态画面&#xff0c;运行记录贴着泛黄的停机登记表&#x…

作者头像 李华
网站建设 2026/9/7 19:27:11

dorcker如果多台电脑都需要部署怎么办

如果有多台电脑都需要部署 Docker 容器&#xff0c;最直接的方法就是使用容器编排工具将多台机器组成一个集群来统一管理。主要有三个主流方案&#xff0c;你可以根据实际情况选择&#xff1a; &#x1f3af; 方案对比与选择方案适用场景优点缺点Docker Swarm中小规模集群、刚接…

作者头像 李华
网站建设 2026/9/7 19:26:38

分布式文件系统设计核心:从元数据到副本策略的工程实践

聊分布式文件系统设计之前&#xff0c;先说我上周的一次评审经历。有个团队准备把几千万个小文件全部塞进单机文件系统&#xff0c;理由是“数据量也不大”。我当时反问了一句&#xff1a;如果半年后数据翻十倍&#xff0c;你的目录树还能扛住吗&#xff1f;这个问题&#xff0…

作者头像 李华
网站建设 2026/9/7 19:26:31

Ant Design + Electron 桌面应用搭建指南:从登录页到分发上架

Ant Design Electron 桌面应用搭建指南&#xff1a;从登录页到分发上架 【免费下载链接】ant-design An enterprise-class UI design language and React UI library 项目地址: https://gitcode.com/GitHub_Trending/an/ant-design 给团队的内部工具套一层桌面壳&#…

作者头像 李华
网站建设 2026/9/7 19:26:20

Spark向量化执行引擎选型与落地实践:从原理到调优

做了五年多的 Spark 调优&#xff0c;我越来越觉得很多团队把精力都放在资源配置、数据倾斜和 Shuffle 优化上&#xff0c;反而忽略了执行引擎本身带来的瓶颈。前段时间帮一个团队做专项性能优化&#xff0c;几十个核心 ETL 任务跑下来&#xff0c;最明显的规律不是某个查询写得…

作者头像 李华
网站建设 2026/9/7 19:25:13

2026年知网AIGC检测怎么过?passbug实测分享

passbug官网直达入口&#xff1a;https://passbug.cn/ 硕士论文送审前&#xff0c;知网AIGC检测报告上那行“疑似AI生成内容比例”成了不少人最怕看见的数字。理工科论文尤其如此——实验数据、公式推导、算法伪代码这些段落&#xff0c;语言模式与AI生成文本高度重合&#xf…

作者头像 李华