//! DevFlow Tauri 入口 — 初始化 AppState 并注册全部 IPC 命令 mod commands; mod state; // 媒体存储域(#958 引用化):hash 去重落盘/OSS 双写/引用形态,飞书与桌面粘贴图共用 mod media_store; // Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例 mod mcp; // 本地工具代理统一网关:内嵌 axum 监听 127.0.0.1:8899,路由到 mysql/ssh/mongo/redis 4 代理端口 mod local_proxy_gateway; mod tray; mod feishu_channel; // 双进程共用日志初始化(数据目录落点+启动期轮转+%TEMP% 旧日志迁移) // main.rs 经 devflow_lib::logging 访问,须 pub pub mod logging; use tauri::{Emitter, Listener, Manager}; // UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt use tauri_plugin_notification::NotificationExt; use state::AppState; // Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内 use df_tunnel::TunnelClient; /// 关窗进行中提示哨兵:tray-hide-notice 每进程只发一次(反复关窗不重复弹气泡)。 static CLOSE_NOTICE_SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// 首次关窗托盘化说明的持久化标记文件名(app_data_dir 下;存在 = 历史上已提示过,不再弹)。 /// 用文件而非进程哨兵:解释一次即够,重启后不必再打扰;写入与通知成败解耦(先占坑语义), /// 防通知通道异常(如无系统通知权限)导致每次关窗都尝试弹。 const TRAY_NOTICE_SENT_MARKER: &str = "tray-hide-notice-sent"; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // 双进程共用日志初始化(logging.rs):落点系统应用数据目录 + 启动期 5MB 轮转 + // %TEMP% 旧日志迁移。治「使用数据分析-2026-10-04」登记项(%TEMP% 系统清理无声删除+无轮转)。 // identifier 与 tauri.conf.json 一致(main.rs MCP 侧走编译期注入同源)。 let _guard = logging::init_logging("top.1216.devflow", "gui"); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) // P1 bg 化:系统通知插件(Rust 侧 notify.rs 条件发送:主窗可见聚焦不发 + 同类节流 + 失败静默) .plugin(tauri_plugin_notification::init()) // B2 应用内升级:tauri-plugin-updater(内建 ed25519 签名校验;端点/pubkey 见 tauri.conf.json plugins.updater) .plugin(tauri_plugin_updater::Builder::new().build()) // Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口) .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { tray::show_main(app); })) .setup(|app| { // 数据库放在系统应用数据目录 下(dev/release 拆分): // Dev 模式 devflow-dev.db(可随意改动/清空),Build 模式 devflow.db(长期保留真实运行数据) let db_name = if cfg!(debug_assertions) { "devflow-dev.db" } else { "devflow.db" }; let data_dir = app.path().app_data_dir()?; std::fs::create_dir_all(&data_dir)?; let db_path = data_dir.join(db_name); // 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?; // 启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断) if let Err(e) = tauri::async_runtime::block_on( commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers), ) { tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e); } // L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state let session_for_handshake = app_state.ai_session.clone(); app.manage(app_state); // spawn_agent 子代理循环需要 AppHandle(emit/guard),工具注册期无法捕获, // setup 阶段回注进程级 OnceLock(重复注入忽略,首次生效)。 crate::commands::ai::tools::spawn_agent::init_app_handle(app.handle().clone()); // D2a 工具结果外置根目录注入(app_data 下 tool-results/;未注入时直通降级) commands::ai::tool_result_offload::init_base(data_dir.clone()); // P1 上下文侧工具结果外置(同根目录,tool-results/ 内以 `ctx-` 前缀区分)。 // 未注入时 extract_key_info 退化为纯摘要 = 改造前行为(零回归)。 df_ai::tool_offload::init_base(data_dir.clone()); // 飞书直连渠道:配置启用时起长连接(读 feishu-config;未配置/disabled 静默跳过) feishu_channel::start_if_enabled(app.handle().clone()); // B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复。 // 常驻轮询循环,开关(df-unattended-enabled,默认关)关时每轮只读配置空转零副作用。 commands::ai::unattended::start(app.handle().clone()); // ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ── // 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。 // mcp_get_status IPC 经 State> 读取,spawn 内写锁同步。 let mcp_status = std::sync::Mutex::new(mcp::McpStatus { running: false, port: mcp::mcp_port(), }); app.manage(mcp_status); // 2) 启动 HTTP MCP server(复用 AppState.db,与 GUI 共享同一连接)。 // db 经 app.state::() 取 Arc clone(manage 已注入,setup 后续可用)。 let db_for_mcp = app.state::().db.clone(); mcp::spawn_mcp_http(app.handle(), db_for_mcp); // 2.5) 启动本地工具代理统一网关(127.0.0.1:8899 → mysql/ssh/mongo/redis 4 代理端口)。 // 11 个 local_proxy 工具只连网关,端口收敛到网关映射表,失败非阻断(仅日志)。 crate::local_proxy_gateway::spawn_local_proxy_gateway(app.handle()); // 3) 创建托盘(关窗口不退出,托盘「退出」才真正结束进程)。失败非阻断。 if let Err(e) = tray::setup_tray(app.handle()) { tracing::error!("[tray] 托盘创建失败(非阻断): {}", e); } // L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态 // 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次), // 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。 // 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。 let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new( std::time::Instant::now() .checked_sub(std::time::Duration::from_secs(3600)) .unwrap_or_else(std::time::Instant::now), )); let app_handle = app.handle().clone(); app.listen("ai-client-ready", move |_event| { let session_arc = session_for_handshake.clone(); let app_h = app_handle.clone(); let last_at = last_handshake_at.clone(); tauri::async_runtime::spawn(async move { // 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。 { let mut last = last_at.lock().await; let elapsed = last.elapsed(); if elapsed < std::time::Duration::from_secs(3) { tracing::info!( "[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)", elapsed.as_millis() ); return; } *last = std::time::Instant::now(); } let mut session = session_arc.lock().await; let app_state_ref = app_h.state::(); // 职责分离(根治 BUG-2026-07-08 误杀活 loop): // 生产环境前端 WebSocket 重连 / webview reload 会触发 ai-client-ready(非用户重发), // 旧握手无脑强杀正在跑的 agentic loop(stop_flag + transition Idle + emit AiCompleted) // → AI 中断不回复 → 用户「莫名其妙停止」(实证 cf5f44ed:seq26 后 loop 等下次 LLM 被杀)。 // // 三道防线已就绪,握手无需也不应再强杀 loop: // 1. guard.rs GeneratingGuard RAII:new→Generating,reset→Idle(正常),Drop→Idle(panic 兜底)。 // loop 自己复位 generating,握手强杀是多余且有害的旁路。 // 2. conv_state.rs can_accept_request 只 Idle|Error 可接 → generating 时 ai_chat_send // 拒新消息 → 防「用户重发双 loop」(原 BUG-2026-07-08 的真实诉求)。 // 3. max_iterations 超时兜底 loop 卡死。 // // 故握手职责收窄为「同步真实状态给前端」:读 conv_states 当前状态逐个 emit, // 不改写 conv_states、不设 stop_flag、不收尾。loop 在跑则前端拿到 Generating, // loop 已退出(guard 已复位)则拿到 Idle —— 前端状态与后端一致即可。 let dirty_convs: Vec = app_state_ref.conv_states.active_convs(); let was_generating = !dirty_convs.is_empty(); // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, // 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending), // 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。 // 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。 session.pending_approvals.retain(|_, a| !a.recovered); // 握手需推状态的 conv 集合:dirty conv(后端已知活跃)+ 当前 active_conv(用户正看的)。 // dirty_convs 已含活跃 conv;active_conv 若非活跃(Idling/Eror)也补推一次让前端同步。 let active_conv = session.active_conversation_id.clone(); let mut convs_to_sync: Vec = dirty_convs.clone(); if let Some(ref cid) = active_conv { if !convs_to_sync.iter().any(|c| c == cid) { convs_to_sync.push(cid.clone()); } } drop(session); // 逐个 conv 读 conv_states 真实状态推给前端(loop 在跑推 Generating/Compressed, // 已退出推 Idle)。ConvStateStore.get 同步无锁,不竞争 session lock。 for cid in &convs_to_sync { let real_state = app_state_ref.conv_states.get(cid); let _ = app_h.emit( "ai-chat-event", commands::ai::AiChatEvent::AiConvStateChanged { conv_state: real_state, conversation_id: Some(cid.clone()), }, ); } tracing::info!( "[L0-handshake] 前端重连握手完成, was_generating={}, dirty_convs={:?}", was_generating, dirty_convs ); }); }); // 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 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。 // settings value 契约模糊(JSON 序列化带引号或裸值并存):经 get_string_norm // 归一去引号(此处 from_str 写法已收敛为 state.rs SettingsNorm 正典,2026-10-01 DRY #2), // 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。 use state::SettingsNorm; let relay_url = state .settings .get_string_norm("relay_url") .await .filter(|s| !s.is_empty()) .unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string()); // token:KV 取,无则回退默认常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)。 // 2026-10-05 配置化(原硬编码):relay 部署改 token 后桌面端无需重编译, // 前端高级-连接面板(或 settings KV)写 relay_token 即生效。 let token = state .settings .get_string_norm("relay_token") .await .filter(|s| !s.is_empty()) .unwrap_or_else(|| "devflow-relay-default-token".to_string()); // subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp // 治本:先查 is_connected()(AtomicBool 无锁读),未连接时静默丢弃事件, // 避免每次 send_raw_event 都失败并记 WARN(旧实现用 suppress_until 降频,是治标)。 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"); let mut was_connected = false; while let Ok(value) = rx.recv().await { if !tunnel_for_sub.is_connected() { if was_connected { tracing::info!("[tunnel-sub] tunnel 已断开,暂停透传"); was_connected = false; } continue; } if !was_connected { tracing::info!("[tunnel-sub] tunnel 已重连,恢复透传"); was_connected = true; } if let Err(e) = tunnel_for_sub.send_raw_event(value).await { // 连接刚断(查询与发送间窗口),记一条 DEBUG 而非 WARN tracing::debug!("[tunnel-sub] send_raw_event 失败(连接瞬断): {}", e); was_connected = false; } } 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>, > }, ); // H4 兜底审计 subscriber(无人值守可靠性专项 B2):订阅 ai_event_bus, // Started{id,name,conversation_id}→入配对表 / Completed{id,result,conversation_id}→ // 经配对表取 name 落审计行。兜「事件到达但主链未落审计行」的旁路场景, // 统一记 completed(Completed 无 name/isError,失败态由主链口径区分)。 { let audit_db = state.db.clone(); let mut audit_rx = state.ai_event_bus.subscribe(); tauri::async_runtime::spawn(async move { // call_id → (name, conversation_id) 配对表(容量上限防泄漏) let mut pending: std::collections::HashMap)> = std::collections::HashMap::new(); while let Ok(event) = audit_rx.recv().await { let Some(event_type) = event.get("type").and_then(|t| t.as_str()).map(|s| s.to_string()) else { continue }; let Some(call_id) = event.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()) else { continue }; match event_type.as_str() { "AiToolCallStarted" => { let name = event.get("name").and_then(|n| n.as_str()).unwrap_or("unknown").to_string(); let conv = event.get("conversation_id").and_then(|c| c.as_str()).map(|s| s.to_string()); if pending.len() > 512 { pending.clear(); // 防泄漏:异常堆积时整体丢弃(审计兜底可容忍) } pending.insert(call_id, (name, conv)); } "AiToolCallCompleted" => { let Some((name, conv)) = pending.remove(&call_id) else { continue }; let Some(conv_id) = conv.as_deref() else { continue }; let repo = df_storage::crud::AiToolExecutionRepo::new(&audit_db); let result_str = match event.get("result") { Some(serde_json::Value::String(s)) => Some(s.clone()), Some(other) => Some(other.to_string()), None => None, }; crate::commands::ai::audit::record_audit( &repo, conv_id, &call_id, &name, "{}", // 事件面无原始 args(arguments 参数非 Option,空 JSON 占位) "completed", df_ai::ai_tools::RiskLevel::Low, // subscriber 侧无元数据,保守 Low(主链已记真实级别) result_str, Some("event_bus"), None, ).await; } _ => {} } } }); } // connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。 // 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。 match state .tunnel .connect(&relay_url, &device_id, &token, on_command.clone()) .await { Ok(()) => tracing::info!( "[tunnel] 连接 relay 成功 device_id={} url={}", device_id, relay_url ), Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e), } // supervisor:轮询 is_connected,断开则重连 // 连接被拒(relay 未部署)→300s+INFO;其他错误→2s-60s WARN let mut backoff = std::time::Duration::from_secs(2); let mut last_was_refused = false; loop { tokio::time::sleep(std::time::Duration::from_secs(5)).await; if !state.tunnel.is_connected() { tracing::info!("[tunnel-supervisor] 断线重连 url={relay_url} device_id={device_id}"); match state .tunnel .connect(&relay_url, &device_id, &token, on_command.clone()) .await { Ok(()) => { tracing::info!("[tunnel-supervisor] 重连成功,退避重置"); backoff = std::time::Duration::from_secs(2); last_was_refused = false; } Err(e) => { let refused = is_connection_refused(&e); if refused { // relay 未运行:INFO + 300s 退避,不刷 WARN if !last_was_refused { tracing::info!("[tunnel-supervisor] relay 未运行({e}),300s 后轻量探测"); last_was_refused = true; } // 300s 退避(避免空转刷屏,relay 未运行时一直保持此间隔) tokio::time::sleep(std::time::Duration::from_secs(300)).await; } else { tracing::warn!("[tunnel-supervisor] 重连失败 {e},退避 {backoff_secs}s", backoff_secs = backoff.as_secs()); tokio::time::sleep(backoff).await; // 指数退避上限 60s,防网络长故障时高频重连打 relay backoff = (backoff * 2).min(std::time::Duration::from_secs(60)); last_was_refused = false; } } } } else if last_was_refused { last_was_refused = false; } } }); Ok(()) }) // 辅助函数 is_connection_refused 在文件尾部 // 判断 TunnelError 是否为连接被拒(relay 未运行)。 // 匹配 WS connect_async 产生的 IO error:ECONNREFUSED(Win:10061,Unix:111)。 // 被拒 → relay 未部署,用长退避避免刷屏;其他错误(超时/DNS/网络瞬断)走指数退避。 .invoke_handler(tauri::generate_handler![ // 项目 commands::project::list_projects, commands::project::create_project, commands::project::import_project, commands::project::get_project, commands::project::update_project, commands::project::delete_project, commands::project::list_deleted_projects, commands::project::archive_project, commands::project::list_archived_projects, commands::project::restore_project, commands::project::purge_project, commands::project::scan_project_stack, commands::project::check_path_binding, commands::project::relocate_project_path, commands::project::check_path_exists, commands::project::scan_project_with_ai, commands::project::scan_directory_for_projects, commands::project::import_projects_batch, // 任务 commands::task::list_tasks, commands::task::count_tasks, commands::task::create_task, commands::task::update_task, commands::task::delete_task, commands::task::restore_task, // 归档功能 P1(V46/V47):任务归档 + 归档视图 commands::task::archive_task, commands::task::list_archived_tasks, commands::task::get_task_by_id, commands::task::advance_task, // 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树 commands::task::create_task_link, commands::task::remove_task_link, commands::task::list_task_links, commands::task::move_task_queue, commands::task::get_task_tree, // 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询 commands::events::get_project_timeline, // 知识图谱 Phase 3(对标设计 §五 + §2.3 + D10):项目基础设施配置 CRUD commands::services::add_project_service, commands::services::update_project_service, commands::services::remove_project_service, commands::services::list_project_services, // 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询 commands::module::add_project_module, commands::module::update_project_module, commands::module::remove_project_module, commands::module::list_project_modules, commands::module::scan_project_modules, commands::module::get_module_git_status, commands::module::list_branches, // 工程文件浏览(Batch 10):文件树 + 单文件预览 commands::module::get_module_file_tree, commands::module::read_module_file, commands::module::file_outline, commands::module::get_module_file_meta, commands::module::get_module_file_diff, commands::module::get_module_commits, commands::module::get_commit_detail, commands::module::add_module_dependency, commands::module::remove_module_dependency, commands::module::list_module_dependencies, commands::module::detect_module_cycles, // 灵感 commands::idea::list_ideas, commands::idea::create_idea, commands::idea::update_idea, commands::idea::relate_ideas, commands::idea::delete_idea, commands::idea::restore_idea, // 归档功能 P1(V47):灵感归档 + 归档视图 commands::idea::archive_idea, commands::idea::list_archived_ideas, commands::idea::list_deleted_ideas, commands::idea::evaluate_idea, commands::idea::list_idea_evaluations, commands::idea::promote_idea, // 工作流 commands::workflow::run_workflow, commands::workflow::list_workflow_executions, commands::workflow::get_workflow_execution, commands::workflow::approve_human_approval, commands::workflow::cancel_workflow_node, // AI 聊天 commands::ai::ai_chat_send, commands::ai::ai_regenerate, commands::ai::ai_chat_edit, commands::ai::ai_chat_force_send, commands::ai::ai_chat_stop, commands::ai::ai_approve, // 达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached,前端操作卡留 batch45) commands::ai::ai_continue_loop, commands::ai::ai_stop_loop, // 流中断自动续跑探测(前端看门狗超时后调,按后端真实状态续跑/兜底收敛) commands::ai::ai_resume_interrupted, commands::ai::ai_pending_tool_calls, commands::ai::ai_chat_clear, commands::ai::ai_is_generating, // 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队) commands::ai::ai_inject_runtime, // F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩 commands::ai::ai_chat_clear_context, commands::ai::ai_chat_compress_context, commands::ai::ai_list_providers, commands::ai::ai_save_provider, commands::ai::ai_set_provider, commands::ai::ai_delete_provider, // c: 负载均衡池可编辑层(enabled/weight 轻量更新 + caps 重建) commands::ai::ai_update_provider_pool, // F-01 阶段5:测试连接拉取模型列表 + 单模型探测 commands::ai::ai_fetch_models, commands::ai::ai_probe_model, // 用表单当前值一次性测试连接(不写库,新建/编辑未保存均可测) commands::ai::ai_test_connection, // AI 对话管理 commands::ai::ai_conversation_create, commands::ai::ai_conversation_list, commands::ai::ai_list_recoverable, commands::ai::ai_recover_parked, // 挂机引擎(无人值守):状态查询 + 模式开关(设置页挂机区块/TopBar 徽章) commands::ai::unattended::unattended_status, commands::ai::unattended::unattended_set_enabled, commands::ai::ai_conversation_switch, // S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态) commands::ai::ai_conversation_peek, // S3 完成卡备料:按父会话反查子代理轨迹精简元数据(只读) commands::ai::ai_sub_agent_list, commands::ai::context_briefs::ai_context_briefs, commands::ai::ai_conversation_load_more, commands::ai::ai_conversation_delete, commands::ai::ai_conversation_rename, commands::ai::ai_conversation_archive, commands::ai::ai_conversation_set_pinned, commands::ai::ai_update_conversation_goals, // 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批 commands::ai::ai_task_boundary_get, commands::ai::ai_task_boundary_set, commands::ai::ai_conversation_export, commands::ai::ai_list_skills, // 核心设计6: 热重载技能(invalidate + 重扫,不重启生效) commands::ai::ai_reload_skills, commands::ai::ai_set_concurrency_config, commands::ai::ai_set_agent_max_iterations, commands::ai::ai_set_agent_max_retries, // F-260619-03 Phase A: AI 工具文件访问授权目录白名单 commands::ai::ai_get_allowed_dirs, commands::ai::ai_set_allowed_dirs, // 工具风险元数据投影(前端启动构建 High 风险名单,审批二次确认门控) commands::ai::ai_list_tools, // F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起) commands::ai::ai_authorize_dir, // 审批历史面板查 ai_tool_executions 表,敏感字段截断) commands::ai::audit::record::list_tool_executions, // S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读) commands::ai::audit::record::list_task_conversations, // AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围) commands::ai::audit::record::tool_failure_stats, // D2a 外置工具结果读回(审计面板遇句柄按 path 读原文全文) commands::ai::audit::record::ai_read_offloaded_result, // 知识库 commands::knowledge::knowledge_list, commands::knowledge::knowledge_get, commands::knowledge::knowledge_search, commands::knowledge::knowledge_create, commands::knowledge::knowledge_update_status, commands::knowledge::knowledge_record_reuse, commands::knowledge::knowledge_list_candidates, commands::knowledge::knowledge_archive, commands::knowledge::knowledge_retry_embedding, commands::knowledge::knowledge_get_config, commands::knowledge::knowledge_save_config, commands::knowledge::knowledge_extract_now, commands::knowledge::knowledge_get_detail, commands::knowledge::knowledge_update, commands::knowledge::knowledge_events, // 通用应用设置 KV(前端 localStorage 迁移目标) commands::settings::settings_get, commands::settings::settings_set, commands::settings::settings_get_all, commands::settings::settings_delete, commands::settings::get_data_dir, // 应用版本号(编译期注入,关于页/更新检查消费) commands::settings::app_get_version, // B2 应用内升级:检查更新 + 自动检查偏好 KV commands::update::app_check_update, commands::update::app_update_get_prefs, commands::update::app_update_set_prefs, commands::update::app_update_mark_checked, commands::update::app_update_mark_notified, // B3 应用内升级:下载安装(进度事件 + 成功重启) commands::update::app_download_install_update, // 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改) commands::settings::ai_get_approval_timeout, commands::settings::ai_set_approval_timeout, // ScriptNode 命令安全配置(白/黑名单,前端设置页写入) commands::settings::set_script_safety, // Plan 执行开关 commands::settings::set_plan_execution, commands::settings::is_plan_execution_enabled, // 冲突解决 commands::settings::resolve_conflict, // 模板管理 commands::settings::list_templates, commands::settings::save_template, commands::settings::delete_template, // CI 状态 commands::ci_status::get_commit_status, // Phase3: 内嵌 HTTP MCP server 状态查询 crate::mcp::mcp_get_status, ]) // 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。 // 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行 // 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。 // P1 bg 化:有 AI 会话运行中时,首次关窗 emit tray-hide-notice 提示后台仍在跑(每进程一次), // 异步等 2 秒再 hide(同步 sleep 卡主线程渲染,气泡画不出来);无运行会话保持立即 hide 零回归。 // UX-P2 走查⑥:无会话关窗历史上零提示,用户误以为已退出。窗口随即隐藏,应用内气泡画了也 // 看不见,且前端气泡通道仅 running>0 才渲染,故走 OS 系统通知(托盘旁 toast)补位, // 以持久化标记全生命周期只解释一次(解释性提示,与上方每进程一次的会话提醒互不替代)。 .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { if window.label() != "main" { return; // 非主窗口放行真正销毁 } api.prevent_close(); // 拦截真正关闭 let app = window.app_handle().clone(); let active = app.state::().conv_states.active_convs().len(); if active == 0 { // 首次(全历史一次)关窗托盘化:弹一次系统通知说明「已最小化到托盘」 match app.path().app_data_dir() { Ok(dir) => { let marker = dir.join(TRAY_NOTICE_SENT_MARKER); if !marker.exists() { let _ = app .notification() .builder() .title("DevFlow") .body("已最小化到系统托盘,程序仍在后台运行;托盘图标可随时唤起,托盘菜单「退出」才真正结束") .show(); let _ = std::fs::write(&marker, b"1"); } } Err(e) => { tracing::warn!("[tray] app_data_dir 解析失败,跳过首次关窗托盘提示: {}", e); } } let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为) return; } // 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡。 // 键名必须为 running:App.vue 监听端读 e.payload.running,曾发 "active" 致 // 端上恒取 undefined→按 0 处理,气泡整条链路静默失效(UX-P3 批次修正)。 if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) { let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "running": active })); } // 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染) let win = window.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let _ = win.hide(); }); } }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } /// 判断 TunnelError 是否为连接被拒(匹配 ECONNREFUSED 各平台表述) fn is_connection_refused(e: &df_tunnel::TunnelError) -> bool { let msg = e.to_string().to_lowercase(); msg.contains("refused") || msg.contains("10061") || msg.contains("积极拒绝") || msg.contains("actively refused") }