#[cfg(not(windows))] use std::env; use std::path::Path; use std::process::Command; use std::time::Duration; use serde::{Deserialize, Serialize}; const MCP_PACKAGE_NAME: &str = "@dbx-app/mcp-server"; const MCP_LATEST_URL: &str = "https://registry.npmjs.org/@dbx-app%2fmcp-server/latest"; const MCP_INSTALL_COMMAND: &str = "npm install -g @dbx-app/mcp-server@latest --registry=https://registry.npmjs.org"; const SHELL_COMMAND_MARKER: &str = "__DBX_MCP_COMMAND_OUTPUT_START__"; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; #[derive(Debug, Serialize)] pub struct McpServerStatus { pub installed: bool, pub npm_available: bool, pub node_version: Option, pub current_version: Option, pub latest_version: Option, pub update_available: bool, pub bin_path: Option, pub install_command: String, pub update_command: String, pub error: Option, } #[derive(Debug, Deserialize)] struct NpmLatestPackage { version: String, } #[tauri::command] pub async fn check_mcp_server_status() -> Result { let local_status = tauri::async_runtime::spawn_blocking(|| { let npm_available = command_success("npm", &["--version"]); let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line); let current_version = if npm_available { installed_mcp_version() } else { None }; let bin_path = locate_mcp_bin(); (npm_available, node_version, current_version, bin_path) }); let latest_version = fetch_latest_mcp_version(); let (local_status, latest_version) = tokio::join!(local_status, latest_version); let (npm_available, node_version, current_version, bin_path) = local_status.map_err(|err| err.to_string())?; let latest_version = latest_version.ok(); let update_available = current_version .as_deref() .zip(latest_version.as_deref()) .is_some_and(|(current, latest)| dbx_core::update::is_newer_version(latest, current)); let error = if npm_available { None } else { Some("npm is not available in PATH.".to_string()) }; Ok(McpServerStatus { installed: current_version.is_some() || bin_path.is_some(), npm_available, node_version, current_version, latest_version, update_available, bin_path, install_command: MCP_INSTALL_COMMAND.to_string(), update_command: MCP_INSTALL_COMMAND.to_string(), error, }) } #[tauri::command] pub async fn install_mcp_server() -> Result { tauri::async_runtime::spawn_blocking(|| { let output = command_output( "npm", &["install", "-g", "@dbx-app/mcp-server@latest", "--registry=https://registry.npmjs.org"], )?; if !output.success { let error_msg = if !output.stderr.is_empty() { output.stderr } else { output.stdout }; return Err(format!("Installation failed: {}", error_msg)); } let version = installed_mcp_version().unwrap_or_else(|| "unknown".to_string()); Ok(format!("Successfully installed @dbx-app/mcp-server@{}", version)) }) .await .map_err(|e| e.to_string())? } async fn fetch_latest_mcp_version() -> Result { let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker"); let proxy_url = tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.map_err(|e| e.to_string())?; if let Some(proxy_url) = proxy_url { let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid system proxy URL: {e}"))?; builder = builder.proxy(proxy); } let client = builder.build().map_err(|e| format!("Failed to create HTTP client: {e}"))?; let package = client .get(MCP_LATEST_URL) .send() .await .and_then(|r| r.error_for_status()) .map_err(|e| format!("Failed to check MCP Server updates: {e}"))? .json::() .await .map_err(|e| format!("Failed to parse MCP Server update response: {e}"))?; Ok(package.version) } fn installed_mcp_version() -> Option { let root = command_stdout("npm", &["root", "-g"]).ok()?; let pkg_json_path = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("package.json"); let content = std::fs::read_to_string(pkg_json_path).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; value.get("version")?.as_str().map(ToOwned::to_owned) } pub(crate) fn resolve_mcp_server_command() -> Option<(String, Vec)> { locate_mcp_bin() .map(|path| (path, Vec::new())) .or_else(|| installed_mcp_bin_script().map(|script| ("node".to_string(), vec![script]))) } pub(crate) fn locate_command(command: &str) -> Option { #[cfg(windows)] { return locate_windows_command(command); } #[cfg(not(windows))] { command_stdout("which", &[command]).ok().and_then(first_non_empty_line) } } fn locate_mcp_bin() -> Option { locate_command("dbx-mcp-server") } fn installed_mcp_bin_script() -> Option { let root = command_stdout("npm", &["root", "-g"]).ok()?; let script = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("dist").join("index.js"); script.is_file().then(|| script.to_string_lossy().to_string()) } #[cfg(windows)] fn locate_windows_command(command: &str) -> Option { command_stdout("where", &[command]) .ok() .and_then(first_windows_command_path) .or_else(|| { let script = format!("(Get-Command -All {} -ErrorAction SilentlyContinue).Source", windows_shell_quote(command)); command_stdout("powershell.exe", &["-NoProfile", "-Command", &script]) .ok() .and_then(first_windows_command_path) }) .or_else(|| windows_command_candidates(command).into_iter().find(|candidate| Path::new(candidate).is_file())) } #[cfg(windows)] fn first_windows_command_path(value: String) -> Option { let paths = value.lines().map(str::trim).filter(|line| !line.is_empty()).collect::>(); if let Some(path) = paths.iter().copied().find(|path| is_windows_launchable_command(path) && Path::new(path).is_file()) { return Some(path.to_string()); } paths.into_iter().find(|path| Path::new(path).is_file()).map(ToOwned::to_owned) } #[cfg(windows)] fn is_windows_launchable_command(path: &str) -> bool { matches!( Path::new(path).extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(), Some("exe" | "cmd" | "bat" | "com") ) } fn command_success(command: &str, args: &[&str]) -> bool { command_output(command, args).is_ok_and(|output| output.success) } fn command_stdout(command: &str, args: &[&str]) -> Result { let output = command_output(command, args)?; if !output.success { return Err(output.stderr.trim().to_string()); } Ok(output.stdout.trim().to_string()) } fn first_non_empty_line(value: String) -> Option { value.lines().map(str::trim).find(|line| !line.is_empty()).map(ToOwned::to_owned) } #[derive(Debug)] struct CommandOutput { success: bool, stdout: String, stderr: String, } fn command_output(command: &str, args: &[&str]) -> Result { let direct = run_command(command, args); if direct.as_ref().is_ok_and(|output| output.success) { return direct; } #[cfg(windows)] { return run_windows_command_candidates(command, args).or(direct); } #[cfg(not(windows))] { run_command_through_user_shell(command, args).or(direct) } } fn run_command(command: &str, args: &[&str]) -> Result { let mut cmd = Command::new(command); cmd.args(args); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(CREATE_NO_WINDOW); } let output = cmd.output().map_err(|e| e.to_string())?; Ok(CommandOutput { success: output.status.success(), stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), }) } #[cfg(windows)] fn run_windows_command_candidates(command: &str, args: &[&str]) -> Result { for candidate in windows_command_candidates(command) { let output = run_command(&candidate, args); if output.as_ref().is_ok_and(|output| output.success) { return output; } } run_command_through_user_shell(command, args) } #[cfg(windows)] fn windows_command_candidates(command: &str) -> Vec { if Path::new(command).extension().is_some() { return Vec::new(); } let names = ["cmd", "exe", "bat", "com", "ps1"].iter().map(|extension| format!("{command}.{extension}")); names .clone() .chain( windows_common_command_dirs() .into_iter() .flat_map(|dir| names.clone().map(move |name| dir.join(name).to_string_lossy().to_string())), ) .collect() } #[cfg(windows)] fn windows_common_command_dirs() -> Vec { let mut dirs = Vec::new(); if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") { dirs.push(nvm_symlink.into()); } if let Ok(app_data) = std::env::var("APPDATA") { dirs.push(std::path::PathBuf::from(app_data).join("npm")); } if let Ok(program_files) = std::env::var("ProgramFiles") { dirs.push(std::path::PathBuf::from(program_files).join("nodejs")); } if let Ok(program_files_x86) = std::env::var("ProgramFiles(x86)") { dirs.push(std::path::PathBuf::from(program_files_x86).join("nodejs")); } dirs.push(std::path::PathBuf::from(r"C:\nvm4w\nodejs")); dirs } #[cfg(windows)] fn run_command_through_user_shell(command: &str, args: &[&str]) -> Result { let script = windows_command_script(command, args); let mut output = run_command("powershell.exe", &["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script])?; output.stdout = stdout_after_shell_marker(&output.stdout); Ok(output) } #[cfg(windows)] fn windows_command_script(command: &str, args: &[&str]) -> String { let mut words = Vec::with_capacity(args.len() + 1); words.push(windows_shell_quote(command)); words.extend(args.iter().map(|arg| windows_shell_quote(arg))); format!("Write-Output {}; & {}", windows_shell_quote(SHELL_COMMAND_MARKER), words.join(" ")) } #[cfg(windows)] fn windows_shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } #[cfg(not(windows))] fn run_command_through_user_shell(command: &str, args: &[&str]) -> Result { let script = shell_command_script(command, args); let (shell, shell_args) = user_shell_invocation_args(&script); let shell_arg_refs = shell_args.iter().map(String::as_str).collect::>(); let mut output = run_command(&shell, &shell_arg_refs)?; output.stdout = stdout_after_shell_marker(&output.stdout); Ok(output) } #[cfg(not(windows))] fn user_shell_invocation_args(script: &str) -> (String, Vec) { let shell = env::var("SHELL").ok().filter(|value| !value.trim().is_empty()).unwrap_or_else(default_user_shell); let shell_name = Path::new(&shell).file_name().and_then(|value| value.to_str()).unwrap_or_default(); let args = match shell_name { "fish" => vec!["-l".to_string(), "-i".to_string(), "-c".to_string(), script.to_string()], "bash" => vec![ "--noprofile".to_string(), "--norc".to_string(), "-i".to_string(), "-c".to_string(), bash_login_script(script), ], "sh" | "dash" => vec!["-ic".to_string(), script.to_string()], "zsh" => vec!["-ilc".to_string(), script.to_string()], _ => vec!["-lc".to_string(), script.to_string()], }; (shell, args) } #[cfg(not(windows))] fn bash_login_script(script: &str) -> String { format!( "for dbx_profile in ~/.bash_profile ~/.bash_login ~/.profile ~/.bashrc; do \ [ -r \"$dbx_profile\" ] && . \"$dbx_profile\"; \ done; unset dbx_profile; {script}" ) } #[cfg(not(windows))] fn default_user_shell() -> String { if Path::new("/bin/zsh").exists() { "/bin/zsh".to_string() } else { "/bin/sh".to_string() } } #[cfg(not(windows))] fn shell_command_script(command: &str, args: &[&str]) -> String { let mut words = Vec::with_capacity(args.len() + 1); words.push(shell_quote(command)); words.extend(args.iter().map(|arg| shell_quote(arg))); format!("printf '%s\\n' {}; {}", shell_quote(SHELL_COMMAND_MARKER), words.join(" ")) } #[cfg(not(windows))] fn shell_quote(value: &str) -> String { if value.is_empty() { return "''".to_string(); } format!("'{}'", value.replace('\'', "'\"'\"'")) } fn stdout_after_shell_marker(stdout: &str) -> String { stdout .find(SHELL_COMMAND_MARKER) .map(|index| stdout[index + SHELL_COMMAND_MARKER.len()..].trim_start_matches(['\r', '\n']).to_string()) .unwrap_or_else(|| stdout.to_string()) } #[cfg(test)] mod tests { #[cfg(not(windows))] use super::bash_login_script; #[cfg(not(windows))] use super::{shell_command_script, shell_quote}; use super::{stdout_after_shell_marker, SHELL_COMMAND_MARKER}; #[cfg(not(windows))] #[test] fn shell_quote_handles_empty_and_single_quotes() { assert_eq!(shell_quote(""), "''"); assert_eq!(shell_quote("npm"), "'npm'"); assert_eq!(shell_quote("can't"), "'can'\"'\"'t'"); } #[cfg(not(windows))] #[test] fn shell_command_script_marks_command_output_after_startup_noise() { let script = shell_command_script("npm", &["list", "-g", "@dbx-app/mcp-server", "--json"]); assert!(script.contains(SHELL_COMMAND_MARKER)); assert!(script.contains("'@dbx-app/mcp-server'")); } #[cfg(not(windows))] #[test] fn bash_login_script_sources_profile_and_rc_files() { let script = bash_login_script("node --version"); assert!(script.contains("~/.bash_profile")); assert!(script.contains("~/.bashrc")); assert!(script.ends_with("node --version")); } #[test] fn stdout_after_shell_marker_ignores_shell_startup_output() { let stdout = format!("loading profile\n{SHELL_COMMAND_MARKER}\n22.19.0\n"); assert_eq!(stdout_after_shell_marker(&stdout), "22.19.0\n"); } }