Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "shardx-launcher",
"private": true,
"version": "0.1.10",
"version": "0.1.11",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "shardx-launcher"
version = "0.1.10"
version = "0.1.11"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
Expand Down
1 change: 0 additions & 1 deletion src-tauri/src/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
73 changes: 70 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,75 @@ pub fn notify_store_changed(kind: &str) {
/// Download MCP server source into `<dir>/mcp`; user manages registration.
#[tauri::command]
async fn mcp_download(dir: String) -> Result<String, String> {
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<Value, String> {
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<Value, String> {
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 ----
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 93 additions & 1 deletion src-tauri/src/mcp_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dir>/mcp` and return that path.
///
/// If `<dir>` already is the MCP folder, repair/update it in place.
pub async fn download_mcp(dir: &Path) -> Result<PathBuf> {
let dest = dir.join("mcp");
let dest = download_destination(dir);
let bytes = reqwest::get(MCP_ARCHIVE_URL)
.await
.context("download MCP archive")?
Expand Down Expand Up @@ -64,3 +77,82 @@ pub async fn download_mcp(dir: &Path) -> Result<PathBuf> {
}
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<PathBuf> {
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<PathBuf> {
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);
}
}
4 changes: 4 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn default_theme() -> String {
Expand Down Expand Up @@ -63,6 +66,7 @@ pub fn load() -> Result<Settings> {
api_enabled: default_api_enabled(),
api_port: default_api_port(),
api_secret: String::new(),
mcp_path: None,
});
}
let body = fs::read_to_string(&path)?;
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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; }
Expand Down
Loading