新增: 跨端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:
18
crates/df-tunnel/Cargo.toml
Normal file
18
crates/df-tunnel/Cargo.toml
Normal 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"
|
||||
43
crates/df-tunnel/src/error.rs
Normal file
43
crates/df-tunnel/src/error.rs
Normal 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>;
|
||||
108
crates/df-tunnel/src/events.rs
Normal file
108
crates/df-tunnel/src/events.rs
Normal 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),
|
||||
}
|
||||
26
crates/df-tunnel/src/lib.rs
Normal file
26
crates/df-tunnel/src/lib.rs
Normal 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};
|
||||
114
crates/df-tunnel/src/tunnel.rs
Normal file
114
crates/df-tunnel/src/tunnel.rs
Normal 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)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user