# 移动端原生 App 远程映射方案（模型 B：实时终端镜像 + 结构化选项）

> 状态：设计待评审 · 面向 CCGUI 1.0.25+ · 与《微信ClawBot对接方案.md》并列
> 目标读者：实现者。本文把改造拆到文件/函数级，可据此直接开工。

## 1. 背景与目标

当前手机端遥控走微信 ClawBot（iLink），这是一条腾讯云中转的**纯文本、约 35s 长轮询**管道，天生扛不了实时流、也没有按钮/卡片，导致对话里 AI 的**选项交互**只能退化成"回数字"，体验很差。

本方案新增一个**原生 App 客户端**（Flutter，iOS + Android 一套码），把 CCGUI 里指定的终端会话**实时映射**到手机，获得近乎原生的控制体验：

- 连接到已在电脑上运行的 CCGUI → 看项目列表 → 进项目看会话列表 → 进会话
- 会话操作只需：**关闭**、**恢复**（选权限模式）、**新建**（选模型 + 权限模式）
- 核心诉求：AI 的选项交互（权限确认 / AskUserQuestion）在手机上变成**原生可点按钮**
- 外网可达、零配置（给别人也能用）

## 2. 与微信模型 A 的关系：互补，不替换

| | 微信 ClawBot（模型 A） | 原生 App（本方案，模型 B） |
|---|---|---|
| 角色 | 异步通知，把人"叫回来" | 回来后的实时控制 |
| 传输 | iLink 云中转，纯文本长轮询 | WebSocket 实时双向流 |
| 终端 | 读屏纯文本快照 | 全彩 xterm 实时镜像 |
| 保留 | 保留 | 新增 |

**关键收益**：App 第一阶段**不做推送**——"叫你回来"交给现成的微信通知，App 只管"连上后的实时控制"，省掉 APNs/FCM + 推送服务端一整块。

两者共用同一套后端遥控能力层，互不冲突。

## 3. 总体架构：数据面 / 控制面分离，复用桌面前端作为"控制大脑"

前提（用户已确认）：**电脑上的 CCGUI 保持运行**，App 连的就是这台桌面。基于此，采用**模型 B**——不把会话编排逻辑大规模下沉到 Rust，而是复用桌面 React 前端已跑通的编排大脑（`openTab / newSession / doResume / formatToolPrompt / sendKeys / 就绪泵`），这与微信模块 `onWechatInbound` 的做法同构，改造面最小。

```
┌─────────────┐   WebSocket    ┌──────────────────────── CCGUI 桌面进程 ──────────────────────┐
│  Flutter App │◀══════════════▶│  mobile 网关(Rust,新增)                                      │
│  xterm.dart  │                │    │                                                         │
│  选项浮层     │                │    ├─【数据面·低延迟】直连 PtyManager                          │
└─────────────┘                │    │      out: pty.rs 读线程订阅者扇出 → WS                    │
                               │    │      in : WS input/resize → PtyManager.write/resize      │
                               │    │                                                         │
                               │    └─【控制面】emit "mobile-inbound" → 前端复用编排            │
                               │           list/new/resume/close/answer                       │
                               │           前端执行后 → 命令/事件回传网关 → App                 │
                               └──────────────────────────────────────────────────────────────┘
```

- **数据面（实时终端 in/out）**：直接在 Rust 层做，绕过前端，保证低延迟。
- **控制面（列表 / 新建 / 恢复 / 关闭 / 选项应答）**：网关 `emit("mobile-inbound")` 给前端，前端复用现有函数执行，结果回传。
- **静态列表**：项目列表来自 `Config.projects`、历史会话来自 `sessions::list_sessions`，网关可在 Rust 侧直接读，不必经前端。
- **活动会话快照**（正在跑的 tab：tabId/sessionId/project/title/status）是前端 React 态，需前端主动发布给网关（见 7.2）。

## 4. 传输与安全（分阶段推进，帧协议传输无关，不返工）

