优化: 跨端链路协议对齐

- miniapp 配置域旧端点强制重置
- df-tunnel 隧道协议与 relay 现网对齐
- relay e2e 例程入 examples
This commit is contained in:
lxy
2026-10-04 16:27:31 +08:00
parent 7aa0da32f2
commit d9e0ce73d1
9 changed files with 248 additions and 126 deletions
+10 -9
View File
@@ -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 {
+35 -17
View File
@@ -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<string, unknown>): Partial<MiniappConfig> {
const out = { ...parsed } as Record<string, unknown>
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<MiniappConfig>
}
/**
* 从 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<MiniappConfig>
// 合并默认值,避免旧版本字段缺失导致 undefined
return { ...defaultConfig, ...parsed }
const parsed = JSON.parse(raw) as Record<string, unknown>
// 合并默认值 + 旧字段迁移,避免旧版本字段缺失/更名导致 undefined
const migrated = migrateLegacyFields(parsed)
return { ...defaultConfig, ...migrated }
} catch (e) {
console.warn('[df-miniapp] load config from storage failed:', e)
return { ...defaultConfig }
+12 -12
View File
@@ -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 {
</view>
</view>
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/deviceId/token) -->
<!-- 连接配置(查看/编辑双模式:编辑可改 relayHost/channelId/token) -->
<view class="card">
<view class="card-head">
<text class="card-title">连接配置</text>
@@ -151,13 +151,13 @@ function saveEdit(): void {
</view>
<view class="row">
<text class="row-label">中继地址</text>
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/miniapp" placeholder-class="row-ph" />
<input v-if="editing" v-model="draftRelayHost" class="row-input" placeholder="ws://…/ws/commander" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.relayHost }}</text>
</view>
<view class="row">
<text class="row-label">设备 ID</text>
<input v-if="editing" v-model="draftDeviceId" class="row-input" placeholder="桌面端设备 ID" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.deviceId }}</text>
<text class="row-label">通道 ID</text>
<input v-if="editing" v-model="draftChannelId" class="row-input" placeholder="u-relay 通道 ID" placeholder-class="row-ph" />
<text v-else class="row-value mono">{{ cfg.channelId }}</text>
</view>
<view class="row">
<text class="row-label">配对 Token</text>
+38 -35
View File
@@ -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
}
+20 -3
View File
@@ -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`
+68
View File
@@ -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(())
}
+27 -16
View File
@@ -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":"<reason>"}` 文本帧。
/// 桌面端据此识别握手失败原因(不依赖 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")]
+3 -3
View File
@@ -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 引用二进制)
+35 -31
View File
@@ -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<Mutex<...>>` 持连接状态,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<dyn Fn(serde_json::Value) -> 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<Option<String>>,
device_id: Mutex<Option<String>>,
channel_id: Mutex<Option<String>>,
token: Mutex<Option<String>>,
/// 当前活跃连接状态(None = 未连接)
conn: Mutex<Option<ConnState>>,
@@ -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<WsMessage> = 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::<OutFrame>();
@@ -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);
}