新增: 跨端WS隧道脚手架(df-tunnel客户端+df-relay云中继)

df-tunnel:桌面端出站WS客户端(TunnelClient trait+骨架,穿NAT连云后端)
df-relay:axum WS中继服务(转发小程序↔桌面端,广播骨架)
双crate独立消息骨架,不依赖src-tauri/df-types避跨crate强耦合
workspace crates/* glob自动注册,Phase2填充WS握手/鉴权/重连/路由
This commit is contained in:
2026-06-22 01:24:01 +08:00
parent 566bbcb394
commit 5d1749a58f
11 changed files with 818 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "df-tunnel"
version = "0.1.0"
edition = "2021"
description = "本地 DevFlow 桌面端出站 WS 隧道客户端(穿 NAT 连云后端 df-relay)"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# WS 客户端(tungstenite 底层 + tokio 异步封装)
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"

View File

@@ -0,0 +1,43 @@
//! df-tunnel 错误类型
//!
//! 设计:单一 TunnelError 覆盖 WS 连接/收发/序列化/鉴权四类失败,
//! thiserror 派生保留错误链(`#[from]` 自动 From)。
//! 脚手架阶段仅定义类型,不涉及具体错误码细分(Phase2 填充)。
use thiserror::Error;
/// 隧道客户端运行期错误
///
/// 覆盖范围:
/// - 连接建立失败(网络不可达 / TLS 握手 / 鉴权拒绝)
/// - 收发失败(底层 WS 帧 / 序列化 / 反序列化)
/// - 协议层错误(未预期的消息类型 / 配对绑定失败)
#[derive(Debug, Error)]
pub enum TunnelError {
/// WS 连接建立或断线重连失败
#[error("隧道连接失败: {0}")]
Connect(String),
/// 底层 tungstenite WS 协议错误
#[error("WS 协议错误: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
/// 消息序列化/反序列化失败
#[error("消息序列化失败: {0}")]
Serde(#[from] serde_json::Error),
/// 云后端鉴权/配对拒绝(token 无效 / device 未绑定)
#[error("鉴权/配对失败: {0}")]
Auth(String),
/// 隧道未连接(对 send/recv 的前置状态校验失败)
#[error("隧道未连接")]
NotConnected,
/// 其他未归类错误(降级通道,Phase2 视需要细分)
#[error("隧道内部错误: {0}")]
Other(#[from] anyhow::Error),
}
/// 模块级 Result 别名,简化调用方签名
pub type Result<T> = std::result::Result<T, TunnelError>;

View File

@@ -0,0 +1,108 @@
//! df-tunnel 隧道事件类型
//!
//! 设计依据:设计文档「三、技术选型」指出 AiChatEvent 17 变体在两端同语言保证类型一致。
//! 但 AiChatEvent 定义在 src-tauri 非 crate 层,跨 crate path 引用会破坏分层边界
//! (lib crate 依赖 src-tauri 二进制不合理)。故此处**独立定义隧道透传子集**:
//! - 仅保留跨端高频透传的 4 个变体(TextDelta/ToolCall/Approval/Completed),
//! Phase2 可按需追加,不依赖 src-tauri。
//! - 携带 conv_id 字段做多会话路由(对齐 F-09 多会话 conv_id 契约)。
//!
//! 向后兼容:新增变体只加不减,旧消费方忽略未知变体(serde 配 `#[serde(other)]` 捕获兜底)。
use serde::{Deserialize, Serialize};
/// 隧道透传的事件类型(AiChatEvent 简化子集)
///
/// 变体语义对齐桌面端 ai-chat-event:
/// - `TextDelta`:流式增量文本块
/// - `ToolCall`:工具调用请求(含工具名/参数)
/// - `Approval`:审批请求(用户授权确认)
/// - `Completed`:单轮对话完成
///
/// Phase2 可扩展 Error/Stopped/Regenerated 等变体。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelEvent {
/// 流式文本增量
TextDelta {
/// 会话 ID(多会话路由用,对齐 F-09)
conv_id: String,
/// 增量文本片段
delta: String,
},
/// 工具调用请求
ToolCall {
/// 会话 ID
conv_id: String,
/// 工具调用唯一标识(用于回填结果)
call_id: String,
/// 工具名
name: String,
/// 工具参数(原始 JSON,由消费方按 name 解析)
arguments: serde_json::Value,
},
/// 审批请求(工具/操作需用户授权)
Approval {
/// 会话 ID
conv_id: String,
/// 审批项唯一标识
approval_id: String,
/// 审批描述(展示给用户)
description: String,
},
/// 对话单轮完成
Completed {
/// 会话 ID
conv_id: String,
/// 本轮产生的消息 ID
message_id: String,
},
}
impl TunnelEvent {
/// 取事件所属会话 ID(路由分发的便利方法)
pub fn conv_id(&self) -> &str {
match self {
TunnelEvent::TextDelta { conv_id, .. }
| TunnelEvent::ToolCall { conv_id, .. }
| TunnelEvent::Approval { conv_id, .. }
| TunnelEvent::Completed { conv_id, .. } => conv_id,
}
}
}
/// 云后端 → 桌面端的操作指令(小程序远程操作映射)
///
/// 对齐设计文档「Layer1 指令路由」:
/// send/stop/approve/regenerate/switch 五类操作。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelCommand {
/// 发送消息
Send { conv_id: String, content: String },
/// 停止当前生成
Stop { conv_id: String },
/// 审批通过/拒绝(approval_id + accepted)
Approve { conv_id: String, approval_id: String, accepted: bool },
/// 重新生成
Regenerate { conv_id: String },
/// 切换对话
Switch { conv_id: String },
}
/// 隧道传输的消息包装(双向)
///
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent)
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。
/// 同一 WS 连接复用,靠 kind 标签区分。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "dir", rename_all = "snake_case")]
pub enum TunnelMessage {
/// 桌面端发出的事件(下行)
Event(TunnelEvent),
/// 云后端转发的指令(上行,发往桌面端)
Command(TunnelCommand),
}

View File

@@ -0,0 +1,26 @@
//! # df-tunnel
//!
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
//!
//! ## 职责
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射)
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command
//!
//! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
//!
//! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型
//! - [`tunnel`]:TunnelClient trait + WsTunnelClient 骨架
//! - [`error`]:TunnelError 错误类型
pub mod error;
pub mod events;
pub mod tunnel;
// 顶层再导出常用项,简化调用方 use
pub use error::{Result, TunnelError};
pub use events::{TunnelCommand, TunnelEvent, TunnelMessage};
pub use tunnel::{TunnelClient, WsTunnelClient};

View File

@@ -0,0 +1,114 @@
//! df-tunnel 隧道客户端
//!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://your-server/ws/device`,
//! 穿 NAT 无需端口映射。本模块定义 `TunnelClient` trait + tokio-tungstenite WS 连接骨架。
//!
//! 脚手架范围:trait 定义 + 连接骨架(占位实现),Phase2 填:
//! - 实际 WS 握手 + 鉴权(token / device_id)
//! - 断线指数退避重连
//! - 事件桥接(app.emit 同步 clone 推隧道)
//! - 指令路由(收到 TunnelCommand 调 Tauri command)
use async_trait::async_trait;
use crate::error::Result;
use crate::events::{TunnelCommand, TunnelEvent};
/// 隧道客户端抽象
///
/// 设计为 trait 而非具体结构,便于:
/// 1. 测试用 mock 实现(不依赖真实 WS)
/// 2. 未来替换底层实现(如换 sse / quic)不改调用方
///
/// 所有方法 async + 返回 Result<T>,失败显式上抛不吞错。
#[async_trait]
pub trait TunnelClient: Send + Sync {
/// 主动连接云后端(出站长连接,穿 NAT)
///
/// `url` 形如 `wss://your-server/ws/device?token=xxx`。
/// 成功后进入「已连接」状态,失败返回 TunnelError::Connect。
async fn connect(&self, url: &str) -> Result<()>;
/// 主动断开(优雅关闭 WS,释放底层 TCP)
async fn disconnect(&self) -> Result<()>;
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
fn is_connected(&self) -> bool;
/// 发送事件(桌面端 → 云后端 → 小程序)
///
/// 设计为按事件粒度发送,不聚合;调用方在 app.emit 的同时 clone 推隧道。
async fn send_event(&self, event: TunnelEvent) -> Result<()>;
/// 接收指令(云后端 → 桌面端)
///
/// 阻塞等待下一条指令。返回 None 表示连接已关闭(EOF)。
async fn recv_command(&self) -> Result<Option<TunnelCommand>>;
}
/// 默认 WS 隧道客户端骨架(tokio-tungstenite)
///
/// 脚手架阶段:字段占位,方法返回 `TunnelError::Other("未实现")`。
/// Phase2 替换为完整实现:
/// - `WebSocketStream<MaybeTlsStream<TcpStream>>` 持有连接
/// - `mpsc` 通道解耦 send/recv 任务
/// - 鉴权握手 + 心跳 + 重连
pub struct WsTunnelClient {
/// 目标服务器 URL(连接后保存,重连用)
#[allow(dead_code)]
server_url: Option<String>,
/// 连接状态标志(轻量 is_connected 查询用)
#[allow(dead_code)]
connected: bool,
}
impl WsTunnelClient {
/// 创建未连接的客户端实例
pub fn new() -> Self {
Self {
server_url: None,
connected: false,
}
}
}
impl Default for WsTunnelClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TunnelClient for WsTunnelClient {
async fn connect(&self, url: &str) -> Result<()> {
// 脚手架占位:Phase2 实现 tokio_tungstenite::connect_async + 鉴权握手。
// 此处仅记录意图,返回未实现错误(不静默假成功,避免掩盖调用方逻辑)。
tracing::warn!(url = %url, "WsTunnelClient.connect 尚未实现(Phase2 填充)");
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.connect 尚未实现(Phase2)"
)))
}
async fn disconnect(&self) -> Result<()> {
tracing::warn!("WsTunnelClient.disconnect 尚未实现(Phase2 填充)");
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.disconnect 尚未实现(Phase2)"
)))
}
fn is_connected(&self) -> bool {
self.connected
}
async fn send_event(&self, _event: TunnelEvent) -> Result<()> {
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.send_event 尚未实现(Phase2)"
)))
}
async fn recv_command(&self) -> Result<Option<TunnelCommand>> {
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.recv_command 尚未实现(Phase2)"
)))
}
}