| 阶段 | 通道 | 安全 | 适用 |
|---|---|---|---|
| 一 | `ws://` 明文，仅监听 LAN | 强随机 token + 二维码配对 | 同一可信 WiFi，打通端到端 |
| 一尾 | `wss://` 自签证书 | 证书指纹 pinning（二维码下发） | 原生 App 独占能力，局域网加密 |
| 三 | 自建 rendezvous 云中继 | 端到端加密（X25519 + Noise/libsodium），中继零知识 | 外网、零配置、给别人用 |

要点：
- **原生 App 能直连局域网**（浏览器不能）——App 可程序化接受桌面自签证书做 pinning，故 `wss://电脑IP:端口` 直连成立。
- **外网零配置 = 必须自建云中继**（TeamViewer/AnyDesk 拓扑）：桌面只出站拨一条常驻连接到中继，手机也连中继，中继按房间撮合转发。沿用微信"只出站、不开入站端口"的 NAT 拓扑。
- **配对模型推荐"配对码/零账号"**：桌面显示 ID + 一次性配对码/二维码，手机扫码即绑定，中继只按房间号路由，避开账号系统。
- **E2E 是给别人用的底线**：流量过你运维的中继，中继不应看到明文；配对期交换公钥，之后逐帧加密。
- 安全闸沿用微信：**远程写入不绕过 Claude Code 自身权限确认**，且设一个总开关 `mobile_enabled`。
- 注意：新增 `0.0.0.0` 入站监听可能触发 360 弹窗/拦截（见记忆），首启需引导放行。

## 5. 通信协议（单 WS 多路复用，控制帧 JSON + 数据帧二进制）

一条 WS 连接承载一台桌面的全部会话。

**客户端 → 桌面**
| type | 字段 | 说明 |
|---|---|---|
| `hello` | `token` | 鉴权（配对得到的设备 token） |
| `list` | — | 请求项目 + 会话清单 |
| `attach` | `tabId, cols, rows` | 订阅某会话的实时流 |
| `detach` | `tabId` | 取消订阅 |
| `input` | `tabId, data`(base64/原始字节) | 键入，等价 `pty_write` |
| `resize` | `tabId, cols, rows` | 尺寸，等价 `pty_resize` |
| `new` | `projectPath, model, permissionMode, extraArgs` | 新建会话 |
| `resume` | `projectPath, sessionId, permissionMode` | 恢复历史会话 |
| `close` | `tabId` | 关闭会话，等价 `pty_kill` |
| `answer` | `tabId, promptId, choice`(下标/多选下标) | AI 选项应答 |
| `ping` | — | 心跳 |

**桌面 → 客户端**
| type | 字段 | 说明 |
|---|---|---|
| `sessions` | `projects[], tabs[]` | 项目 + 活动/历史会话清单 |
| `snapshot` | `tabId, data` | attach 时先回放环形缓冲历史屏 |
| `data` | `tabId, data` | 实时 PTY 字节，等价 `pty-data` |
| `exit` | `tabId, exitCode` | 会话退出，等价 `pty-exit` |
| `status` | `tabId, status` | working/attention/idle 状态变化 |
| `prompt` | `tabId, promptId, kind, questions/text` | 结构化 AI 选项（来自 `formatToolPrompt`） |
| `pong` | — | 心跳回应 |

数据帧阶段一沿用现有 base64（复用 `PtyDataPayload`）；上 wss 后可切二进制去掉 33% 膨胀。断线重连后 `hello + attach`，桌面用环形缓冲从上次偏移续传。

**尺寸冲突策略**：一个 PTY 只有一份 cols/rows。默认**手机不 resize PTY**（按桌面当前尺寸渲染、允许横向滚动/缩放），仅在显式"接管尺寸"时才 resize，避免与桌面互相打架。

## 6. 后端改造清单（Rust，函数级）

