新增: 跨端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:
184
Cargo.lock
generated
184
Cargo.lock
generated
@@ -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"
|
||||
|
||||
19
crates/df-relay/Cargo.toml
Normal file
19
crates/df-relay/Cargo.toml
Normal file
@@ -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"
|
||||
85
crates/df-relay/src/broadcast.rs
Normal file
85
crates/df-relay/src/broadcast.rs
Normal file
@@ -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<String>, 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<String>, 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 },
|
||||
}
|
||||
37
crates/df-relay/src/error.rs
Normal file
37
crates/df-relay/src/error.rs
Normal file
@@ -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<T> = std::result::Result<T, RelayError>;
|
||||
26
crates/df-relay/src/lib.rs
Normal file
26
crates/df-relay/src/lib.rs
Normal file
@@ -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};
|
||||
158
crates/df-relay/src/relay.rs
Normal file
158
crates/df-relay/src/relay.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! df-relay 中继服务骨架
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
|
||||
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)。
|
||||
//! 纯转发,无业务逻辑。
|
||||
//!
|
||||
//! 脚手架范围:RelayServer trait + axum WS 路由骨架(占位 handler),
|
||||
//! Phase2 填:
|
||||
//! - 客户端注册表(HashMap<device_id, 连接集>)
|
||||
//! - 广播分发(按 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<HashMap<device_id, ConnectionSet>>。
|
||||
/// 当前用 unit 占位以满足 Router state 约束。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RelayState {
|
||||
/// Phase2:客户端注册表
|
||||
/// 例:`clients: Arc<RwLock<HashMap<String, ConnectionSet>>>`
|
||||
#[allow(dead_code)]
|
||||
_reserved: (),
|
||||
}
|
||||
|
||||
/// axum WS 路由构造
|
||||
///
|
||||
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
|
||||
/// 共享 RelayState。Phase2 在 handler 内填鉴权 + 连接生命周期管理。
|
||||
pub fn build_router(state: Arc<RelayState>) -> 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<Arc<RelayState>>,
|
||||
) -> impl IntoResponse {
|
||||
tracing::info!("桌面端 WS 连接接入(脚手架占位)");
|
||||
ws.on_upgrade(handle_connection)
|
||||
}
|
||||
|
||||
/// 小程序 WS 连接 handler(脚手架占位)
|
||||
async fn miniapp_ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(_state): State<Arc<RelayState>>,
|
||||
) -> 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<String>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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