新增: 跨端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

184
Cargo.lock generated
View File

@@ -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"

View 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"

View 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 },
}

View 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>;

View 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};

View 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
}
}

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)"
)))
}
}