### 6.1 `src-tauri/Cargo.toml` — 新增依赖
- `tungstenite`（**同步**版，非 tokio）：贴合现有"无 tokio + 每连接一线程 + generation 代际取消"范式（`hooks.rs`、`wechat/`）。
- 阶段一尾/三：`rustls` + `rcgen`（自签证书生成）、`snow` 或 libsodium 绑定（Noise/E2E）。
- 现有已可复用：`tiny_http 0.12`、`base64 0.22`、`qrcode 0.14`、`uuid 1`。

### 6.2 `src-tauri/src/pty.rs` — PTY 字节流扇出 + 环形缓冲
现状：读线程（`pty.rs:97-114`）只 `app.emit("pty-data")`；`PtyManager` 只有 `inner`（`pty.rs:32-35`），读线程闭包只 clone 了 `app_reader`、`tab_for_reader`，**未持有 manager**。

改造：
1. `PtyManager` 新增字段（Clone 时随 `inner` 一起 clone，因均为 `Arc`）：
   ```rust
   pub struct PtyManager {
       inner: Arc<Mutex<HashMap<String, PtyEntry>>>,
       subscribers: Arc<Mutex<HashMap<String, Vec<Sender<Vec<u8>>>>>>, // tabId → WS 发送端
       scrollback: Arc<Mutex<HashMap<String, RingBuffer>>>,            // tabId → 原始字节环形缓冲(256KB~1MB)
   }
   ```
2. `spawn`（`pty.rs:38-132`）在起读线程前，clone `subscribers` / `scrollback` 进闭包。
3. 读线程（`pty.rs:100-112`）在 `Ok(n)` 分支：先把 `&buf[..n]` **原始字节**写入该 tab 的环形缓冲（供重放，注意按字节存、attach 时整段重放避免截断在 ANSI 中途），再 base64 后 `emit`（保持桌面不变），最后遍历该 tab 的订阅者 `Sender` 逐个 `try_send(buf[..n])`。**背压**：用有界 channel，慢客户端满则丢帧/合并，绝不阻塞读线程。
4. 新增方法：`subscribe(&self, tab_id) -> Receiver<Vec<u8>>`、`unsubscribe(&self, tab_id, id)`、`snapshot(&self, tab_id) -> Vec<u8>`（读环形缓冲）。
5. `remove`（`pty.rs:163-166`）与 wait 线程退出时清理该 tab 的 subscribers/scrollback。

### 6.3 `src-tauri/src/mobile/`（新模块）— WS 网关
仿 `wechat/mod.rs` 的线程与 generation 结构：
- `start_gateway(app: AppHandle, port: u16)`：`TcpListener::bind(("0.0.0.0", port))`，`accept` 循环，每个连接 `std::thread::spawn` 一个 `tungstenite::accept` 会话线程。generation 原子代际用于 `mobile_enabled` 翻转时优雅退出（照搬 `wechat` 的写法）。启动范式参考 `lib.rs:783-786` 的 `hooks::start_server` / `lib.rs:805-812` 的 wechat 启动。
- 每连接会话线程：
  - `hello` 校验 token（与配对存储比对）。
  - `attach`：`ptys.subscribe(tabId)` 拿 Receiver，先发 `snapshot`（`ptys.snapshot`），再起转发循环把字节封 `data` 帧发出。
  - `input`/`resize`/`close`：直接调 `ptys.write / resize / kill`。
  - `list`/`new`/`resume`/`answer`：转成 `app.emit("mobile-inbound", {...})` 交前端（见 7.2/7.3），前端处理后经命令/事件回传。
  - `ping/pong` 心跳，断线清理订阅。
- 鉴权/配对：新增 `mobile/store.rs`，仿 `wechat/store.rs` 落盘 `%APPDATA%/ccgui/mobile.json`（设备 token 列表，支持吊销）。配对二维码用现有 `qrcode` crate，内容 = `ws(s)://LAN-IP:port` + token（+ 阶段一尾的证书指纹）。

