From 5d1749a58fe811227f9cb73699524d428d959f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 22 Jun 2026 01:24:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E8=B7=A8=E7=AB=AFWS?= =?UTF-8?q?=E9=9A=A7=E9=81=93=E8=84=9A=E6=89=8B=E6=9E=B6(df-tunnel?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF+df-relay=E4=BA=91=E4=B8=AD=E7=BB=A7?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit df-tunnel:桌面端出站WS客户端(TunnelClient trait+骨架,穿NAT连云后端) df-relay:axum WS中继服务(转发小程序↔桌面端,广播骨架) 双crate独立消息骨架,不依赖src-tauri/df-types避跨crate强耦合 workspace crates/* glob自动注册,Phase2填充WS握手/鉴权/重连/路由 --- Cargo.lock | 184 +++++++++++++++++++++++++++++++ crates/df-relay/Cargo.toml | 19 ++++ crates/df-relay/src/broadcast.rs | 85 ++++++++++++++ crates/df-relay/src/error.rs | 37 +++++++ crates/df-relay/src/lib.rs | 26 +++++ crates/df-relay/src/relay.rs | 158 ++++++++++++++++++++++++++ crates/df-tunnel/Cargo.toml | 18 +++ crates/df-tunnel/src/error.rs | 43 ++++++++ crates/df-tunnel/src/events.rs | 108 ++++++++++++++++++ crates/df-tunnel/src/lib.rs | 26 +++++ crates/df-tunnel/src/tunnel.rs | 114 +++++++++++++++++++ 11 files changed, 818 insertions(+) create mode 100644 crates/df-relay/Cargo.toml create mode 100644 crates/df-relay/src/broadcast.rs create mode 100644 crates/df-relay/src/error.rs create mode 100644 crates/df-relay/src/lib.rs create mode 100644 crates/df-relay/src/relay.rs create mode 100644 crates/df-tunnel/Cargo.toml create mode 100644 crates/df-tunnel/src/error.rs create mode 100644 crates/df-tunnel/src/events.rs create mode 100644 crates/df-tunnel/src/lib.rs create mode 100644 crates/df-tunnel/src/tunnel.rs diff --git a/Cargo.lock b/Cargo.lock index 2007f7e..b8fb7cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,64 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.24.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -721,6 +779,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbus" version = "0.9.11" @@ -897,6 +961,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "df-relay" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "futures-util", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.23.1", + "tracing", +] + [[package]] name = "df-storage" version = "0.1.0" @@ -912,6 +992,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "df-tunnel" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures-util", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.23.1", + "tracing", +] + [[package]] name = "df-types" version = "0.1.0" @@ -1842,6 +1937,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -1856,6 +1957,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2382,6 +2484,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.1" @@ -3750,6 +3858,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4640,6 +4759,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.23.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4786,6 +4931,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4829,6 +4975,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4882,6 +5029,43 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/crates/df-relay/Cargo.toml b/crates/df-relay/Cargo.toml new file mode 100644 index 0000000..5f16a96 --- /dev/null +++ b/crates/df-relay/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "df-relay" +version = "0.1.0" +edition = "2021" +description = "跨端 AI Chat 云中继服务(axum WS Server,转发小程序 ↔ 桌面端)" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } + +# HTTP + WS 服务端 +axum = { version = "0.7", features = ["ws"] } +tokio-tungstenite = { version = "0.23", features = ["native-tls"] } +futures-util = "0.3" diff --git a/crates/df-relay/src/broadcast.rs b/crates/df-relay/src/broadcast.rs new file mode 100644 index 0000000..e1e8d83 --- /dev/null +++ b/crates/df-relay/src/broadcast.rs @@ -0,0 +1,85 @@ +//! df-relay 广播消息骨架 +//! +//! 设计依据:设计文档「Layer2」—— 云后端纯转发,无业务逻辑。 +//! 中继消息骨架定义「谁发给谁」的路由语义: +//! - `DeviceEvent`:桌面端事件 → 广播给该 device 绑定的小程序 +//! - `MiniappCommand`:小程序指令 → 转发给该 device 绑定的桌面端 +//! - `Control`:控制面消息(配对/心跳/在线状态) +//! +//! 与 df-tunnel::TunnelMessage 区分: +//! - TunnelMessage 是隧道两端(桌面↔云)的传输单元 +//! - BroadcastMessage 是云后端内部的路由单元(标识来源/去向/广播范围) +//! +//! 脚手架阶段仅定义 enum,Phase2 填路由表 + 广播实现。 + +use serde::{Deserialize, Serialize}; + +/// 客户端类型(用于路由判断) +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ClientKind { + /// 桌面端(出站长连接,事件真相源) + Device, + /// 小程序(远程操作发起方) + Miniapp, +} + +/// 中继路由消息骨架 +/// +/// 每条消息携带 `device_id`(配对绑定键),云后端按 device_id +/// 找到绑定的对端连接进行转发/广播。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BroadcastMessage { + /// 配对绑定的设备 ID(桌面端首次配置时生成) + pub device_id: String, + /// 消息来源(用于鉴权校验:device 不能发 MiniappCommand) + pub from: ClientKind, + /// 消息载荷(原始 JSON,转发时不解析业务字段,保持轻量) + pub payload: serde_json::Value, + /// 时间戳(毫秒,用于冲突处理 —— 桌面端为真相源,但保留时间戳辅助判断) + pub ts: i64, +} + +impl BroadcastMessage { + /// 便捷构造:device 发出的事件 + pub fn from_device(device_id: impl Into, payload: serde_json::Value, ts: i64) -> Self { + Self { + device_id: device_id.into(), + from: ClientKind::Device, + payload, + ts, + } + } + + /// 便捷构造:小程序发出的指令 + pub fn from_miniapp(device_id: impl Into, payload: serde_json::Value, ts: i64) -> Self { + Self { + device_id: device_id.into(), + from: ClientKind::Miniapp, + payload, + ts, + } + } +} + +/// 客户端在线状态(用于离线降级提示) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PresenceState { + /// 在线 + Online, + /// 离线(对端可缓存指令待重连) + Offline, +} + +/// 控制面消息(配对绑定 / 心跳 / 在线状态广播) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ControlMessage { + /// 配对绑定请求(小程序持 token 申请绑定 device_id) + Pair { device_id: String, token: String }, + /// 心跳(保活 + 在线确认) + Heartbeat { device_id: String }, + /// 在线状态变更通知 + Presence { device_id: String, state: PresenceState }, +} diff --git a/crates/df-relay/src/error.rs b/crates/df-relay/src/error.rs new file mode 100644 index 0000000..ab49736 --- /dev/null +++ b/crates/df-relay/src/error.rs @@ -0,0 +1,37 @@ +//! df-relay 错误类型 +//! +//! 设计:单一 RelayError 覆盖服务启动/客户端管理/广播/WS 协议四类失败, +//! thiserror 派生保留错误链。脚手架阶段仅定义类型。 + +use thiserror::Error; + +/// 中继服务运行期错误 +#[derive(Debug, Error)] +pub enum RelayError { + /// 服务启动失败(端口占用 / 绑定失败) + #[error("中继服务启动失败: {0}")] + Start(String), + + /// 客户端管理错误(配对失败 / 重复连接 / 鉴权拒绝) + #[error("客户端管理错误: {0}")] + Client(String), + + /// 广播失败(无可用连接 / 发送失败) + #[error("广播失败: {0}")] + Broadcast(String), + + /// WS 协议层错误(底层 tungstenite) + #[error("WS 协议错误: {0}")] + WebSocket(String), + + /// 序列化/反序列化失败 + #[error("消息序列化失败: {0}")] + Serde(#[from] serde_json::Error), + + /// 其他未归类错误(降级通道) + #[error("中继内部错误: {0}")] + Other(#[from] anyhow::Error), +} + +/// 模块级 Result 别名 +pub type Result = std::result::Result; diff --git a/crates/df-relay/src/lib.rs b/crates/df-relay/src/lib.rs new file mode 100644 index 0000000..0c0c13c --- /dev/null +++ b/crates/df-relay/src/lib.rs @@ -0,0 +1,26 @@ +//! # df-relay +//! +//! 跨端 AI Chat 云中继服务(设计文档 Layer2)。 +//! +//! ## 职责 +//! - axum WS Server 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对) +//! - 广播中继:小程序操作 → 桌面端;桌面端事件 → 小程序 +//! - 纯转发,无业务逻辑(保持轻量,设计文档明确) +//! +//! ## 边界 +//! - 不依赖 df-tunnel / df-types / src-tauri(独立消息骨架,避免跨 crate 强耦合) +//! - 不含业务逻辑(Phase2 填连接生命周期 / 注册表 / 广播分发) +//! +//! ## 模块 +//! - [`broadcast`]:广播消息骨架(BroadcastMessage / ClientKind / ControlMessage) +//! - [`relay`]:RelayServer trait + DefaultRelayServer 骨架 + axum WS 路由 +//! - [`error`]:RelayError 错误类型 + +pub mod broadcast; +pub mod error; +pub mod relay; + +// 顶层再导出常用项 +pub use broadcast::{BroadcastMessage, ClientKind, ControlMessage, PresenceState}; +pub use error::{RelayError, Result}; +pub use relay::{build_router, DefaultRelayServer, RelayServer, RelayState}; diff --git a/crates/df-relay/src/relay.rs b/crates/df-relay/src/relay.rs new file mode 100644 index 0000000..f4a6701 --- /dev/null +++ b/crates/df-relay/src/relay.rs @@ -0,0 +1,158 @@ +//! df-relay 中继服务骨架 +//! +//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。 +//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)。 +//! 纯转发,无业务逻辑。 +//! +//! 脚手架范围:RelayServer trait + axum WS 路由骨架(占位 handler), +//! Phase2 填: +//! - 客户端注册表(HashMap) +//! - 广播分发(按 device_id 路由) +//! - 鉴权中间件 + +use async_trait::async_trait; +use axum::{ + extract::{ + ws::{WebSocket, WebSocketUpgrade}, + State, + }, + response::IntoResponse, + routing::get, + Router, +}; +use std::sync::Arc; + +use crate::broadcast::BroadcastMessage; +use crate::error::Result; + +/// 中继服务抽象 +/// +/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。 +#[async_trait] +pub trait RelayServer: Send + Sync { + /// 启动 HTTP/WS 服务监听指定地址 + async fn start(&self, addr: &str) -> Result<()>; + + /// 广播消息给指定 device_id 绑定的对端 + async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>; + + /// 注册新连接(连接建立后入表) + async fn register_client(&self, device_id: String) -> Result<()>; + + /// 注销连接(断开后出表) + async fn unregister_client(&self, device_id: String) -> Result<()>; + + /// 查询 device 是否有在线连接(离线降级判断用) + fn is_device_online(&self, device_id: &str) -> bool; +} + +/// 共享状态(axum State 传递) +/// +/// 脚手架占位:Phase2 填 tokio::sync::RwLock>。 +/// 当前用 unit 占位以满足 Router state 约束。 +#[derive(Clone, Default)] +pub struct RelayState { + /// Phase2:客户端注册表 + /// 例:`clients: Arc>>` + #[allow(dead_code)] + _reserved: (), +} + +/// axum WS 路由构造 +/// +/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点, +/// 共享 RelayState。Phase2 在 handler 内填鉴权 + 连接生命周期管理。 +pub fn build_router(state: Arc) -> Router { + Router::new() + .route("/ws/device", get(device_ws_handler)) + .route("/ws/miniapp", get(miniapp_ws_handler)) + .with_state(state) +} + +/// 桌面端 WS 连接 handler(脚手架占位) +/// +/// 当前仅接受升级并立即返回(不消费流),Phase2 填: +/// - 握手鉴权(token) +/// - register_client +/// - 循环 recv 转发 BroadcastMessage +/// - 断开 unregister_client +async fn device_ws_handler( + ws: WebSocketUpgrade, + State(_state): State>, +) -> impl IntoResponse { + tracing::info!("桌面端 WS 连接接入(脚手架占位)"); + ws.on_upgrade(handle_connection) +} + +/// 小程序 WS 连接 handler(脚手架占位) +async fn miniapp_ws_handler( + ws: WebSocketUpgrade, + State(_state): State>, +) -> impl IntoResponse { + tracing::info!("小程序 WS 连接接入(脚手架占位)"); + ws.on_upgrade(handle_connection) +} + +/// 通用 WS 连接处理(脚手架:接收一次即关闭) +/// +/// Phase2 替换为完整读写循环 + 广播分发。 +async fn handle_connection(mut socket: WebSocket) { + // 接收首条消息(占位),Phase2 改为持续循环 + if let Some(Ok(msg)) = futures_util::StreamExt::next(&mut socket).await { + tracing::debug!(?msg, "收到首条消息(脚手架占位,Phase2 填路由)"); + } + // 脚手架:静默关闭,不报错(连接生命周期管理留 Phase2) +} + +/// 默认中继服务骨架 +/// +/// 脚手架阶段:trait 方法返回未实现错误,不静默假成功。 +/// Phase2 替换为完整实现(启动 axum::serve + 注册表 + 广播)。 +pub struct DefaultRelayServer { + #[allow(dead_code)] + bind_addr: Option, +} + +impl DefaultRelayServer { + pub fn new() -> Self { + Self { bind_addr: None } + } +} + +impl Default for DefaultRelayServer { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl RelayServer for DefaultRelayServer { + async fn start(&self, addr: &str) -> Result<()> { + tracing::warn!(addr = %addr, "DefaultRelayServer.start 尚未实现(Phase2 填充)"); + Err(crate::error::RelayError::Other(anyhow::anyhow!( + "DefaultRelayServer.start 尚未实现(Phase2)" + ))) + } + + async fn broadcast(&self, _msg: BroadcastMessage) -> Result<()> { + Err(crate::error::RelayError::Other(anyhow::anyhow!( + "DefaultRelayServer.broadcast 尚未实现(Phase2)" + ))) + } + + async fn register_client(&self, _device_id: String) -> Result<()> { + Err(crate::error::RelayError::Other(anyhow::anyhow!( + "DefaultRelayServer.register_client 尚未实现(Phase2)" + ))) + } + + async fn unregister_client(&self, _device_id: String) -> Result<()> { + Err(crate::error::RelayError::Other(anyhow::anyhow!( + "DefaultRelayServer.unregister_client 尚未实现(Phase2)" + ))) + } + + fn is_device_online(&self, _device_id: &str) -> bool { + false + } +} diff --git a/crates/df-tunnel/Cargo.toml b/crates/df-tunnel/Cargo.toml new file mode 100644 index 0000000..cb40dea --- /dev/null +++ b/crates/df-tunnel/Cargo.toml @@ -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" diff --git a/crates/df-tunnel/src/error.rs b/crates/df-tunnel/src/error.rs new file mode 100644 index 0000000..6224b15 --- /dev/null +++ b/crates/df-tunnel/src/error.rs @@ -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 = std::result::Result; diff --git a/crates/df-tunnel/src/events.rs b/crates/df-tunnel/src/events.rs new file mode 100644 index 0000000..0cf2289 --- /dev/null +++ b/crates/df-tunnel/src/events.rs @@ -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), +} diff --git a/crates/df-tunnel/src/lib.rs b/crates/df-tunnel/src/lib.rs new file mode 100644 index 0000000..53d9086 --- /dev/null +++ b/crates/df-tunnel/src/lib.rs @@ -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}; diff --git a/crates/df-tunnel/src/tunnel.rs b/crates/df-tunnel/src/tunnel.rs new file mode 100644 index 0000000..ff20d23 --- /dev/null +++ b/crates/df-tunnel/src/tunnel.rs @@ -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,失败显式上抛不吞错。 +#[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>; +} + +/// 默认 WS 隧道客户端骨架(tokio-tungstenite) +/// +/// 脚手架阶段:字段占位,方法返回 `TunnelError::Other("未实现")`。 +/// Phase2 替换为完整实现: +/// - `WebSocketStream>` 持有连接 +/// - `mpsc` 通道解耦 send/recv 任务 +/// - 鉴权握手 + 心跳 + 重连 +pub struct WsTunnelClient { + /// 目标服务器 URL(连接后保存,重连用) + #[allow(dead_code)] + server_url: Option, + /// 连接状态标志(轻量 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> { + Err(crate::error::TunnelError::Other(anyhow::anyhow!( + "WsTunnelClient.recv_command 尚未实现(Phase2)" + ))) + } +}