diff --git a/Cargo.lock b/Cargo.lock index b8fb7cd..273b079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,7 @@ dependencies = [ "df-nodes", "df-project", "df-storage", + "df-tunnel", "df-types", "df-workflow", "futures", @@ -858,6 +859,7 @@ dependencies = [ "tauri-plugin-window-state", "tokio", "tracing", + "uuid", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 003410a..5f97a62 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,7 +36,12 @@ df-ai = { path = "../crates/df-ai" } df-ideas = { path = "../crates/df-ideas" } df-project = { path = "../crates/df-project" } df-mcp = { path = "../crates/df-mcp" } +# Phase3 跨端隧道:桌面端主动出站连 df-relay(/ws/device),透传 ai_event_bus → miniapp +# + 转发 miniapp 下行指令 → remote_bridge 路由 Tauri command。穿 NAT 无需端口映射。 +df-tunnel = { path = "../crates/df-tunnel" } futures = "0.3" +# Phase3 tunnel device_id:首次启动生成 UUID v4 落 settings KV 持久化(relay 按 device_id 路由配对) +uuid = { workspace = true } # write_file base64 编码:写二进制/非 UTF-8 文件(图片/PDF/Excel 等) base64 = "0.22" # validate_path URL 解码:防 %2e%2e 等 URL 编码绕过路径遍历检查(BUG-260617-03) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a927296..305b5aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ mod state; use tauri::{Emitter, Listener, Manager}; use state::AppState; +// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内 +use df_tunnel::TunnelClient; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -86,6 +88,90 @@ pub fn run() { }); }); + // Phase3 跨端隧道集成:tunnel connect relay + subscriber 透传 ai_event_bus → miniapp + // + on_command 下行路由(miniap 指令 → remote_bridge → Tauri command)。 + // spawn 后台 task:device_id/relay_url 解析(持久 KV,无则默认)→ subscriber 透传 + // → on_command 回调注册 → connect(失败非阻断,可后续手动重连)。 + let tunnel_app = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let state = tunnel_app.state::(); + + // device_id:KV 取,无则生成 UUID v4 落库(持久,relay 按 device_id 路由配对)。 + let device_id = match state.settings.get("device_id").await { + Ok(Some(id)) if !id.is_empty() => id, + _ => { + let id = uuid::Uuid::new_v4().to_string(); + let _ = state.settings.set("device_id", &id).await; + id + } + }; + // relay_url:KV 取,无则默认本地(df-miniapp config.ts:33 同源 localhost:8080)。 + let relay_url = state + .settings + .get("relay_url") + .await + .ok() + .flatten() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string()); + // token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN) + let token = "devflow-relay-default-token".to_string(); + + // subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp + let mut rx = state.ai_event_bus.subscribe(); + let tunnel_for_sub = state.tunnel.clone(); + tauri::async_runtime::spawn(async move { + tracing::info!("[tunnel-sub] subscriber task 启动,透传 ai_event_bus → relay"); + while let Ok(value) = rx.recv().await { + if let Err(e) = tunnel_for_sub.send_raw_event(value).await { + tracing::warn!("[tunnel-sub] send_raw_event 失败(可能未连接): {}", e); + } + } + tracing::info!("[tunnel-sub] subscriber task 退出(rx 关闭)"); + }); + + // on_command 回调:miniapp 下行指令 → remote_bridge 路由 Tauri command。 + // CommandHandler = Arc BoxFuture<'static, ()> + Send + Sync> + // (tunnel.rs:45)。回调返 BoxFuture,Box::pin 构造 + 类型标注对齐签名。 + // + // 借用处理:`handle_remote_command(payload, app, state)` 的 state 是 + // `State<'_, AppState>`(newtype `&AppState`),借用 app。若先 `let state = + // app.state()` 再 move app 入函数,E0505(借用的 app 被 move)。 + // 正解:同一表达式内 `app.clone()` move clone、`app.state()` 借用原 app, + // 原 app 不 move(仅被 state 借用到调用结束),借用检查通过。 + let cmd_app = tunnel_app.clone(); + let on_command: df_tunnel::tunnel::CommandHandler = std::sync::Arc::new( + move |payload: serde_json::Value| { + let app = cmd_app.clone(); + Box::pin(async move { + crate::commands::ai::remote_bridge::handle_remote_command( + payload, + app.clone(), + app.state::(), + ) + .await; + }) + as std::pin::Pin< + Box + Send>, + > + }, + ); + + // connect(失败非阻断,tunnel 可后续手动重连)。收发循环在 client 内部 spawn。 + match state + .tunnel + .connect(&relay_url, &device_id, &token, on_command) + .await + { + Ok(()) => tracing::info!( + "[tunnel] 连接 relay 成功 device_id={} url={}", + device_id, + relay_url + ), + Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断): {}", e), + } + }); + Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f5d9e8b..10c5c87 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -412,6 +412,12 @@ pub struct AppState { /// workspace_root 始终在白名单(向后兼容)。 /// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。 pub allowed_dirs: Arc>, + // ── 跨端隧道(Phase3 Layer1)── + /// 桌面端出站 WS 隧道客户端,连 df-relay(/ws/device)透传 ai_event_bus → miniapp + /// + 转发 miniapp 下行指令 → remote_bridge。lib.rs setup spawn task 解析 + /// device_id/relay_url 后 connect(失败非阻断,可后续手动重连)。 + /// Arc 包裹:setup task(subscriber + on_command 回调)与 AppState 共享同一客户端。 + pub tunnel: std::sync::Arc, } /// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单 @@ -661,6 +667,8 @@ impl AppState { ai_session: Arc::new(Mutex::new(AiSession::new())), // L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。 ai_event_bus: crate::commands::ai::event_bus::EventBus::new(), + // Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect + tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()), knowledge: KnowledgeRepo::new(&db), knowledge_events: KnowledgeEventsRepo::new(&db), knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),