### 6.4 `src-tauri/src/store.rs` — Settings 新增（改 2 处）
- 结构体（`store.rs:53-85`）新增 `pub mobile_enabled: bool` + `pub mobile_port: u16`。
- `Default`（`store.rs:92-108`）：`mobile_enabled: false`、`mobile_port: 45820`（仿 `hook_port: 45813`）。
- serde 自动处理加载/保存，无需改读写逻辑。

### 6.5 `src-tauri/src/lib.rs` — 命令注册 + 启动 + 快照托管
- `AppState`（`lib.rs:24-29`）新增托管：活动会话快照 `pub mobile_sessions: Mutex<Vec<MobileSessionMeta>>`（由前端发布，见 7.2）。
- `setup()`（`lib.rs:751-819`）末尾仿 wechat 段：`if settings.mobile_enabled { mobile::start_gateway(app.handle().clone(), mobile_port); }`。
- 新增命令并在 `invoke_handler`（`lib.rs:837-907`）注册：
  - `mobile_publish_sessions(state, snapshot: Vec<MobileSessionMeta>)`：前端把活动会话快照推给网关托管。
  - `mobile_push(state, msg)`：前端把控制面结果（列表/prompt/status）回传网关，网关转 App。
  - `mobile_status` / `mobile_start_pairing` / `mobile_unbind`：配对与状态（仿 wechat 命令）。
- 复用现有 `list_sessions`、`get_config`（读 `Config.projects`）供网关直接取静态列表。

### 6.6 `src-tauri/src/hooks.rs` — PreToolUse hook（阶段二，权限确认原生化）
现状：`HOOK_EVENTS`（`hooks.rs:27`）= `["UserPromptSubmit","Stop","Notification"]`；`hook_command`（`hooks.rs:36-39`）统一带 `-m 3`（curl 3s 超时）；`start_server`（`hooks.rs:122-221`）先 `respond` 后 emit，**响应体即 hook stdout**（`hooks.rs:152-165` 的 `INDEX_GUIDE` 注入是明证）。

改造：
1. `install`（`hooks.rs:57-90`）为 `PreToolUse` 单独拼一条**长超时命令**（如去掉 `-m 3` 或设 `-m 120`），因为要等用户在手机上点按钮，3 秒不够。其余事件不变。
2. `start_server` 主循环遇 `PreToolUse`：**不立即 respond**，而是把 `request`（`tiny_http::Request` 是 `Send`）连同解析出的 `{session_id, tool_name, tool_input}` move 进一个等待线程；主循环继续（不阻塞其它 hook）。
3. 等待线程：生成 `promptId`，`emit("mobile-inbound", {kind:"pretooluse", promptId, ...})` + 同时给桌面前端弹同一浮层；把 `promptId → oneshot Sender` 存入 `AppState` 的 `pending_hooks: Mutex<HashMap<String, Sender<Decision>>>`；等待决策（带超时）。
4. 收到 App/桌面的 `answer`（经 `mobile_push` / 前端命令）→ 通过 `pending_hooks` 发决策 → 等待线程 `request.respond(Response::from_string(decision_json))`。
   - `decision_json` 按 **Claude Code PreToolUse hook 输出 schema**：`{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny|ask","permissionDecisionReason":"..."}}`（实现时以官方 schema 为准）。
5. **超时回退**：等待超过阈值（须 < curl 超时）无人应答 → 返回 `ask`（或空），让 Claude Code 正常弹 TUI，桌面照旧可答。"任一端已答即解锁"去重。

> 边界：`PreToolUse` 对**权限类**（Bash/Edit/Write）能干净返回 allow/deny/ask。对 **AskUserQuestion**，hook 只能 allow/deny、无法提供"选了哪一项"，故其选择仍走 6.7 的按键注入路径。

