diff --git a/apps/df-miniapp/src/api/ws.ts b/apps/df-miniapp/src/api/ws.ts index ff61e21..b45e8ae 100644 --- a/apps/df-miniapp/src/api/ws.ts +++ b/apps/df-miniapp/src/api/ws.ts @@ -1,10 +1,11 @@ /** - * df-relay WS 客户端(uni-app 跨端适配) + * u-relay WS 客户端(uni-app 跨端适配) * - * 协议(对齐 crates/df-relay/src/relay.rs handle_connection): - * 1. 建立 WS 连接到 `/ws/miniapp` - * 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时) - * 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close + * 协议(对齐 E:/wk-lab/u-relay/src/relay.rs handle_connection): + * 1. 建立 WS 连接到 `/ws/commander` + * 2. 首帧发 Hello{kind:'commander', channel_id, token} 身份宣告(relay 10s 超时) + * 3. 校验通过 → relay 回 hello_ack;失败 relay 发 error 帧 + Close + * (错误码:handshake_failed/kind_mismatch/invalid_channel_id/server_misconfigured/auth_failed) * * 跨端适配: * - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket) @@ -176,10 +177,10 @@ class WsClient { } /** - * 发送 Command(miniapp → device) + * 发送 Command(commander → agent) * * 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成 - * BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。 + * BroadcastMessage(channel_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。 */ send(cmd: MiniCommand): boolean { if (DEBUG) console.log('[dbg:ws] send', cmd.cmd, 'socket=', !!this.socket, 'handshaked=', this.handshaked) @@ -314,8 +315,8 @@ class WsClient { this.setStatus('handshaking') const cfg = getConfig() const hello: Hello = { - kind: 'miniapp', - device_id: cfg.deviceId, + kind: 'commander', + channel_id: cfg.channelId, token: cfg.token, } try { diff --git a/apps/df-miniapp/src/config.ts b/apps/df-miniapp/src/config.ts index a1766fa..9abd26a 100644 --- a/apps/df-miniapp/src/config.ts +++ b/apps/df-miniapp/src/config.ts @@ -1,18 +1,18 @@ /** - * miniapp 运行时配置(host/device_id/token 占位,由用户在设置页或 build env 填) + * miniapp 运行时配置(channel_id/token 占位,由用户在设置页或 build env 填) * * 这些值在真实部署前是占位: - * - RELAY_HOST:ws/wss 地址,需替换为实际部署的 df-relay 地址 - * - DEVICE_ID:用户首次配置桌面端时生成的设备 ID - * - TOKEN:对齐 df-relay 的 DF_RELAY_TOKEN(生产建议每 device 独立 token) + * - RELAY_HOST:ws/wss 地址,需替换为实际部署的 u-relay 地址 + * - CHANNEL_ID:u-relay 配对通道 ID(与桌面端 df-tunnel 共享同键) + * - TOKEN:对齐 u-relay 的 U_RELAY_TOKEN(生产建议 per-channel 独立 token,Phase3) */ export interface MiniappConfig { - /** relay WS 地址(含协议),如 'ws://192.168.1.100:8080/ws/miniapp' 或 'wss://relay.example.com/ws/miniapp' */ + /** relay WS 地址(含协议),如 'ws://192.168.1.100:9180/ws/commander' 或 'wss://relay.example.com/ws/commander' */ relayHost: string - /** 配对绑定的设备 ID */ - deviceId: string - /** 配对 token(对齐 df-relay DF_RELAY_TOKEN) */ + /** 配对绑定的通道 ID(u-relay channel_id,与桌面端共享同键配对) */ + channelId: string + /** 配对 token(对齐 u-relay U_RELAY_TOKEN) */ token: string /** 心跳间隔(ms) */ heartbeatInterval: number @@ -29,17 +29,17 @@ export interface MiniappConfig { /** * 默认配置(占位值,需替换) * - * 真实部署:用户在设置页填 relayHost/deviceId/token,或通过 build-time env 注入。 + * 真实部署:用户在设置页填 relayHost/channelId/token,或通过 build-time env 注入。 * MVP 阶段留占位让类型自洽,实际连接会失败(token 校验不通过 → relay 关连接, * 这符合预期,提示用户配置)。 */ export const defaultConfig: MiniappConfig = { - // 测试服 wss(u-work.1216.top → nginx 终止 TLS → df-relay :9180 明文 ws) + // u-relay 现行协议 commander 端点:wss://u-work.1216.top → nginx 终止 TLS → u-relay :9180 明文 ws // 微信小程序真机要求 wss + socket 合法域名白名单(微信公众平台→开发设置→服务器域名) - relayHost: 'wss://u-work.1216.top/ws/miniapp', - // 配对桌面端 device_id(查桌面端 KV app_settings:device_id;填错则握手过但路由无对端) - // 当前填本机桌面端 device_id(联调期硬编码,后续做配对绑定流程) - deviceId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0', + relayHost: 'wss://u-work.1216.top/ws/commander', + // 配对桌面端 channel_id(查桌面端 KV app_settings:channel_id;填错则握手过但路由无对端) + // 当前填本机桌面端 channel_id(联调期硬编码,后续做配对绑定流程) + channelId: '1b92cbbd-d03e-4a0c-b226-9fadf8dcf0f0', token: 'devflow-relay-default-token', heartbeatInterval: 30000, reconnectBaseDelay: 1000, @@ -54,6 +54,23 @@ const STORAGE_KEY = 'df-miniapp-config' /** 单例配置(null 表示尚未从 storage 加载) */ let _config: MiniappConfig | null = null +/** + * 旧版字段迁移(df-relay 一代 → u-relay): + * storage 里的旧 JSON 含 deviceId 字段,映射为 channelId,避免老用户升级后配置丢失。 + */ +function migrateLegacyFields(parsed: Record): Partial { + const out = { ...parsed } as Record + if (out.deviceId !== undefined && out.channelId === undefined) { + out.channelId = out.deviceId + } + delete out.deviceId + // 旧 relayHost 指向已下线的 /ws/miniapp 端点,升级后强制重置为新端点 + if (typeof out.relayHost === 'string' && out.relayHost.includes('/ws/miniapp')) { + out.relayHost = defaultConfig.relayHost + } + return out as Partial +} + /** * 从 storage 读取并合并默认配置 * @@ -64,9 +81,10 @@ function loadFromStorage(): MiniappConfig { try { const raw = uni.getStorageSync(STORAGE_KEY) if (!raw) return { ...defaultConfig } - const parsed = JSON.parse(raw) as Partial - // 合并默认值,避免旧版本字段缺失导致 undefined - return { ...defaultConfig, ...parsed } + const parsed = JSON.parse(raw) as Record + // 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined + const migrated = migrateLegacyFields(parsed) + return { ...defaultConfig, ...migrated } } catch (e) { console.warn('[df-miniapp] load config from storage failed:', e) return { ...defaultConfig } diff --git a/apps/df-miniapp/src/pages/settings/index.vue b/apps/df-miniapp/src/pages/settings/index.vue index e297b3d..3c338f5 100644 --- a/apps/df-miniapp/src/pages/settings/index.vue +++ b/apps/df-miniapp/src/pages/settings/index.vue @@ -6,7 +6,7 @@ import { useAiChat } from '@/composables/useAiChat' /** * 设置页(连接配置查看/编辑 + 连接状态 + 连接操作)。 * - * 默认查看模式(只读展示 relayHost/deviceId/token,token 脱敏);点「编辑」进入编辑模式, + * 默认查看模式(只读展示 relayHost/channelId/token,token 脱敏);点「编辑」进入编辑模式, * 保存时 setConfig 持久化并 resumeIfDisconnected 用新配置重连。连接态可「断开」, * 断开态可「重连」,另展示最近状态详情(wsStatusDetail)辅助排查。 * @@ -82,13 +82,13 @@ function onConnOp(): void { /** 编辑态(false=查看模式,true=编辑模式;草稿保存时才 setConfig,取消丢弃) */ const editing = ref(false) const draftRelayHost = ref('') -const draftDeviceId = ref('') +const draftChannelId = ref('') const draftToken = ref('') /** 进入编辑模式:以当前配置填充草稿 */ function enterEdit(): void { draftRelayHost.value = cfg.value.relayHost - draftDeviceId.value = cfg.value.deviceId + draftChannelId.value = cfg.value.channelId draftToken.value = cfg.value.token editing.value = true } @@ -101,12 +101,12 @@ function cancelEdit(): void { /** 保存编辑:setConfig 持久化 + 提示 + resumeIfDisconnected 用新配置重连 */ function saveEdit(): void { const relayHost = draftRelayHost.value.trim() - const deviceId = draftDeviceId.value.trim() - if (!relayHost || !deviceId) { - uni.showToast({ title: '中继地址和设备 ID 不能为空', icon: 'none' }) + const channelId = draftChannelId.value.trim() + if (!relayHost || !channelId) { + uni.showToast({ title: '中继地址和通道 ID 不能为空', icon: 'none' }) return } - setConfig({ relayHost, deviceId, token: draftToken.value.trim() }) + setConfig({ relayHost, channelId, token: draftToken.value.trim() }) editing.value = false uni.showToast({ title: '已保存,正在重连…', icon: 'none' }) ai.resumeIfDisconnected() @@ -139,7 +139,7 @@ function saveEdit(): void { - + 连接配置 @@ -151,13 +151,13 @@ function saveEdit(): void { 中继地址 - + {{ cfg.relayHost }} - 设备 ID - - {{ cfg.deviceId }} + 通道 ID + + {{ cfg.channelId }} 配对 Token diff --git a/apps/df-miniapp/src/types/relay.ts b/apps/df-miniapp/src/types/relay.ts index a6bce77..9fe954a 100644 --- a/apps/df-miniapp/src/types/relay.ts +++ b/apps/df-miniapp/src/types/relay.ts @@ -1,44 +1,47 @@ /** - * df-relay 协议类型镜像(对齐 Rust crates/df-relay/src/) + * u-relay 协议类型镜像(对齐 E:/wk-lab/u-relay/src/) * * 类型证据: - * - ClientKind / MessageKind:crates/df-relay/src/broadcast.rs:21-41 - * (#[serde(rename_all = "snake_case")] → 'device'/'miniapp', 'event'/'command'/'control') - * - BroadcastMessage:broadcast.rs:48-62(device_id/kind/source/from/payload/ts) - * - Hello 握手帧:crates/df-relay/src/relay.rs:44-53(kind/device_id/token) - * kind 用 ClientKindWire(relay.rs:56-61),序列化为 'device'/'miniapp' - * - 路由端点:relay.rs:177-183 `/ws/miniapp`(小程序专用端点) + * - ClientKind / MessageKind:u-relay/src/broadcast.rs + * (#[serde(rename_all = "snake_case")] → 'agent'/'commander', 'event'/'command'/'control') + * - BroadcastMessage:broadcast.rs(channel_id/kind/source/from/payload/ts) + * - Hello 握手帧:u-relay/src/relay.rs(kind/channel_id/token) + * kind 用 ClientKindWire,序列化为 'agent'/'commander' + * - 路由端点:relay.rs `/ws/agent`(执行端)/ `/ws/commander`(控制端) * - * 关键协议点(relay.rs:332-365 handle_inbound_text): - * relay 不解析 payload —— miniapp 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 device。 + * 关键协议点(relay.rs handle_inbound_text): + * relay 不解析 payload —— commander 入站文本帧直接当 payload 包成 BroadcastMessage 路由给 agent。 * 故 miniapp 发送时,直接序列化 Command JSON(relay 自动包 payload),不需客户端构造 BroadcastMessage。 - * 接收时,device 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage + * 接收时,agent 事件经 relay 包成完整 BroadcastMessage 序列化下发,miniapp 需先解 BroadcastMessage * 再从 payload 取 AiChatEvent。 + * + * 历史注:df-relay 一代协议(kind:'device'/'miniapp' + device_id)已在 u-relay 全部下线, + * 旧名握手期直接拒绝(端点 /ws/miniapp、/ws/device 均已失效)。 */ -/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化,broadcast.rs:21-28) */ -export type ClientKind = 'device' | 'miniapp' +/** 客户端类型(对齐 Rust ClientKind,snake_case 序列化;u-relay 现行仅此两值) */ +export type ClientKind = 'agent' | 'commander' -/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化,broadcast.rs:35-41) */ +/** 消息语义分类(对齐 Rust MessageKind,snake_case 序列化) */ export type MessageKind = 'event' | 'command' | 'control' -/** 在线状态(对齐 Rust PresenceState,snake_case 序列化,broadcast.rs:119-126) */ +/** 在线状态(对齐 Rust PresenceState,snake_case 序列化) */ export type PresenceState = 'online' | 'offline' -/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number */ +/** ConnId 是 Rust u64(对齐 conn.rs ConnId struct),TS 用 number;0 为 NIL 伪值 */ export type ConnId = number /** - * 中继路由消息骨架(对齐 Rust BroadcastMessage,broadcast.rs:48-62) + * 中继路由消息骨架(对齐 Rust BroadcastMessage) * * miniapp 接收时 payload 为 AiChatEvent JSON(relay 透传不解析)。 */ export interface BroadcastMessage { - /** 配对绑定的设备 ID */ - device_id: string + /** 配对绑定的通道 ID */ + channel_id: string /** 消息语义 */ kind: MessageKind - /** 消息来源连接 ID(relay 分配,u64) */ + /** 消息来源连接 ID(relay 分配,u64;0=relay 自构造 NIL 伪值) */ source: ConnId /** 来源客户端类型 */ from: ClientKind @@ -49,36 +52,36 @@ export interface BroadcastMessage { } /** - * 客户端首消息:身份宣告(对齐 Rust Hello,relay.rs:44-53) + * 客户端首消息:身份宣告(对齐 Rust Hello) * - * 小程序连 `/ws/miniapp` 后首帧必须发此结构,否则 relay 关连接(relay.rs:197-208)。 - * kind 恒为 'miniapp'(与端点匹配,relay.rs:212-225 校验,不匹配则关连接)。 + * 小程序连 `/ws/commander` 后首帧必须发此结构(10s 内),否则 relay 关连接。 + * kind 恒为 'commander'(与端点匹配,不匹配 relay 回 kind_mismatch 错误帧 + Close)。 */ export interface Hello { - /** 客户端类型,小程序恒 'miniapp'(对齐 ClientKindWire) */ - kind: 'miniapp' | 'device' - /** 配对绑定的设备 ID(用户首次配置时生成) */ - device_id: string - /** 配对 token(env DF_RELAY_TOKEN,fallback "devflow-relay-default-token") */ + /** 客户端类型,小程序(控制端)恒 'commander'(对齐 ClientKindWire) */ + kind: 'commander' + /** 配对绑定的通道 ID(非空,≤128B,无控制字符) */ + channel_id: string + /** 配对 token(env U_RELAY_TOKEN,与桌面端共享) */ token: string } /** - * 控制面消息(对齐 Rust ControlMessage,broadcast.rs:132-145,tag = "control_kind") + * 控制面消息(对齐 Rust ControlMessage,tag = "control_kind") * - * miniapp 主要发 Heartbeat 心跳 + Ping;Pair 在首次绑定时用(本批未实现绑定流程)。 + * miniapp 主要发 Ping 心跳(relay 本地直回 pong,不经对端);Pair/Heartbeat/Presence 骨架保留。 */ export type ControlMessage = - | { control_kind: 'pair'; device_id: string; token: string } - | { control_kind: 'heartbeat'; device_id: string } - | { control_kind: 'presence'; device_id: string; state: PresenceState } + | { control_kind: 'pair'; channel_id: string; token: string } + | { control_kind: 'heartbeat'; channel_id: string } + | { control_kind: 'presence'; channel_id: string; state: PresenceState } | { control_kind: 'ping' } | { control_kind: 'pong' } /** - * 小程序 → 桌面端 Command 协议(miniapp 发起操作) + * 小程序(控制端)→ 桌面端(执行端)Command 协议 * - * 类型为对齐 Tauri command 名,relay 透传 device_id 路由到桌面端,桌面端 invoke 对应 command。 + * 类型为对齐 Tauri command 名,relay 透传 channel_id 路由到桌面端,桌面端 invoke 对应 command。 * 字段: * - cmd:命令名(对齐 src-tauri command 函数名) * - args:命令参数(对齐各 command 的参数) @@ -227,6 +230,6 @@ export interface ContinueLoopArgs { /** stop_loop 命令参数(对齐 src-tauri ai_stop_loop,Phase3 §2.2 路由表) */ export interface StopLoopArgs { - /** 霷停止循环的会话 id */ + /** 需停止循环的会话 id */ conversation_id: string } diff --git a/crates/df-relay/README.md b/crates/df-relay/README.md index 705c556..7a2df85 100644 --- a/crates/df-relay/README.md +++ b/crates/df-relay/README.md @@ -1,9 +1,26 @@ # df-relay — 已迁移(deprecated) -> **本 crate 已于 2026-08-23 提取为独立产品 [`u-relay`](../u-relay)**(`E:/wk-lab/u-relay`,git filter-repo 保留完整提交历史,脱敏改名 df_\*→u_\*)。 +> **本 crate 已于 2026-08-23 提取为独立产品 [`u-relay`](../../../u-relay)**(`E:/wk-lab/u-relay`,git filter-repo 保留完整提交历史,脱敏改名 df_\*→u_\*)。 - **新功能一律在 u-relay 仓库开发**,本 crate 不再接受功能演进(防双份漂移) -- 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / 小程序 ws.ts)迁移 u-relay 后整体下线(设计 Q7) -- u-relay 协议为 df-relay 超集:hello / device_id / Event / Command 帧原样兼容,迁移只需切 host +- 本目录暂保留:现役生产部署(容器 :9180)仍从 devflow 侧构建,待 devflow 客户端(df-tunnel / df-miniapp ws.ts)迁移 u-relay 后整体下线(设计 Q7) +## ⚠️ 协议兼容性(2026-10-04 更正) + +早期陈述「u-relay 协议为 df-relay 超集,hello/device_id 原样兼容,迁移只需切 host」**已过时**。 +u-relay 在协议正名(agent/commander 第三代)时**已拆除全部兼容层**: + +| 维度 | df-relay(本 crate) | u-relay(现行) | +|---|---|---| +| 端点 | `/ws/device`、`/ws/miniapp` | **`/ws/agent`、`/ws/commander`**(旧端点不存在) | +| Hello.kind | `"device"`、`"miniapp"` | **`"agent"`、`"commander"`**(旧 kind 握手期直接拒绝) | +| 配对键字段 | `device_id` | **`channel_id`**(不再 alias) | +| 错误码 | `invalid_device_id` | `invalid_channel_id` | +| token env | `DF_RELAY_TOKEN` | `U_RELAY_TOKEN` | + +**迁移不是"切 host"而是协议升级**。devflow 侧客户端已于 2026-10-04 完成升级: +- `crates/df-tunnel`(Rust,桌面端 Agent)✅ +- `apps/df-miniapp`(uni-app,Commander:config.ts / ws.ts / types/relay.ts / settings 页 + storage 旧字段迁移)✅ + +协议规格见 `docs/02-架构设计/专项设计/u-relay-wire-protocol-2026-10-04.md`。 设计文档:`C:/Users/23780/docs/u-box-design.md` §3.3 + `u-relay-extract-plan.md` diff --git a/crates/df-tunnel/examples/e2e_u_relay.rs b/crates/df-tunnel/examples/e2e_u_relay.rs new file mode 100644 index 0000000..c565509 --- /dev/null +++ b/crates/df-tunnel/examples/e2e_u_relay.rs @@ -0,0 +1,68 @@ +//! df-tunnel ↔ u-relay 端到端联调(Agent 侧握手 + 存活 + 断开) +//! +//! 起 WsTunnelClient 连本机 u-relay :19180 /ws/agent: +//! 1. Hello{kind:"agent", channel_id, token} → u-relay 立即回 hello_ack(3s PROBE 内收到) +//! 2. send_raw_event 发一条事件(relay 无 commander 在线 → 静默丢弃,不报错) +//! 3. 存活 2s(收发循环稳定,无 panic) +//! 4. 优雅 disconnect +//! +//! 运行:先起 u-relay(U_RELAY_TOKEN=devflow-relay-default-token U_RELAY_ADDR=127.0.0.1:19180), +//! 再 `cargo run -p df-tunnel --example e2e_u_relay`。 + +use std::sync::Arc; +use std::time::Duration; + +use df_tunnel::tunnel::CommandHandler; +use df_tunnel::{TunnelClient, WsTunnelClient}; +use tokio::sync::Mutex; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let url = "ws://127.0.0.1:19180/ws/agent"; + let channel_id = "test-channel-e2e-01"; + let token = "devflow-relay-default-token"; + + let client = Arc::new(WsTunnelClient::new()); + + // 注册 command 回调(收 commander 指令;本测试预计不触发) + let got_cmd = Arc::new(Mutex::new(0usize)); + let counter = got_cmd.clone(); + let on_command: CommandHandler = Arc::new(move |_payload| { + let counter = counter.clone(); + Box::pin(async move { + *counter.lock().await += 1; + }) + }); + + println!("[1] connect {url} channel={channel_id}"); + client + .connect(url, channel_id, token, on_command) + .await + .map_err(|e| anyhow::anyhow!("connect 失败: {e}"))?; + println!(" connect Ok"); + + assert!(client.is_connected(), "is_connected 应为 true"); + println!("[2] is_connected == true ✓(u-relay 已回 hello_ack,握手成功)"); + + // 发一条原始事件(relay 无 commander 在线 → 静默丢弃,不应报错) + client + .send_raw_event(serde_json::json!({"type": "e2e_probe", "note": "df-tunnel e2e"})) + .await + .map_err(|e| anyhow::anyhow!("send_raw_event 失败: {e}"))?; + println!("[3] send_raw_event Ok(事件出站;relay 无对端静默丢弃符合预期)"); + + // 保持 2s 让心跳/收发循环稳定跑(无 panic 即可) + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(client.is_connected(), "2s 后仍应 connected(心跳保活)"); + println!("[4] 2s 存活 ✓"); + + // 优雅断开 + client.disconnect().await?; + assert!(!client.is_connected(), "disconnect 后 is_connected 应为 false"); + println!("[5] disconnect Ok,is_connected == false ✓"); + + let got = *got_cmd.lock().await; + println!("───"); + println!("df-tunnel ↔ u-relay E2E: 5/5 通过(command 收到 {got} 条,预期 0)"); + Ok(()) +} diff --git a/crates/df-tunnel/src/events.rs b/crates/df-tunnel/src/events.rs index af5cba3..046d6fd 100644 --- a/crates/df-tunnel/src/events.rs +++ b/crates/df-tunnel/src/events.rs @@ -11,41 +11,51 @@ use serde::{Deserialize, Serialize}; -/// 握手身份宣告帧(对齐 df-relay::relay::Hello) +/// 握手身份宣告帧(对齐 u-relay::relay::Hello) /// -/// 桌面端 WS 连 `/ws/device` 后,首条消息发此 JSON,relay 校验 kind 与 token。 -/// 字段名/serde rename 与 df-relay 严格一致(`#[serde(rename_all="snake_case")]`), +/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。 +/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`), /// 否则 relay 反序列化失败 → 握手被拒。 /// -/// 注意:本 crate 不依赖 df-relay(避免 lib→server 反向依赖),自行镜像协议结构。 -/// 协议字段如有变更须同步 df-relay::relay::Hello 与本类型。 +/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。 +/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。 +/// +/// 历史:df-relay 一代协议用 device_id + kind:"device",已在 u-relay 现行版全部下线 +/// (端点 /ws/agent,kind:"agent",配对键 channel_id,旧名握手期直接拒绝)。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Hello { - /// 客户端类型("device" / "miniapp"),桌面端恒为 Device + /// 客户端类型("agent" / "commander"),桌面端恒为 Agent pub kind: HelloKind, - /// 配对绑定的设备 ID - pub device_id: String, - /// 配对 token(env DF_RELAY_TOKEN 同源) + /// 配对绑定的通道 ID(非空,≤128B,无控制字符) + pub channel_id: String, + /// 配对 token(env U_RELAY_TOKEN 同源) pub token: String, } -/// Hello.kind 传输表示(serde 字符串,与 df-relay::ClientKindWire 一致) +/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致) +/// +/// 现行仅两值:Agent(执行端,如 df-tunnel)/ Commander(控制端,如 df-miniapp)。 +/// 历史名 Device/Miniapp/Runner/Console/Executor/Controller 已全部下线。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HelloKind { - Device, - Miniapp, + Agent, + Commander, } /// relay 控制面错误帧(握手失败时 relay 发送) /// /// relay 在校验失败/连接异常时发 `{"kind":"control","error":""}` 文本帧。 /// 桌面端据此识别握手失败原因(不依赖 error 帧亦可:连接被 Close 即视为失败)。 +/// +/// 现行 u-relay 错误码:handshake_failed / kind_mismatch / invalid_channel_id / +/// server_misconfigured / auth_failed。历史 `invalid_device_id` 已下线。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayControlError { /// 固定 "control"(用于与正常 BroadcastMessage 区分,relay 错误帧简化为两字段) pub kind: String, - /// 错误原因(handshake_failed / kind_mismatch / auth_failed) + /// 错误原因(handshake_failed / kind_mismatch / invalid_channel_id / + /// server_misconfigured / auth_failed) pub error: String, } @@ -112,10 +122,11 @@ impl TunnelEvent { } } -/// 云后端 → 桌面端的操作指令(小程序远程操作映射) +/// 云后端 → 桌面端的操作指令(控制端远程操作映射) /// /// 对齐设计文档「Layer1 指令路由」: /// send/stop/approve/regenerate/switch 五类操作。 +/// 由控制端(df-miniapp 等 Commander)发出,经 u-relay 路由到本桌面端 Agent。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TunnelCommand { @@ -133,8 +144,8 @@ pub enum TunnelCommand { /// 隧道传输的消息包装(双向) /// -/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent) -/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。 +/// 区分「桌面端 → u-relay → 控制端」的事件流(TunnelEvent) +/// 与「控制端 → u-relay → 桌面端」的指令流(TunnelCommand)。 /// 同一 WS 连接复用,靠 kind 标签区分。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "dir", rename_all = "snake_case")] diff --git a/crates/df-tunnel/src/lib.rs b/crates/df-tunnel/src/lib.rs index 78a9b89..091f83f 100644 --- a/crates/df-tunnel/src/lib.rs +++ b/crates/df-tunnel/src/lib.rs @@ -3,9 +3,9 @@ //! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。 //! //! ## 职责 -//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射) -//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序 -//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command +//! - 主动连接云后端 u-relay(出站穿 NAT,无需端口映射) +//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 控制端(df-miniapp 等) +//! - 指令路由:接收控制端远程操作 → 调对应 Tauri command //! //! ## 边界 //! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制) diff --git a/crates/df-tunnel/src/tunnel.rs b/crates/df-tunnel/src/tunnel.rs index 25b750d..1fac154 100644 --- a/crates/df-tunnel/src/tunnel.rs +++ b/crates/df-tunnel/src/tunnel.rs @@ -1,18 +1,20 @@ -//! df-tunnel 隧道客户端(Phase2 完整实现) +//! df-tunnel 隧道客户端(Phase2 完整实现,2026-10-04 切换 u-relay 协议) //! -//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`, +//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/agent`, //! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。 //! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处): //! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。 //! -//! ## 协议对齐(df-relay Phase2 commit 2b8b30e) -//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }` -//! - relay 双因子校验(kind == expected_device + token 匹配),失败发 -//! `{"kind":"control","error":"..."}` + Close,成功则静默进入收发循环(无显式 ack) -//! - 握手成功信号:**未收到 error 帧 且 socket 保持打开**(connect 用 timeout 探测首帧) -//! - 入站消息:relay 把对端 Miniapp 的文本帧包成 BroadcastMessage 转发,桌面端收到的 -//! 是完整 BroadcastMessage JSON,payload 是 miniapp 指令原样 JSON(Phase3 纯透传,不解析) -//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 BroadcastMessage) +//! ## 协议对齐(u-relay 现行,2026-10-04 起) +//! - 桌面端连 `/ws/agent`,首帧发 `Hello { kind:"agent", channel_id, token }` +//! - relay 双因子校验(kind == agent + token 匹配 + channel_id 格式校验), +//! 失败发 `{"kind":"control","error":"..."}` + Close, +//! 成功立即回 `{"kind":"control","payload":{"control_kind":"hello_ack"}}` 显式 ack +//! - 握手成功信号:**收到 hello_ack 控制帧 或 收到任何非 error 首帧**(u-relay 有显式 ack) +//! - 入站消息:relay 把对端 Commander 的文本帧包成 BroadcastMessage 转发,桌面端收到的 +//! 是完整 BroadcastMessage JSON,payload 是 commander 指令原样 JSON(纯透传,不解析) +//! - 出站消息:桌面端发 TunnelEvent 的 JSON(relay 把它当 payload 包成 Event 广播) +//! - 心跳:relay 本地回 pong(`{"control_kind":"ping"}` → pong 不经对端),路径短延迟低 //! //! ## AiChatEvent 透传原则 //! payload 用 serde_json::Value 透传,本 crate 不依赖 src-tauri/df-types(避跨 crate 强耦合)。 @@ -21,7 +23,7 @@ //! ## 并发模型 //! 内部 spawn 收发循环 task,通过 mpsc 解耦 send_event(调用方)与 socket 写入。 //! 收到 command 方向 payload(`serde_json::Value`)走 on_command 回调(调用方在 connect 时注册),不阻塞收发循环。 -//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 device 端桥接层(src-tauri)。 +//! Phase3 协议统一(D1=A):tunnel 纯透传,业务协议解析在 agent 端桥接层(src-tauri)。 //! `&self` + `Arc>` 持连接状态,trait 方法全部 async + 不持 stream 借用。 use std::sync::atomic::{AtomicBool, Ordering}; @@ -38,7 +40,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use crate::error::{Result, TunnelError}; use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent}; -/// 收到 miniapp→device 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await) +/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await) /// /// Phase3 协议统一(D1=A):tunnel 纯透传,不再反序列化 TunnelCommand 强类型, /// payload 以 `serde_json::Value` 上抛,业务协议解析在 device 端桥接层(src-tauri)。 @@ -61,14 +63,14 @@ pub type CommandHandler = Arc futures_util::future: pub trait TunnelClient: Send + Sync { /// 主动连接云后端 + 注册指令回调 /// - /// `url` 形如 `wss://host/ws/device`(query 不带 token,token 走 Hello 帧)。 + /// `url` 形如 `wss://host/ws/agent`(query 不带 token,token 走 Hello 帧)。 /// 成功后进入「已连接」状态并 spawn 收发循环。失败返回 TunnelError::Connect/Auth。 /// `on_command` 收到云后端转发的 command 方向 payload Value 时被调用(在收发 task 内异步执行)。 - /// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 device 端桥接层 match cmd 路由。 + /// Phase3 纯透传(D1=A):payload 不在 tunnel 解析,由 agent 端桥接层 match cmd 路由。 async fn connect( &self, url: &str, - device_id: &str, + channel_id: &str, token: &str, on_command: CommandHandler, ) -> Result<()>; @@ -79,12 +81,12 @@ pub trait TunnelClient: Send + Sync { /// 是否处于已连接状态(轻量查询,不阻塞,不发心跳) fn is_connected(&self) -> bool; - /// 发送事件(桌面端 → 云后端 → 小程序) + /// 发送事件(桌面端 → 云后端 → 控制端) /// /// 非阻塞入队,实际 socket 写入异步完成。连接断开时返回 NotConnected。 async fn send_event(&self, event: TunnelEvent) -> Result<()>; - /// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value + /// 发送原始事件(桌面端 → 云后端 → 控制端),payload 为 AiChatEvent 序列化 JSON Value /// /// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法 /// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行, @@ -92,7 +94,7 @@ pub trait TunnelClient: Send + Sync { async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>; } -/// 握手探测窗口(relay 校验通过后静默进入收发循环,无 ack 帧; +/// 握手探测窗口(u-relay 校验通过会立即回 hello_ack 控制帧; /// 此窗口内若收到 error 帧或连接 Close,判定握手失败) const HANDSHAKE_PROBE: Duration = Duration::from_secs(3); @@ -104,6 +106,7 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3); const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); /// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) +/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); /// 重连初始退避 @@ -136,7 +139,7 @@ pub struct WsTunnelClient { /// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询 /// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。 server_url: Mutex>, - device_id: Mutex>, + channel_id: Mutex>, token: Mutex>, /// 当前活跃连接状态(None = 未连接) conn: Mutex>, @@ -150,19 +153,19 @@ impl WsTunnelClient { pub fn new() -> Self { Self { server_url: Mutex::new(None), - device_id: Mutex::new(None), + channel_id: Mutex::new(None), token: Mutex::new(None), conn: Mutex::new(None), connected: Arc::new(AtomicBool::new(false)), } } - /// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连复用) + /// 当前已连接时取一份重连参数(URL/channel_id/token,供调用方 supervisor 断线重连复用) pub async fn reconnect_params(&self) -> Option<(String, String, String)> { let url = self.server_url.lock().await.clone()?; - let device_id = self.device_id.lock().await.clone()?; + let channel_id = self.channel_id.lock().await.clone()?; let token = self.token.lock().await.clone()?; - Some((url, device_id, token)) + Some((url, channel_id, token)) } } @@ -177,7 +180,7 @@ impl TunnelClient for WsTunnelClient { async fn connect( &self, url: &str, - device_id: &str, + channel_id: &str, token: &str, on_command: CommandHandler, ) -> Result<()> { @@ -204,8 +207,8 @@ impl TunnelClient for WsTunnelClient { // 2. 发 Hello 握手帧 let hello = Hello { - kind: HelloKind::Device, - device_id: device_id.to_string(), + kind: HelloKind::Agent, + channel_id: channel_id.to_string(), token: token.to_string(), }; let hello_json = serde_json::to_string(&hello)?; @@ -216,13 +219,14 @@ impl TunnelClient for WsTunnelClient { // 3. 探测握手结果:HANDSHAKE_PROBE 窗口内 // - 收到 error 帧 → relay 拒绝(返回 Auth 错误,附原因) // - 收到 Close / EOF → 连接被关(返回 Connect 错误) - // - 收到首条正常消息 / 超时无负帧 → 视为握手成功(relay 静默进入收发循环) + // - 收到 hello_ack / 首条正常消息 / 超时无负帧 → 视为握手成功(u-relay 显式 ack) // - Ping/Pong 协议帧:忽略继续等 let mut first_frame_pending: Option = None; let probe = tokio::time::timeout(HANDSHAKE_PROBE, stream.next()).await; match probe { Err(_) => { - // 超时无负帧:relay 静默通过(常见路径) + // 超时无负帧:hello_ack 错过探测窗(可能性低)或 relay 异常, + // 仍按成功处理(后续帧会进 run_loop 正常分派) tracing::debug!("握手探测窗口无负帧,视为成功"); } Ok(None) => { @@ -253,7 +257,7 @@ impl TunnelClient for WsTunnelClient { // 4. 握手成功:保存参数 + 建发送队列 + spawn 收发循环 *self.server_url.lock().await = Some(url.to_string()); - *self.device_id.lock().await = Some(device_id.to_string()); + *self.channel_id.lock().await = Some(channel_id.to_string()); *self.token.lock().await = Some(token.to_string()); let (tx, rx) = mpsc::unbounded_channel::(); @@ -271,7 +275,7 @@ impl TunnelClient for WsTunnelClient { )); *self.conn.lock().await = Some(ConnState { tx, task }); - tracing::info!(%url, device_id, "WsTunnelClient 连接就绪,收发循环已启动"); + tracing::info!(%url, channel_id, "WsTunnelClient 连接就绪,收发循环已启动"); Ok(()) } @@ -332,7 +336,7 @@ impl WsTunnelClient { } } *self.server_url.lock().await = None; - *self.device_id.lock().await = None; + *self.channel_id.lock().await = None; *self.token.lock().await = None; self.connected.store(false, Ordering::Relaxed); }