新增: MCP HTTP transport + 系统托盘 + 实体解析(会话前基线收尾)

- df-mcp 加 streamable HTTP transport 层(axum 0.7 + tower,server_http.rs),lib.rs pub mod 接线

- src-tauri 加 mcp.rs(spawn_mcp_http + mcp_get_status IPC + mcp-server CLI)+ tray.rs(系统托盘 show_main/setup_tray),lib.rs 集成 + main.rs mcp-server 参数路由

- AI 工具加 entity_resolve.rs(实体解析,tools/mod.rs pub mod 接线)
This commit is contained in:
lxy
2026-08-05 22:14:27 +08:00
parent c480627ba6
commit a91e950874
16 changed files with 2238 additions and 94 deletions
@@ -0,0 +1,226 @@
//! AI 工具实体参数 name→id 自动解析(机制层)
//!
//! 背景:DevFlow aichat 的 AI 工具要求 project_id 传 UUID,但用户/模型常用 name
//! (如 "moyu"/"u-talk"/"DevFlow"),`repo.query("project_id", pid)` 硬匹配 UUID 全返空,
//! 模型被迫绕 list_projects 找 UUID 浪费轮次。
//!
//! 本模块在工具执行前统一做 name→id 解析:
//! - 声明式映射表 [`RESOLVE_MAP`]:新工具需按 name 解析,只需在表里登记一行即生效
//! (工具名 + 参数键名 + 实体类型),无框架/无配置。
//! - UUID 形态跳过:已是 id 的值直接透传,不查库(零额外开销)。
//! - 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`),
//! 命中唯一则替换为项目 id;0 命中 / 重名(≥2)返可行动错误,让 LLM 拿提示自修。
//!
//! 调用点:audit/mod.rs process_tool_calls 单点漏斗(auto + 审批 + 目录授权全部执行路径
//! 统一拿到已解析 id)。
use std::sync::Arc;
use df_storage::crud::ProjectRepo;
use df_storage::db::Database;
/// 实体类型:后续扩展 Task/Idea 时在此加枚举变体即可。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EntityType {
Project,
}
/// 声明式映射表:工具名 → [(参数键名, 实体类型)]。
/// 需按 name 解析的工具在此登记一行即生效。
pub(crate) static RESOLVE_MAP: &[(&str, &[(&str, EntityType)])] = &[
("list_tasks", &[("project_id", EntityType::Project)]),
("create_task", &[("project_id", EntityType::Project)]),
("get_task_count", &[("project_id", EntityType::Project)]),
("get_project_timeline", &[("project_id", EntityType::Project)]),
("add_project_service", &[("project_id", EntityType::Project)]),
("list_project_services",&[("project_id", EntityType::Project)]),
("list_project_modules", &[("project_id", EntityType::Project)]),
("update_project", &[("id", EntityType::Project)]),
("bind_directory", &[("id", EntityType::Project)]),
("delete_project", &[("id", EntityType::Project)]),
("restore_project", &[("id", EntityType::Project)]),
("purge_project", &[("id", EntityType::Project)]),
];
/// 判定字符串是否为 UUID 形态(`Uuid::parse_str` 支持 simple/hyphenated/urn/braced 四格式)。
///
/// **严禁用 `contains('-')` 判定**——"u-talk"/"df-relay" 是真实项目名带连字符,会被误判为 UUID。
fn is_uuid_shape(s: &str) -> bool {
uuid::Uuid::parse_str(s).is_ok()
}
/// 解析工具参数中的实体 name→id。
///
/// 规则(与调用点协商的契约):
/// - 工具未在 [`RESOLVE_MAP`] 登记 / 参数缺失 / 值非字符串 → 原样 `Ok(args.clone())`
/// (不拦截,交给工具 handler 正常处理)。
/// - 值是 UUID 形态 → 已是 id,跳过(不查库)。
/// - 非 UUID → 按 name 精确查 active 项目(list_active 排软删 + 内存过滤 `p.name == value`)。
/// 命中唯一 → 替换该键为项目 id(保持其余字段);0 命中 / 重名(≥2)→ Err(结构化错误,
/// 由调用点包 failed envelope 回传 LLM,让模型拿可行动提示自修)。
pub(crate) async fn resolve_entity_ids(
db: &Arc<Database>,
tool_name: &str,
args: &serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
// 映射表未登记该工具 → 原样透传(零侵入)
let Some((_, key_entities)) = RESOLVE_MAP.iter().find(|(name, _)| *name == tool_name) else {
return Ok(args.clone());
};
let mut resolved = args.clone();
// 遍历该工具登记的所有实体参数键(当前每个工具 1 键,通用支持多键)
for (key, entity_type) in *key_entities {
// 参数缺失 / 值非字符串 → 跳过(工具 handler 自有缺参语义,不在此拦截)
let Some(value) = resolved.get(*key).and_then(|v| v.as_str()) else {
continue;
};
// UUID 形态 → 已是 id,跳过(不查库,零额外开销)
if is_uuid_shape(value) {
continue;
}
// 按 name 精确解析:list_active 排软删 + 内存过滤 p.name == value
let id = match *entity_type {
EntityType::Project => resolve_project_name(db, tool_name, key, value).await?,
};
// 替换该键为项目 id,保持其余字段
if let Some(obj) = resolved.as_object_mut() {
obj.insert(key.to_string(), serde_json::Value::String(id));
}
}
Ok(resolved)
}
/// 按 name 精确解析 active 项目(list_active 排软删),返回唯一命中的项目 id。
/// 0 命中 / 重名(≥2)返可行动错误(含工具名 + 参数键 + 值),让 LLM 自修。
async fn resolve_project_name(
db: &Arc<Database>,
tool_name: &str,
key: &str,
value: &str,
) -> anyhow::Result<String> {
let projects = ProjectRepo::new(db).list_active().await?;
let matches: Vec<_> = projects.iter().filter(|p| p.name == value).collect();
match matches.len() {
0 => Err(anyhow::anyhow!(
"工具 {} 的参数 {}={} 解析失败:未找到名为「{}」的项目(精确匹配,含空格/大小写)。可先调用 list_projects 核对项目名",
tool_name, key, value, value
)),
1 => Ok(matches[0].id.clone()),
_ => {
// 重名:列出前 2 个(含 id+path),引导用 list_projects 区分后传具体项目 id
let a = matches[0];
let b = matches[1];
Err(anyhow::anyhow!(
"工具 {} 的参数 {}={} 命中多个同名项目:「{}」(id={}, path={})、「{}」(id={}, path={})。请用 list_projects 区分后传具体项目 id",
tool_name, key, value,
a.name, a.id, a.path.clone().unwrap_or_default(),
b.name, b.id, b.path.clone().unwrap_or_default(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::models::ProjectRecord;
use df_types::types::{ProjectStatus, new_id};
/// 内存 DB + 插一条项目,返回 (db, project_id)
async fn seed_project(db: &Arc<Database>, name: &str) -> String {
let now = df_types::now_millis().to_string();
let rec = ProjectRecord {
id: new_id(),
name: name.to_owned(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: Some(format!("C:/projects/{}", name)),
stack: None,
created_at: now.clone(),
updated_at: now,
};
ProjectRepo::new(db).insert(rec).await.unwrap()
}
#[test]
fn is_uuid_shape_table() {
let real_uuid = df_types::types::new_id();
assert!(is_uuid_shape(&real_uuid), "真 UUID v4 应为 true");
for s in ["u-talk", "moyu", "proj-svc-1", ""] {
assert!(!is_uuid_shape(s), "「{}」不应判为 UUID", s);
}
}
#[tokio::test]
async fn resolve_unknown_tool_passthrough() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let args = serde_json::json!({ "path": "/tmp/x.rs" });
let out = resolve_entity_ids(&db, "read_file", &args).await.unwrap();
assert_eq!(out, args, "未登记工具应原样透传");
}
#[tokio::test]
async fn resolve_unique_name_to_id() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let pid = seed_project(&db, "moyu").await;
let args = serde_json::json!({ "project_id": "moyu", "status": "todo" });
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
assert_eq!(out["project_id"], serde_json::Value::String(pid));
assert_eq!(out["status"], "todo", "其余字段应保持不变");
}
#[tokio::test]
async fn resolve_uuid_passthrough() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let uuid = df_types::types::new_id();
let args = serde_json::json!({ "project_id": uuid });
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
assert_eq!(out, args, "已是 UUID 应原样透传(不查库)");
}
#[tokio::test]
async fn resolve_missing_param_passthrough() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let args = serde_json::json!({ "status": "todo" });
let out = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap();
assert_eq!(out, args, "参数缺失应原样透传");
}
#[tokio::test]
async fn resolve_zero_hit_errors() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let args = serde_json::json!({ "project_id": "不存在" });
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("未找到"), "0 命中应报「未找到」,实际: {}", msg);
}
#[tokio::test]
async fn resolve_duplicate_name_errors() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let pid1 = seed_project(&db, "DevFlow").await;
let pid2 = seed_project(&db, "DevFlow").await;
let args = serde_json::json!({ "project_id": "DevFlow" });
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("命中多个同名项目"), "重名应报,实际: {}", msg);
assert!(msg.contains(&pid1), "错误应含第一个 id,实际: {}", msg);
assert!(msg.contains(&pid2), "错误应含第二个 id,实际: {}", msg);
}
#[tokio::test]
async fn resolve_soft_deleted_excluded() {
let db = Arc::new(Database::open_in_memory().await.unwrap());
let pid = seed_project(&db, "回收站项目").await;
ProjectRepo::new(&db).soft_delete(&pid).await.unwrap();
let args = serde_json::json!({ "project_id": "回收站项目" });
let err = resolve_entity_ids(&db, "list_tasks", &args).await.unwrap_err();
assert!(
err.to_string().contains("未找到"),
"软删项目不应解析到,实际: {}",
err
);
}
}
+33
View File
@@ -2,6 +2,9 @@
mod commands;
mod state;
// Phase3: 桌面内嵌 HTTP MCP server + 托盘常驻 + 单实例
mod mcp;
mod tray;
use tauri::{Emitter, Listener, Manager};
@@ -51,6 +54,10 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
// Phase3 单实例:二次启动不新开进程,唤起已有实例主窗口(防多实例抢 MCP 18765 端口)
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
tray::show_main(app);
}))
.setup(|app| {
// 数据库放在系统应用数据目录 <app_data_dir> 下(dev/release 拆分):
// Dev 模式 devflow-dev.db(可随意改动/清空),Build 模式 devflow.db(长期保留真实运行数据)
@@ -71,6 +78,23 @@ pub fn run() {
let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state);
// ── Phase3: 内嵌 HTTP MCP server + 托盘常驻 + 单实例 ──
// 1) MCP 运行时状态:先 manage 初始 running=false,spawn 绑定成功后更新为 true。
// mcp_get_status IPC 经 State<Mutex<McpStatus>> 读取,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::<AppState>() 取 Arc clone(manage 已注入,setup 后续可用)。
let db_for_mcp = app.state::<AppState>().db.clone();
mcp::spawn_mcp_http(app.handle(), db_for_mcp);
// 3) 创建托盘(关窗口不退出,托盘「退出」才真正结束进程)。失败非阻断。
if let Err(e) = tray::setup_tray(app.handle()) {
tracing::error!("[tray] 托盘创建失败(非阻断): {}", e);
}
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
@@ -475,7 +499,16 @@ pub fn run() {
commands::settings::delete_template,
// CI 状态
commands::ci_status::get_commit_status,
// Phase3: 内嵌 HTTP MCP server 状态查询
crate::mcp::mcp_get_status,
])
// Phase3: 关窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); // 拦截真正关闭
let _ = window.hide(); // 隐藏到托盘
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+42 -20
View File
@@ -7,8 +7,7 @@ use std::process::ExitCode;
fn main() -> ExitCode {
// CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。
//
// `devflow mcp-server [--db <path>] [--read-only]`
// `devflow mcp-server --print-config`
// `devflow mcp-server [--db <path>] [--read-only] [--stdio] [--print-config]`
//
// 仅识别首个非程序名参数是否为 `mcp-server`;其余情况走 GUI。
// stdio 句柄由父进程(MCP 客户端)继承,无需额外配置。
@@ -24,10 +23,12 @@ fn main() -> ExitCode {
///
/// - `--db <path>`:指定 SQLite 路径(默认:系统 app_data_dir/devflow[-dev].db)
/// - `--read-only`:仅暴露 list/get 工具
/// - `--print-config`:输出 Claude Code / Cursor 的 MCP 配置 JSON 片段到 stdout 后退出
/// - `--stdio`:显式声明 stdio 形态(默认即 stdio;headless/CI/GUI 未启动场景)
/// - `--print-config`:输出 MCP 配置到 stdout 后退出(默认 HTTP 形态,加 --stdio 输出 stdio command 形态)
fn run_mcp_server(args: &[String]) -> ExitCode {
let mut db_path: Option<PathBuf> = None;
let mut read_only = false;
let mut stdio_mode = false;
let mut print_config = false;
let mut i = 0;
@@ -42,13 +43,15 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
db_path = Some(PathBuf::from(&args[i]));
}
"--read-only" => read_only = true,
"--stdio" => stdio_mode = true,
"--print-config" => print_config = true,
"-h" | "--help" => {
println!("devflow mcp-server [--db <path>] [--read-only] [--print-config]");
println!("devflow mcp-server [--db <path>] [--read-only] [--stdio] [--print-config]");
println!();
println!(" --db <path> 指定 SQLite 数据库路径(默认:app_data_dir/devflow.db)");
println!(" --db <path> 指定 SQLite 数据库路径(默认:app_data_dir/<identifier>/devflow[-dev].db)");
println!(" --read-only 仅暴露 list/get 工具(拒绝 create/update/delete)");
println!(" --print-config 输出 MCP 配置 JSON 片段到 stdout 后退出");
println!(" --stdio 显式声明 stdio 形态(默认即 stdio;headless/CI/GUI 未启动场景)");
println!(" --print-config 输出 MCP 配置(默认 HTTP,加 --stdio 输出 stdio command 形态)");
return ExitCode::SUCCESS;
}
other => {
@@ -60,18 +63,36 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
}
if print_config {
// 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态)
// Claude Code: claude mcp add devflow -- devflow mcp-server [--read-only]
// 说明性注释(非 JSON)打 stderr,保证 stdout 是纯 JSON 可直接被客户端消费
if !stdio_mode {
// 默认输出 HTTP 形态:桌面进程内嵌 HTTP MCP server + 托盘常驻,
// Claude Code 用 --transport http 直连同一进程。端口 18765 与
// crates/df-mcp/src/server_http.rs 的 DEFAULT_MCP_PORT 对齐(本文件不 import,直接写死)。
let json = r#"{"mcpServers":{"devflow":{"transport":"http","url":"http://127.0.0.1:18765/mcp"}}}"#;
println!("{json}");
eprintln!("# Claude Code 接入(需先启动 DevFlow 桌面进程,托盘常驻):");
eprintln!("# claude mcp add --transport http devflow http://127.0.0.1:18765/mcp");
eprintln!("# stdio 备选(无 GUI 场景):");
eprintln!("# devflow mcp-server --stdio --print-config");
return ExitCode::SUCCESS;
}
// 输出 Claude Code / Cursor 兼容的 MCP 配置片段(command/args 形态,显式带 --stdio)。
// Claude Code: claude mcp add devflow -- devflow mcp-server --stdio [--read-only]
// Cursor: 在 ~/.cursor/mcp.json 加 mcpServers.devflow
let read_only_flag = if read_only { r#","--read-only""# } else { "" };
// 用当前可执行文件绝对路径(command 需要能被客户端直接执行;硬编码 devflow
// 在 Windows 上是 DevFlow.exe,且 PATH 未必含安装目录)。
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| "devflow".to_owned());
let json = format!(
r#"{{"mcpServers":{{"devflow":{{"command":"devflow","args":["mcp-server"{read_only_flag}]}}}}}}"#
r#"{{"mcpServers":{{"devflow":{{"command":"{exe}","args":["mcp-server","--stdio"{read_only_flag}]}}}}}}"#
);
println!("{json}");
println!();
println!("# Claude Code 接入:");
println!("# claude mcp add devflow -- devflow mcp-server");
println!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json");
eprintln!("# Claude Code 接入(stdio 形态):");
eprintln!("# claude mcp add devflow -- {exe} mcp-server --stdio");
eprintln!("# Cursor 接入:把上面 JSON 写入 ~/.cursor/mcp.json");
return ExitCode::SUCCESS;
}
@@ -105,13 +126,12 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
}
}
/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/devflow.db 或 dev-dev.db)。
/// 默认数据库路径:与 GUI 应用同库(系统 app_data_dir/{identifier}/devflow[-dev].db)。
///
/// 复用与 lib.rs::run 一致的命名(debug 用 devflow-dev.db,release 用 devflow.db),
/// 保证 MCP server 与 GUI 读写同一文件
/// GUI 模式通过 Tauri 的 `app.path().app_data_dir()` 获取路径(含 identifier),
/// CLI 模式无 app handle,改用编译时注入的 MCP_APP_IDENTIFIER 拼出相同路径
/// 保证 MCP server 与 GUI 读写同一数据库,无需手动传 --db。
fn default_db_path() -> anyhow::Result<PathBuf> {
// tauri 的 app_data_dir 需要 app handle,CLI 无 handle;用 dirs crate 或平台约定降级。
// 这里用 std 环境变量推导(不引 dirs):优先 APPDATA/tav(Windows)、HOME( unix)。
let base = if cfg!(target_os = "windows") {
std::env::var("APPDATA").map(PathBuf::from)?
} else if cfg!(target_os = "macos") {
@@ -121,8 +141,10 @@ fn default_db_path() -> anyhow::Result<PathBuf> {
let home = std::env::var("HOME").map(PathBuf::from)?;
home.join(".local/share")
};
// Tauri app_data_dir 末段为包名/产品名(devflow)。dev 模式可能为 devflow-dev。
let dir = base.join("devflow");
// 从编译时环境变量 MCP_APP_IDENTIFIER 读取 Tauri identifier(如 top.1216.devflow),
// 与 tauri::path::PathResolver::app_data_dir 行为一致,拼出正确路径。
let identifier = option_env!("MCP_APP_IDENTIFIER").unwrap_or("devflow");
let dir = base.join(identifier);
std::fs::create_dir_all(&dir)?;
let name = if cfg!(debug_assertions) {
"devflow-dev.db"
+99
View File
@@ -0,0 +1,99 @@
//! 桌面进程内嵌 HTTP MCP server。
//!
//! 复用 AppState.db 与 GUI 共享同一数据库连接,在 tauri 后台任务里常驻监听
//! 127.0.0.1:<port>/mcp,多 Claude 会话 HTTP 直连同一桌面进程读写 DevFlow 数据。
//!
//! 运行时状态(McpStatus)以 `tauri::State<Mutex<McpStatus>>` 管理:
//! - setup 阶段 manage 初始 `{ running: false, port }`
//! - spawn 内 bind 成功/失败/异常退出时更新 running
//! - 前端经 `mcp_get_status` IPC 查询(Mutex 保证跨线程一致)
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use df_mcp::server_http::{self, McpHttpState};
use df_storage::db::Database;
/// 前端可查的 MCP 状态。
#[derive(Clone, Serialize)]
pub struct McpStatus {
/// server 是否正在监听(bind 成功为 true;绑定失败/异常退出为 false)
pub running: bool,
/// 实际监听端口(env `DEVFLOW_MCP_PORT` 覆盖,默认 18765)
pub port: u16,
}
/// managed state 别名:跨线程共享的 MCP 运行时状态。
/// setup 中 `app.manage(Mutex::new(McpStatus { running: false, port }))` 注入,
/// spawn 内更新、`mcp_get_status` 读取。
pub type McpStatusState = Mutex<McpStatus>;
/// 解析 MCP 监听端口:优先 env `DEVFLOW_MCP_PORT`,否则默认 18765。
/// 供 setup(spawn 前 manage 初始状态)与 mcp_get_status(兜底)共用,保证一致。
pub fn mcp_port() -> u16 {
std::env::var("DEVFLOW_MCP_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(server_http::DEFAULT_MCP_PORT)
}
/// 在 tauri 后台任务里启动 HTTP MCP server(复用 AppState.db,与 GUI 共享连接)。
///
/// 前置条件:setup 中已 `app.manage(Mutex::new(McpStatus{...}))`(本函数内只更新不注入)。
/// bind 成功/失败/异常退出都会同步更新 managed 状态,前端 mcp_get_status 可查实时值。
pub fn spawn_mcp_http(app: &AppHandle, db: Arc<Database>) {
// 工具调用回调:复用现有 df-data-changed 机制,让 GUI 前端在 MCP 写库后自动刷新。
// 非变更类工具(如 list/get)在 emit_data_changed 内部映射不到即 noop,零副作用。
let app_for_cb = app.clone();
let on_tool_call: Option<Arc<dyn Fn(&str) + Send + Sync>> = Some(Arc::new(move |tool_name: &str| {
crate::commands::ai::audit::emit_data_changed(&app_for_cb, tool_name);
}));
let state = McpHttpState::new(db, false, on_tool_call);
let port = mcp_port();
// 后台 task 持有 app clone,用于 bind 结果写回 managed 状态
let app_for_status = app.clone();
tauri::async_runtime::spawn(async move {
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", port)).await {
Ok(l) => l,
Err(e) => {
tracing::error!(port, err = %e, "[mcp-http] 绑定端口失败, MCP server 不可用(可设 DEVFLOW_MCP_PORT 换端口)");
set_mcp_running(&app_for_status, false);
return;
}
};
// 绑定成功,更新状态为 running(供前端查询)
set_mcp_running(&app_for_status, true);
tracing::info!(port, "[mcp-http] MCP server listening on 127.0.0.1:{port}/mcp");
if let Err(e) = server_http::serve_on(listener, state).await {
tracing::error!(err = %e, "[mcp-http] server 异常退出");
set_mcp_running(&app_for_status, false);
}
});
}
/// 更新 managed McpStatus.running。state 未注入/try_state 失败/锁中毒时静默跳过
/// (状态保持原值,非致命,防 panic 拖垮后台任务)。
fn set_mcp_running(app: &AppHandle, running: bool) {
if let Some(state) = app.try_state::<McpStatusState>() {
if let Ok(mut guard) = state.lock() {
guard.running = running;
}
}
}
/// IPC command:前端查询 MCP server 状态。
#[tauri::command]
pub fn mcp_get_status(state: State<'_, McpStatusState>) -> McpStatus {
match state.lock() {
Ok(guard) => McpStatus {
running: guard.running,
port: guard.port,
},
// 锁中毒(理论上不应发生):降级返回静态配置,防 panic
Err(_) => McpStatus {
running: false,
port: mcp_port(),
},
}
}
+49
View File
@@ -0,0 +1,49 @@
//! 托盘常驻:图标 + 菜单[显示主窗口/退出]。关窗口不退出,托盘退出才真正结束进程。
//!
//! - 主窗口 CloseRequested 被 lib.rs 拦截(prevent_close + hide),进程常驻托盘
//! - 托盘菜单「显示主窗口」唤起 / 「退出」app.exit(0) 真正结束
//! - TrayHandle 经 app.manage 保活,防托盘图标被 GC 移除
use tauri::{
AppHandle, Manager,
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
};
/// managed state 保活,防 GC 移除托盘图标。
/// 字段不读仅用于持有 TrayIcon(保活);零调用方≠垃圾,预留保留(对齐 state.rs releases 标法)。
#[allow(dead_code)]
pub struct TrayHandle(pub tauri::tray::TrayIcon);
/// 创建托盘:图标 + 菜单(显示主窗口/退出)。
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
// 复用窗口图标(tauri.conf.json bundle.icon 已配置),缺则 panic 提示配置
let icon = app
.default_window_icon()
.cloned()
.expect("窗口图标缺失(tauri.conf.json icons 已配置)");
let tray = TrayIconBuilder::with_id("devflow-tray")
.icon(icon)
.tooltip("DevFlow (MCP server running)")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app),
"quit" => app.exit(0), // 真正退出
_ => {}
})
.build(app)?;
app.manage(TrayHandle(tray));
Ok(())
}
/// 唤起主窗口:show + unminimize + set_focus(单实例二次启动 / 托盘菜单共用)。
pub(crate) fn show_main(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}