### 6.7 AI 选项应答（复用现有，无新增后端）
- **AskUserQuestion 选项来源**：`sessions::last_tool_use`（`sessions.rs:489-540`）读 transcript 尾部拿 `{name, input}`，前端 `formatToolPrompt` 结构化。
- **答复**：沿用 `sendKeys`（`App.tsx:257-259`）把"选下标 N → N-1 个 `\x1b[B` + `\r`"、多选空格切换注入 PTY。App 的 `answer` 帧 → 前端 `sendKeys`。
- 已知脆弱性（如实告知）：假设"光标初始在第 1 项、transcript 选项顺序 == TUI 菜单顺序"，无回读校验，有选错风险；实时镜像可让用户目视校验。

## 7. 前端改造清单（TypeScript，函数级）

### 7.1 `src/ipc.ts` — 抽出 Transport 接口（可选，用于将来 Web 端复用）
桌面 App 本身仍走 Tauri IPC，本阶段前端**不换传输**；仅新增 mobile 相关命令封装：`mobilePublishSessions`、`mobilePush`、`mobileStatus`、`mobileStartPairing`、`mobileUnbind`，与 `onMobileInbound(cb)` 事件订阅（对称于 `onWechatInbound`，`ipc.ts:205-206`）。
> Transport 接口的完整抽象留给未来 H5/Web 客户端，届时把 `ipc.ts` 的 invoke/listen 抽成 Tauri 版 + WS 版双实现。本阶段不做，避免范围膨胀。

### 7.2 `src/App.tsx` — 活动会话快照发布
新增一个 `useEffect`，监听 `tabs` 变化（`App.tsx` tabs 状态）时，映射成 `MobileSessionMeta[]`（tabId/sessionId/projectPath/title/status/model）并调 `mobilePublishSessions`。这样网关始终持有最新活动会话清单供 App 的 `list`。

### 7.3 `src/App.tsx` — mobile-inbound 编排复用（对称 onWechatInbound）
新增 `onMobileInbound(p => …)` 处理器，按 `p.kind` 分派，全部复用现有函数：
- `list` → 读 `tabsRef` + 调 `listSessions` 组装 → `mobilePush({type:'sessions'})`。
- `new` → `newSession(projectPath, {model, permissionMode, extraArgs})`（`App.tsx:547-555`）。
- `resume` → `doResume(projectPath, sessionId, title, permissionMode)`（`App.tsx:563-586`）。
- `close` → `ptyKill`（或现有关闭逻辑）。
- `answer`（AskUserQuestion）→ 复用 `App.tsx:974-1015` 的"回数字→方向键"翻译 + `sendKeys`。
- `pretooluse`（阶段二）→ 前端渲染同一权限浮层，用户在桌面/手机任一端答复 → 回传决策解锁 hook。
- attention 时把 `formatToolPrompt`（`App.tsx:198-233`）结果经 `mobilePush({type:'prompt'})` 主动推给 App（对称于 `App.tsx:712-738` 推微信）。

无需改 `TerminalView.tsx`（数据面在 Rust 直连，前端 xterm 仅桌面渲染用）。

## 8. Flutter 客户端（新工程，`ccgui-mobile/`）

技术栈：Flutter（iOS+Android 一套码）+ `xterm`（xterm.dart）+ `web_socket_channel` + `mobile_scanner`（扫码配对）+ `flutter_secure_storage`（设备 token）。

页面与对接点：
- **连接/配对页**：`mobile_scanner` 扫桌面二维码 → 存 `ws(s)://IP:port` + token（+ 证书指纹）→ `hello`。
- **项目列表页**：`list` → 渲染 `sessions.projects[]`。
- **会话列表页**：项目下的活动会话（可 attach，带 status 徽标）+ 历史会话（可 resume）；操作按钮：关闭 / 恢复(选权限模式) / 新建(选模型+权限模式，弹底部选择器)。
- **会话页**：`xterm.dart` TerminalView 承载实时镜像——`data` 帧 base64 解码后 `terminal.write(bytes)`；`terminal.onOutput → input` 帧；底部**辅助键条**（Esc/Tab/Ctrl/方向/Ctrl-C/回车）补足软键盘缺键；`status=attention` 或收到 `prompt` 帧时弹**原生选项浮层**（AskUserQuestion→单选/多选按钮，权限→允许/允许且不再问/拒绝），点选发 `answer`。
- iOS 约束：后台不能长连 WS（实时仅前台，靠通知唤醒——复用微信通知）；App Store 对"远程终端"类审核（2.5.2）有驳回风险，必要时走 TestFlight/企业签名。

