diff --git a/package-lock.json b/package-lock.json index 827f342..a591547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shardx-launcher", - "version": "0.1.5", + "version": "0.1.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shardx-launcher", - "version": "0.1.5", + "version": "0.1.11", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/package.json b/package.json index 1f05384..1dcf5eb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "shardx-launcher", "private": true, - "version": "0.1.10", + "version": "0.1.11", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 67562e6..a1975cb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3863,7 +3863,7 @@ dependencies = [ [[package]] name = "shardx-launcher" -version = "0.1.10" +version = "0.1.11" dependencies = [ "aes", "aes-gcm", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a6a707..131bbd5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shardx-launcher" -version = "0.1.10" +version = "0.1.11" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 0b7ac92..ec022c7 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -200,7 +200,6 @@ pub async fn launch_profile( cmd.stdout(Stdio::null()).stderr(Stdio::null()); #[cfg(target_os = "windows")] { - use std::os::windows::process::CommandExt; // 0x08000000 = CREATE_NO_WINDOW — suppress the brief console flash // when a Tauri GUI app spawns the engine binary. cmd.creation_flags(0x08000000); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 66bcabd..7a1b6df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,10 +48,75 @@ pub fn notify_store_changed(kind: &str) { /// Download MCP server source into `/mcp`; user manages registration. #[tauri::command] async fn mcp_download(dir: String) -> Result { - mcp_setup::download_mcp(std::path::Path::new(&dir)) + let path = mcp_setup::download_mcp(std::path::Path::new(&dir)) .await - .map(|p| p.display().to_string()) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + let path_string = path.display().to_string(); + let mut s = settings::load().map_err(|e| e.to_string())?; + s.mcp_path = Some(path_string.clone()); + settings::save(&s).map_err(|e| e.to_string())?; + notify_store_changed("settings"); + Ok(path_string) +} + +#[tauri::command] +fn mcp_set_path(dir: String) -> Result { + let path = mcp_setup::resolve_mcp_dir(std::path::Path::new(&dir)) + .ok_or_else(|| "Selected folder is not a ShardX MCP server folder.".to_string())?; + let path = path.display().to_string(); + let mut s = settings::load().map_err(|e| e.to_string())?; + s.mcp_path = Some(path.clone()); + settings::save(&s).map_err(|e| e.to_string())?; + notify_store_changed("settings"); + Ok(serde_json::json!({ + "path": path, + "installed": true, + "state": "ready", + "message": "Using existing MCP server files.", + })) +} + +#[tauri::command] +fn mcp_status() -> Result { + let mut s = settings::load().map_err(|e| e.to_string())?; + if let Some(path) = &s.mcp_path { + let installed = mcp_setup::is_mcp_dir(std::path::Path::new(path)); + if installed { + return Ok(serde_json::json!({ + "path": path, + "installed": true, + "state": "ready", + "message": "MCP server files are downloaded.", + })); + } + } + + if let Some(path) = mcp_setup::find_existing_mcp() { + let path = path.display().to_string(); + s.mcp_path = Some(path.clone()); + settings::save(&s).map_err(|e| e.to_string())?; + return Ok(serde_json::json!({ + "path": path, + "installed": true, + "state": "ready", + "message": "Found existing MCP server files from a previous download.", + })); + } + + Ok(match s.mcp_path { + Some(path) => serde_json::json!({ + "path": path, + "installed": false, + "state": "missing", + "message": "Saved MCP folder is missing index.js or package.json.", + }), + None => serde_json::json!({ + "path": null, + "installed": false, + "state": "not_downloaded", + "message": "MCP server has not been downloaded yet.", + }), + }) } // ---- Profiles ---- @@ -1203,6 +1268,8 @@ pub fn run() { cookies_export_to_file, cookies_import, mcp_download, + mcp_set_path, + mcp_status, runtime::runtime_status, runtime::runtime_install, runtime::launcher_update_check, diff --git a/src-tauri/src/mcp_setup.rs b/src-tauri/src/mcp_setup.rs index c28ee79..c6106be 100644 --- a/src-tauri/src/mcp_setup.rs +++ b/src-tauri/src/mcp_setup.rs @@ -19,9 +19,22 @@ const MCP_ARCHIVE_URL: &str = /// Top-level directory inside the tarball that wraps the actual files. const MCP_TOP_DIR: &str = "ShardX-MCP"; +fn download_destination(dir: &Path) -> PathBuf { + // The picker text says "choose a folder" while the status box shows the + // actual `mcp` folder. If the user repairs by selecting that shown folder, + // update it in place instead of creating `mcp/mcp`. + if is_mcp_dir(dir) || dir.file_name().is_some_and(|name| name == "mcp") { + dir.to_path_buf() + } else { + dir.join("mcp") + } +} + /// Download the MCP server into `/mcp` and return that path. +/// +/// If `` already is the MCP folder, repair/update it in place. pub async fn download_mcp(dir: &Path) -> Result { - let dest = dir.join("mcp"); + let dest = download_destination(dir); let bytes = reqwest::get(MCP_ARCHIVE_URL) .await .context("download MCP archive")? @@ -64,3 +77,82 @@ pub async fn download_mcp(dir: &Path) -> Result { } Ok(dest) } + +pub fn is_mcp_dir(dir: &Path) -> bool { + if !dir.join("index.js").is_file() || !dir.join("package.json").is_file() { + return false; + } + std::fs::read_to_string(dir.join("package.json")) + .map(|s| s.contains("\"shardx-mcp\"")) + .unwrap_or(false) +} + +pub fn resolve_mcp_dir(dir: &Path) -> Option { + if is_mcp_dir(dir) { + return Some(dir.to_path_buf()); + } + let nested = dir.join("mcp"); + is_mcp_dir(&nested).then_some(nested) +} + +pub fn find_existing_mcp() -> Option { + let mut candidates = Vec::new(); + if let Some(dir) = dirs::document_dir() { + candidates.extend([ + dir.join("MCP").join("ShardBrowser").join("mcp"), + dir.join("MCP").join("mcp"), + dir.join("ShardBrowser").join("mcp"), + dir.join("GitHub").join("ShardBrowser").join("mcp"), + dir.join("mcp"), + ]); + } + if let Some(dir) = dirs::download_dir() { + candidates.push(dir.join("mcp")); + } + if let Some(dir) = dirs::desktop_dir() { + candidates.push(dir.join("mcp")); + } + candidates.into_iter().find_map(|p| resolve_mcp_dir(&p)) +} + +#[cfg(test)] +mod tests { + use super::{download_destination, is_mcp_dir}; + + #[test] + fn detects_downloaded_mcp_folder() { + let dir = std::env::temp_dir().join(format!("shardx-mcp-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("index.js"), "").unwrap(); + std::fs::write(dir.join("package.json"), r#"{ "name": "shardx-mcp" }"#).unwrap(); + + assert!(is_mcp_dir(&dir)); + assert_eq!(super::resolve_mcp_dir(&dir).as_deref(), Some(dir.as_path())); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn download_destination_avoids_nested_mcp_when_repairing() { + let base = std::env::temp_dir().join(format!( + "shardx-mcp-destination-test-{}", + std::process::id() + )); + let normal_parent = base.join("ShardBrowser"); + let existing_mcp = normal_parent.join("mcp"); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&existing_mcp).unwrap(); + std::fs::write(existing_mcp.join("index.js"), "").unwrap(); + std::fs::write( + existing_mcp.join("package.json"), + r#"{ "name": "shardx-mcp" }"#, + ) + .unwrap(); + + assert_eq!(download_destination(&normal_parent), existing_mcp); + assert_eq!(download_destination(&existing_mcp), existing_mcp); + + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ad04929..a8e9421 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -33,6 +33,9 @@ pub struct Settings { /// (see `ensure_secret`); rotating it invalidates issued tokens. #[serde(default)] pub api_secret: String, + /// Last downloaded MCP server folder, if any. + #[serde(default)] + pub mcp_path: Option, } fn default_theme() -> String { @@ -63,6 +66,7 @@ pub fn load() -> Result { api_enabled: default_api_enabled(), api_port: default_api_port(), api_secret: String::new(), + mcp_path: None, }); } let body = fs::read_to_string(&path)?; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9d3cbd0..423d96d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShardX Launcher", - "version": "0.1.10", + "version": "0.1.11", "identifier": "com.shardx.launcher", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.css b/src/App.css index 854018e..2d0e7c5 100644 --- a/src/App.css +++ b/src/App.css @@ -282,6 +282,11 @@ textarea { resize: vertical; min-height: 60px; line-height: 1.5; } } .side-auto-btn:hover { border-color: var(--accent); } .side-auto-btn:disabled { opacity: .5; cursor: default; } +.side-auto-btn-ok { + background: #4ade8014; + color: var(--ok); +} +.side-auto-btn-ok:hover { border-color: #4ade8055; } /* Theme switch — a 2-segment pill (Light | Dark) sitting just above the user pill. The active half gets the accent fill; clicking @@ -332,6 +337,7 @@ textarea { resize: vertical; min-height: 60px; line-height: 1.5; } .version-pill .shard-mini { color: var(--accent); flex: 0 0 auto; } .version-pill-text { display: flex; flex-direction: column; min-width: 0; } .version-pill-current { font-size: 12px; font-weight: 500; } +.version-pill-label { color: var(--accent); font-size: 10.5px; font-weight: 600; } .version-pill-sub { font-size: 10.5px; color: var(--tx-4); } .version-pill.update-available { @@ -1118,6 +1124,44 @@ textarea { resize: vertical; min-height: 60px; line-height: 1.5; } } .copy-icon:hover { color: var(--tx-1); background: var(--bg-1); } +.settings-steps { + margin: 10px 0 12px; + padding-left: 20px; + color: var(--tx-2); + font-size: 12px; + line-height: 1.5; +} +.settings-steps code { color: var(--tx-1); } +.mcp-status { + margin-top: 10px; + padding: 9px 10px; + border: 1px solid var(--bd-1); + border-radius: var(--radius); + background: var(--bg-2); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12px; +} +.mcp-status strong { color: var(--tx-1); white-space: nowrap; } +.mcp-status span { color: var(--tx-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mcp-status-ready { border-color: #4ade8033; } +.mcp-status-ready strong { color: var(--ok); } +.mcp-status-missing { border-color: #f59e0b55; } +.mcp-status-missing strong { color: #fbbf24; } +.mcp-setup-box { + margin-top: 10px; + padding: 10px; + border: 1px solid var(--bd-1); + border-radius: var(--radius); + background: var(--bg-2); +} +.mcp-setup-actions { + margin-top: 10px; + flex-wrap: wrap; +} + /* Confirm modal */ .dialog-confirm { max-width: 440px; } .confirm-msg { color: var(--tx-2); font-size: 13px; line-height: 1.5; margin: 0; } diff --git a/src/App.tsx b/src/App.tsx index 585118e..3953eae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ function detectHostOs(): "macOS" | "Windows" | "Linux" { return "macOS"; } const HOST_OS = detectHostOs(); +const CUSTOM_BUILD_LABEL = "custom build"; // OS clipboard via Tauri plugin (webview navigator.clipboard throws). const clip = { @@ -280,6 +281,7 @@ type Settings = { api_enabled?: boolean; api_port?: number; api_secret?: string; + mcp_path?: string | null; }; type ApiInfo = { enabled: boolean; @@ -287,6 +289,12 @@ type ApiInfo = { base_url: string; token: string; }; +type McpStatus = { + path: string | null; + installed: boolean; + state: "not_downloaded" | "ready" | "missing"; + message: string; +}; type Section = "browsers" | "proxies" | "proxyshard" | "fingerprints" | "settings"; /// Library fingerprint backing the editor GPU select; payload supplies the coherent base. @@ -701,22 +709,15 @@ function Sidebar({ // Automation/MCP quick widget (fills the sidebar's lower space). const [autoUrl, setAutoUrl] = useState(""); - const [mcpBusy, setMcpBusy] = useState(false); + const [mcp, setMcp] = useState(null); + const refreshMcp = () => invoke("mcp_status").then(setMcp).catch(() => {}); useEffect(() => { invoke<{ base_url: string; enabled: boolean }>("api_info") .then((i) => setAutoUrl(i.enabled ? i.base_url : "")) .catch(() => {}); + refreshMcp(); }, []); - const downloadMcp = async () => { - const dir = await open({ directory: true, title: "Where to download the MCP server" }); - if (typeof dir !== "string") return; - setMcpBusy(true); - try { - const p = await invoke("mcp_download", { dir }); - toast.ok(`MCP downloaded to ${p}`); - } catch (e) { toast.err("MCP download failed: " + String(e)); } - finally { setMcpBusy(false); } - }; + useStoreChanged(refreshMcp); return (