## 9. 分阶段里程碑与工作量

| 阶段 | 内容 | 量级 |
|---|---|---|
| 一 | 6.2/6.3/6.4/6.5 + 7.2/7.3 + 8（LAN `ws` 明文 + token 配对，双端 Flutter）：连接→项目→会话→实时镜像→关闭/恢复/新建 | 约 5-7 周 |
| 一尾 | `wss` 自签 + 证书 pinning（rustls/rcgen） | 约 3-5 天 |
| 二 | 6.6 PreToolUse hook + AskUserQuestion 原生按钮（7.3 已含前端）| 约 1 周 |
| 三 | 自建 rendezvous 中继 + 配对码 + E2E + 桌面出站拨出 + 断线重连（外网零配置）| 约 3-5 周 + 运维 |
| 四 | 分发：Android APK；iOS TestFlight/审核；推送增强（可选，先由微信顶着）| 视策略 |

整体约 2-3 个月的产品级项目。核心遥控语义（会话路由、就绪泵、结构化选项、答复翻译、安全闸）微信模块已跑通、可整套平移，新增主要在"网络传输 + 安全 + 后端字节流扇出/缓冲"这套新地基与 Flutter 客户端。

## 10. 已知风险与边界

- **多端同连一个 PTY 的尺寸/焦点冲突**：默认手机不 resize PTY（见 §5）。
- **AskUserQuestion 答复靠按键注入**，有选错风险（§6.7），实时镜像目视校验缓解；权限类已由 PreToolUse 结构化解决。
- **非工具型 TUI 菜单**（/model、/effort、斜杠补全、plan/信任框）在 transcript 与 hook 里都无结构化数据，App 端只能实时镜像 + 辅助键条操作，不生成原生按钮。
- **PreToolUse hook 阻塞**：等待期间该工具在桌面 TUI 同步冻结；须超时回退 `ask` 并"任一端已答即解锁"。
- **安全**：LAN/中继暴露 shell 控制通道是高价值攻击面，坚持强 token + 远程不绕过 Claude 权限确认 + 总开关 + 设备 token 吊销 + （中继）E2E。
- **360 误杀**：新增 `0.0.0.0` 监听可能被拦，首启引导放行；编译沿用 `-j2` 规避运行中应用被杀（见记忆）。
- **Claude Code 版本漂移**：hook 字段 / AskUserQuestion 结构 / PreToolUse 输出 schema 可能变，按键注入与屏幕抓取尤其脆弱，需回归。

## 11. 端到端验证

阶段一验收（LAN）：
1. 编译后端（`cargo build`，用 `-j2` 规避 360）与前端；启动 CCGUI。
2. 设置页开启 `mobile_enabled`，确认 `0.0.0.0:45820` 监听（防火墙放行），页面显示配对二维码。
3. 手机与电脑同 WiFi，Flutter App 扫码配对 → `hello` 通过。
4. App 显示项目列表 → 进项目显示活动 + 历史会话 → attach 某活动会话，验证：实时输出镜像、`snapshot` 历史回放、键入/回车、辅助键条特殊键、resize 策略。
5. 新建会话（选模型 + 权限模式）→ 电脑侧起真实 PTY 且 App 同步镜像；恢复历史会话（选权限模式）；关闭会话。
6. 断开重连（切 App 后台再回）→ 环形缓冲续传不丢屏。

阶段二验收：触发一次 Bash 权限确认 → App 弹原生"允许/允许且不再问/拒绝"→ 点按 → Claude 侧按决策放行/拒绝（无按键注入）；触发一次 AskUserQuestion → App 弹单选/多选 → 点选 → 终端命中对应项。

外网/中继（阶段三）另行验收。
