diff --git a/.github/GIT_HOOKS.md b/.github/GIT_HOOKS.md new file mode 100644 index 00000000..43e986a6 --- /dev/null +++ b/.github/GIT_HOOKS.md @@ -0,0 +1,87 @@ +# Git Hooks + +This project uses Git hooks to maintain code quality and prevent CI failures. + +## Pre-commit Hook + +The pre-commit hook automatically runs before each commit to validate: + +### ✅ **Rust Validation** +- Formats Rust code with `cargo fmt` +- Runs `cargo clippy --workspace -- -D warnings` to catch linting issues +- Runs `cargo check --workspace` to verify compilation +- Automatically adds formatted files to the commit + +### ✅ **TypeScript Validation** +- Runs `pnpm lint` to check code style and catch common errors +- Runs `pnpm typecheck` to verify type correctness +- Checks all TypeScript files in the workspace + +## Setup + +The hook is automatically set up when you: +1. Clone the repository +2. Run `pnpm install` (triggers `prepare` script) + +### Manual Setup + +If the hook isn't working, run: + +```bash +# Make hook executable (Linux/Mac) +chmod +x .git/hooks/pre-commit + +# Windows (PowerShell) +icacls ".git\hooks\pre-commit" /grant Everyone:RX +``` + +## Testing the Hook + +To test the validation without committing: + +```bash +# Run both checks +pnpm validate + +# Or individually +cargo clippy --workspace -- -D warnings +cargo check --workspace +pnpm lint +pnpm typecheck +``` + +## Bypassing the Hook (Not Recommended) + +In rare cases where you need to bypass validation: + +```bash +git commit --no-verify -m "your message" +``` + +**Note**: Only use `--no-verify` when absolutely necessary, as it skips important validations that prevent CI failures. + +## Troubleshooting + +### Hook not running +- Ensure `.git/hooks/pre-commit` exists +- Check it's executable: `ls -la .git/hooks/pre-commit` +- Try manual setup commands above + +### Hook fails on Windows +- The hook uses bash scripting (requires Git Bash or WSL) +- Alternatively, use the PowerShell version: `.git/hooks/pre-commit.ps1` +- Configure Git to use PowerShell hooks: + ```powershell + git config core.hooksPath .git/hooks + ``` + +### Slow validation +- The hook only validates files in your commit (staged changes) +- If you have many Rust crates, consider using `cargo check -p ` +- TypeScript check runs on entire workspace (necessary for type consistency) + +## CI Integration + +These same checks run in CI: +- GitHub Actions runs `cargo check` and `pnpm typecheck` +- Pre-commit hooks help catch issues early before pushing diff --git a/.github/actions/install-linux-deps/action.yml b/.github/actions/install-linux-deps/action.yml index f4e494e8..10ced8fe 100644 --- a/.github/actions/install-linux-deps/action.yml +++ b/.github/actions/install-linux-deps/action.yml @@ -16,15 +16,15 @@ runs: - name: Cache apt packages (base) uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: build-essential pkg-config libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libsecret-1-dev - version: 1.0 + packages: build-essential pkg-config libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libsecret-1-dev libfuse2 + version: 1.1 - name: Cache apt packages (E2E) if: ${{ inputs.e2e == 'true' }} uses: awalsh128/cache-apt-pkgs-action@latest with: - packages: webkit2gtk-driver xvfb gnome-keyring gsettings-desktop-schemas dbus-x11 at-spi2-core libglib2.0-bin - version: 1.2 + packages: webkit2gtk-driver xvfb gnome-keyring gsettings-desktop-schemas dbus-x11 at-spi2-core libglib2.0-bin libwayland-server0 libwayland-client0 + version: 1.3 # Compile gsettings schemas (required after restore from cache) - name: Compile gsettings schemas diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 259cad93..d0473a1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,6 +242,7 @@ jobs: - run: pnpm build env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} # ───────────────────────────────────────────────────────────── # Test Results Report (separate checks per test type and OS) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index 74f48525..401baaf8 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -12,6 +12,8 @@ on: secrets: TAURI_SIGNING_PRIVATE_KEY: required: false + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: + required: false env: PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig @@ -95,11 +97,20 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Build app - run: pnpm build + - name: Build app (Linux) + if: matrix.os == 'ubuntu-latest' + run: pnpm --filter @mcpmux/desktop exec tauri build --bundles deb,rpm,updater env: PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + + - name: Build app (Windows) + if: matrix.os == 'windows-latest' + run: pnpm build + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - name: Run desktop E2E tests (Linux) if: matrix.os == 'ubuntu-latest' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6a053353..65cd3e1a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -57,6 +57,7 @@ jobs: - run: pnpm build env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig - name: Upload artifacts diff --git a/Cargo.lock b/Cargo.lock index 1080684a..30e6afd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -260,6 +260,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -427,6 +438,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -930,6 +947,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "5.0.1" @@ -948,6 +974,17 @@ dependencies = [ "dirs-sys 0.5.0", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -1090,7 +1127,7 @@ dependencies = [ "rustc_version", "toml 0.9.11+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1986,7 +2023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -2097,6 +2134,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.0", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2505,6 +2555,7 @@ dependencies = [ "chrono", "dirs 5.0.1", "dotenvy", + "image", "keyring", "mcpmux-core", "mcpmux-gateway", @@ -2517,6 +2568,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-deep-link", "tauri-plugin-opener", "tauri-plugin-single-instance", @@ -2685,6 +2737,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2700,7 +2762,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -3486,6 +3548,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -3622,6 +3697,15 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4952,7 +5036,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4999,6 +5083,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-deep-link" version = "2.4.6" @@ -5661,7 +5759,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -6687,6 +6785,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7ad78ec6..c4696e54 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,6 +20,8 @@ tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" tauri-plugin-deep-link = "2" tauri-plugin-updater = "2" +tauri-plugin-autostart = "2" +image = { version = "0.25", default-features = false, features = ["png"] } serde.workspace = true serde_json.workspace = true tokio.workspace = true @@ -38,11 +40,12 @@ url.workspace = true urlencoding = "2.1" open = "5.3" dotenvy.workspace = true -notify = { version = "7", default-features = false, features = ["macos_fsevent"] } +notify = { version = "7", default-features = false, features = [ + "macos_fsevent", +] } notify-debouncer-mini = "0.5" # Internal crates (path-only, no version needed) mcpmux-core.workspace = true mcpmux-gateway.workspace = true mcpmux-storage.workspace = true - diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index f5eee08b..9c6f72fb 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod server; pub mod server_discovery; pub mod server_feature; pub mod server_manager; +pub mod settings; pub mod space; // Re-export commands for convenience @@ -31,4 +32,5 @@ pub use server::*; pub use server_discovery::*; pub use server_feature::*; pub use server_manager::*; +pub use settings::*; pub use space::*; diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs new file mode 100644 index 00000000..dde5b369 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -0,0 +1,217 @@ +//! Settings commands for auto-start and system tray behavior + +use serde::{Deserialize, Serialize}; +use tauri::State; +use tauri_plugin_autostart::AutoLaunchManager; +use tracing::{debug, info}; + +use crate::state::AppState; + +/// Startup and system tray settings +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupSettings { + /// Whether to launch the app at system startup + pub auto_launch: bool, + /// Whether to start minimized to tray + pub start_minimized: bool, + /// Whether to minimize to tray instead of closing + pub close_to_tray: bool, +} + +impl Default for StartupSettings { + fn default() -> Self { + Self { + auto_launch: false, + start_minimized: false, + close_to_tray: true, // Default to close-to-tray behavior + } + } +} + +/// Get current startup settings +#[tauri::command] +pub async fn get_startup_settings( + app_state: State<'_, AppState>, + manager: State<'_, AutoLaunchManager>, +) -> Result { + debug!("[Settings] Getting startup settings"); + + let settings_repo = &app_state.settings_repository; + + // Get auto-launch status from the OS + let auto_launch = manager + .is_enabled() + .map_err(|e| format!("Failed to check auto-launch status: {}", e))?; + + // Get other settings from database + let start_minimized = settings_repo + .get("startup.start_minimized") + .await + .map_err(|e| format!("Failed to get start_minimized setting: {}", e))? + .map(|v| v == "true") + .unwrap_or(false); + + let close_to_tray = settings_repo + .get("ui.close_to_tray") + .await + .map_err(|e| format!("Failed to get close_to_tray setting: {}", e))? + .map(|v| v == "true") + .unwrap_or(true); // Default to true + + Ok(StartupSettings { + auto_launch, + start_minimized, + close_to_tray, + }) +} + +/// Update startup settings +#[tauri::command] +pub async fn update_startup_settings( + settings: StartupSettings, + app_state: State<'_, AppState>, + manager: State<'_, AutoLaunchManager>, +) -> Result<(), String> { + info!("[Settings] Updating startup settings: {:?}", settings); + + let settings_repo = &app_state.settings_repository; + + // Check if auto-launch setting has changed before modifying OS + let current_auto_launch = manager.is_enabled().unwrap_or(false); + + if settings.auto_launch != current_auto_launch { + if settings.auto_launch { + manager + .enable() + .map_err(|e| format!("Failed to enable auto-launch: {}", e))?; + info!("[Settings] Auto-launch enabled"); + } else { + manager + .disable() + .map_err(|e| format!("Failed to disable auto-launch: {}", e))?; + info!("[Settings] Auto-launch disabled"); + } + } else { + info!("[Settings] Auto-launch unchanged, skipping OS update"); + } + + // Update other settings in database + settings_repo + .set( + "startup.start_minimized", + &settings.start_minimized.to_string(), + ) + .await + .map_err(|e| format!("Failed to save start_minimized setting: {}", e))?; + + settings_repo + .set("ui.close_to_tray", &settings.close_to_tray.to_string()) + .await + .map_err(|e| format!("Failed to save close_to_tray setting: {}", e))?; + + info!("[Settings] Startup settings updated successfully"); + Ok(()) +} + +/// Check if app should start hidden (for auto-launch with --hidden flag) +pub fn should_start_hidden() -> bool { + let args: Vec = std::env::args().collect(); + args.contains(&"--hidden".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_startup_settings_default() { + let settings = StartupSettings::default(); + assert_eq!(settings.auto_launch, false); + assert_eq!(settings.start_minimized, false); + assert_eq!(settings.close_to_tray, true); + } + + #[test] + fn test_startup_settings_serialization() { + let settings = StartupSettings { + auto_launch: true, + start_minimized: false, + close_to_tray: true, + }; + + let json = serde_json::to_string(&settings).unwrap(); + assert!(json.contains("\"autoLaunch\":true")); + assert!(json.contains("\"startMinimized\":false")); + assert!(json.contains("\"closeToTray\":true")); + } + + #[test] + fn test_startup_settings_deserialization() { + let json = r#"{"autoLaunch":true,"startMinimized":true,"closeToTray":false}"#; + let settings: StartupSettings = serde_json::from_str(json).unwrap(); + + assert_eq!(settings.auto_launch, true); + assert_eq!(settings.start_minimized, true); + assert_eq!(settings.close_to_tray, false); + } + + #[test] + fn test_should_start_hidden_without_flag() { + // This test might be tricky as it depends on actual process args + // In a real test environment, we'd mock std::env::args + // For now, we just verify the function exists and can be called + let _result = should_start_hidden(); + // Can't assert the actual value since it depends on how tests are run + } + + #[test] + fn test_startup_settings_clone() { + let settings = StartupSettings { + auto_launch: true, + start_minimized: false, + close_to_tray: true, + }; + + let cloned = settings.clone(); + assert_eq!(settings.auto_launch, cloned.auto_launch); + assert_eq!(settings.start_minimized, cloned.start_minimized); + assert_eq!(settings.close_to_tray, cloned.close_to_tray); + } + + #[test] + fn test_startup_settings_debug() { + let settings = StartupSettings::default(); + let debug_str = format!("{:?}", settings); + assert!(debug_str.contains("StartupSettings")); + assert!(debug_str.contains("auto_launch")); + assert!(debug_str.contains("start_minimized")); + assert!(debug_str.contains("close_to_tray")); + } + + #[test] + fn test_startup_settings_with_all_enabled() { + let settings = StartupSettings { + auto_launch: true, + start_minimized: true, + close_to_tray: true, + }; + + assert!(settings.auto_launch); + assert!(settings.start_minimized); + assert!(settings.close_to_tray); + } + + #[test] + fn test_startup_settings_with_all_disabled() { + let settings = StartupSettings { + auto_launch: false, + start_minimized: false, + close_to_tray: false, + }; + + assert!(!settings.auto_launch); + assert!(!settings.start_minimized); + assert!(!settings.close_to_tray); + } +} diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index 8b86f485..7f0bbfc7 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -12,6 +12,7 @@ use uuid::Uuid; use crate::commands::gateway::GatewayAppState; use crate::state::AppState; +use crate::tray; /// Space change event payload #[derive(Debug, Clone, Serialize)] @@ -76,6 +77,7 @@ const DEFAULT_SPACE_CONFIG: &str = r#"{ pub async fn create_space( name: String, icon: Option, + app: AppHandle, state: State<'_, AppState>, gateway_state: State<'_, Arc>>, ) -> Result { @@ -109,6 +111,14 @@ pub async fn create_space( }); } + // Update system tray menu to show the new space + // Only reached if both space creation and config file writing succeeded + if let Err(e) = tray::update_tray_spaces(&app, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[create_space] Space '{}' created successfully", space.name); + Ok(space) } @@ -116,6 +126,7 @@ pub async fn create_space( #[tauri::command] pub async fn delete_space( id: String, + app: AppHandle, state: State<'_, AppState>, gateway_state: State<'_, Arc>>, ) -> Result<(), String> { @@ -134,6 +145,14 @@ pub async fn delete_space( gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceDeleted { space_id: uuid }); } + // Update system tray menu to remove the deleted space + // Only reached if space deletion from DB succeeded + if let Err(e) = tray::update_tray_spaces(&app, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[delete_space] Space '{}' deleted successfully", uuid); + Ok(()) } @@ -225,7 +244,7 @@ pub async fn set_active_space( let event = SpaceChangeEvent { from_space_id: old_space.map(|s| s.id.to_string()), to_space_id: new_space.id.to_string(), - to_space_name: new_space.name, + to_space_name: new_space.name.clone(), clients_needing_confirmation: clients_needing_confirmation.clone(), }; @@ -242,6 +261,14 @@ pub async fn set_active_space( // will be emitted by the gateway when they make their next request // and the SpaceResolver returns the new active space. + // Update system tray menu to show checkmark (✓) on the newly active space + // Only reached if set_active operation succeeded in DB + if let Err(e) = tray::update_tray_spaces(&app_handle, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[set_active_space] Switched to space '{}'", new_space.name); + Ok(()) } @@ -372,3 +399,11 @@ pub async fn remove_server_from_config( Ok(false) } + +/// Refresh the system tray menu to reflect current spaces +#[tauri::command] +pub async fn refresh_tray_menu(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { + tray::update_tray_spaces(&app, &state) + .await + .map_err(|e| format!("Failed to update tray menu: {}", e)) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ccd43237..49527560 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -189,6 +189,10 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--hidden"]), // Start minimized to tray + )) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { // This callback is called when a second instance is launched @@ -518,6 +522,53 @@ pub fn run() { // Setup system tray tray::setup_tray(app.handle())?; + // Setup window close event handler for close-to-tray behavior + if let Some(main_window) = app.get_webview_window("main") { + let app_handle = app.handle().clone(); + let settings_repo = app_state.settings_repository.clone(); + + main_window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + // Check if close-to-tray is enabled + let app_handle_clone = app_handle.clone(); + let settings_clone = settings_repo.clone(); + + tauri::async_runtime::spawn(async move { + match settings_clone.get("ui.close_to_tray").await { + Ok(Some(value)) if value == "true" => { + // Close to tray - hide window instead of closing + info!("[Window] Close requested, hiding to tray"); + if let Some(window) = app_handle_clone.get_webview_window("main") { + let _ = window.hide(); + } + } + Ok(Some(value)) if value == "false" => { + // Actually close the app + info!("[Window] Close requested, exiting app"); + app_handle_clone.exit(0); + } + _ => { + // Default behavior: close to tray + info!("[Window] Close requested (default), hiding to tray"); + if let Some(window) = app_handle_clone.get_webview_window("main") { + let _ = window.hide(); + } + } + } + }); + + // Always prevent default close to handle it asynchronously + api.prevent_close(); + } + }); + + // Check if app should start hidden (auto-launch with --hidden flag) + if commands::should_start_hidden() { + info!("[Window] Starting hidden (--hidden flag present)"); + let _ = main_window.hide(); + } + } + // Register deep link handler for when app receives URLs #[cfg(desktop)] { @@ -549,6 +600,7 @@ pub fn run() { commands::read_space_config, commands::save_space_config, commands::remove_server_from_config, + commands::refresh_tray_menu, // Server Discovery commands (v2) commands::discover_servers, commands::get_server_definition, @@ -645,6 +697,9 @@ pub fn run() { // App log commands get_logs_path, open_logs_folder, + // Startup settings commands + commands::get_startup_settings, + commands::update_startup_settings, ]) .run(tauri::generate_context!()) .expect("error while running McpMux application"); diff --git a/apps/desktop/src-tauri/src/services/file_watcher.rs b/apps/desktop/src-tauri/src/services/file_watcher.rs index 076109a5..e3bfe3a4 100644 --- a/apps/desktop/src-tauri/src/services/file_watcher.rs +++ b/apps/desktop/src-tauri/src/services/file_watcher.rs @@ -223,7 +223,6 @@ impl SpaceFileWatcherBuilder { #[cfg(test)] mod tests { - use super::*; #[test] fn test_builder_default_space_id() { diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 33401b10..6c330568 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -2,12 +2,12 @@ //! //! Provides a system tray icon with quick access to: //! - Space switching -//! - Config export -//! - Server status //! - Open main window +//! - Quit application use tauri::{ - menu::{Menu, MenuBuilder, MenuItemBuilder, PredefinedMenuItem, SubmenuBuilder}, + image::Image, + menu::{Menu, MenuBuilder, SubmenuBuilder}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Emitter, Manager, Runtime, }; @@ -35,8 +35,19 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { let menu = build_tray_menu(app)?; + // Load tray icon - decode PNG and convert to RGBA + let icon_bytes = include_bytes!("../icons/32x32.png"); + let img = image::load_from_memory(icon_bytes) + .map_err(|e| { + tauri::Error::InvalidIcon(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + })? + .to_rgba8(); + let (width, height) = img.dimensions(); + let icon = Image::new_owned(img.into_raw(), width, height); + let _tray = TrayIconBuilder::with_id("mcpmux-tray") .tooltip("McpMux - MCP Server Manager") + .icon(icon) .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(move |app, event| { @@ -65,36 +76,18 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { /// Build the tray menu fn build_tray_menu(app: &AppHandle) -> tauri::Result> { - // Space submenu + // Space submenu (will be populated dynamically) let space_submenu = SubmenuBuilder::new(app, "Active Space") .text("space_default", "🌐 Default") - .separator() - .text("create_space", "➕ Create Space...") - .build()?; - - // Export submenu - let export_submenu = SubmenuBuilder::new(app, "📋 Export Config") - .text("export_cursor", "Cursor") - .text("export_vscode", "VS Code") - .text("export_claude", "Claude Desktop") .build()?; - // Build main menu + // Build simplified main menu let menu = MenuBuilder::new(app) - .item( - &MenuItemBuilder::with_id("status", "McpMux 🟢") - .enabled(false) - .build(app)?, - ) - .separator() .item(&space_submenu) .separator() - .text("refresh", "🔄 Refresh All Servers") - .item(&export_submenu) + .text("open", "Open McpMux") .separator() - .text("open", "⚙️ Open McpMux") - .item(&PredefinedMenuItem::separator(app)?) - .text("quit", "❌ Quit") + .text("quit", "Quit") .build()?; Ok(menu) @@ -110,25 +103,6 @@ fn handle_menu_event(app: &AppHandle, event_id: &str) { let space_id = id.strip_prefix("space_").unwrap_or("default"); handle_switch_space(app, space_id); } - "create_space" => { - open_main_window_at(app, "/spaces/new"); - } - - // Export actions - "export_cursor" => { - handle_export(app, "cursor"); - } - "export_vscode" => { - handle_export(app, "vscode"); - } - "export_claude" => { - handle_export(app, "claude"); - } - - // General actions - "refresh" => { - handle_refresh_servers(app); - } "open" => { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); @@ -149,38 +123,15 @@ fn handle_menu_event(app: &AppHandle, event_id: &str) { fn handle_switch_space(app: &AppHandle, space_id: &str) { info!("Switching to space: {}", space_id); - // Emit event to frontend - let _ = app.emit("tray:switch-space", space_id); -} - -/// Open main window at a specific route -fn open_main_window_at(app: &AppHandle, route: &str) { + // Show window and emit event to frontend if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); - // Emit navigation event - let _ = app.emit("tray:navigate", route); } -} - -/// Handle export request -fn handle_export(app: &AppHandle, client_type: &str) { - info!("Export config requested for: {}", client_type); - - // Emit event to frontend to handle export - let _ = app.emit("tray:export-config", client_type); -} - -/// Handle refresh all servers -fn handle_refresh_servers(app: &AppHandle) { - info!("Refresh all servers requested"); - - // Emit event to frontend - let _ = app.emit("tray:refresh-servers", ()); + let _ = app.emit("tray:switch-space", space_id); } /// Update tray menu with current spaces -#[allow(dead_code)] pub async fn update_tray_spaces( app: &AppHandle, state: &AppState, @@ -205,34 +156,15 @@ pub async fn update_tray_spaces( space_menu = space_menu.text(id, label); } - space_menu = space_menu - .separator() - .text("create_space", "➕ Create Space..."); - let space_submenu = space_menu.build()?; - // Rebuild full menu - let export_submenu = SubmenuBuilder::new(app, "📋 Export Config") - .text("export_cursor", "Cursor") - .text("export_vscode", "VS Code") - .text("export_claude", "Claude Desktop") - .build()?; - + // Rebuild simplified menu let menu = MenuBuilder::new(app) - .item( - &MenuItemBuilder::with_id("status", "McpMux 🟢") - .enabled(false) - .build(app)?, - ) - .separator() .item(&space_submenu) .separator() - .text("refresh", "🔄 Refresh All Servers") - .item(&export_submenu) + .text("open", "Open McpMux") .separator() - .text("open", "⚙️ Open McpMux") - .item(&PredefinedMenuItem::separator(app)?) - .text("quit", "❌ Quit") + .text("quit", "Quit") .build()?; tray.set_menu(Some(menu))?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b26d7a16..a7f42eee 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -83,9 +83,6 @@ } }, "linux": { - "appimage": { - "bundleMediaFramework": false - }, "deb": { "depends": [ "libsecret-1-0", diff --git a/apps/desktop/src/features/registry/ServerCard.tsx b/apps/desktop/src/features/registry/ServerCard.tsx index 99beff85..9a0a0447 100644 --- a/apps/desktop/src/features/registry/ServerCard.tsx +++ b/apps/desktop/src/features/registry/ServerCard.tsx @@ -50,18 +50,48 @@ export function ServerCard({ }; const getTransportBadge = () => { + // Use hosting_type if available, otherwise infer from transport + const hostingType = server.hosting_type || (server.transport.type === 'stdio' ? 'local' : 'remote'); + const config = { - stdio: { bg: 'bg-purple-500/20', text: 'text-purple-600 dark:text-purple-400', label: 'Local' }, - http: { bg: 'bg-[rgb(var(--primary))]/20', text: 'text-[rgb(var(--primary))]', label: 'HTTP' }, - }[server.transport.type]; + local: { icon: '💻', label: 'Local', bg: 'bg-purple-500/20', text: 'text-purple-600 dark:text-purple-400' }, + remote: { icon: '☁️', label: 'Cloud', bg: 'bg-blue-500/20', text: 'text-blue-600 dark:text-blue-400' }, + hybrid: { icon: '🔄', label: 'Hybrid', bg: 'bg-indigo-500/20', text: 'text-indigo-600 dark:text-indigo-400' }, + }[hostingType]; return ( - {config.label} + {config.icon} {config.label} ); }; + const getBadges = () => { + if (!server.badges || server.badges.length === 0) return null; + + const badgeConfig: Record = { + official: { label: 'Official', bg: 'bg-blue-500/20', text: 'text-blue-600 dark:text-blue-400' }, + verified: { label: '✓ Verified', bg: 'bg-green-500/20', text: 'text-green-600 dark:text-green-400' }, + featured: { label: '⭐ Featured', bg: 'bg-amber-500/20', text: 'text-amber-600 dark:text-amber-400' }, + sponsored: { label: 'Sponsored', bg: 'bg-yellow-500/20', text: 'text-yellow-600 dark:text-yellow-400' }, + popular: { label: '🔥 Popular', bg: 'bg-red-500/20', text: 'text-red-600 dark:text-red-400' }, + }; + + return ( + <> + {server.badges.slice(0, 2).map((badge) => { + const config = badgeConfig[badge]; + if (!config) return null; + return ( + + {config.label} + + ); + })} + + ); + }; + return (
+ {getBadges()} {getTransportBadge()} {getAuthBadge()} + {server.capabilities?.read_only_mode && ( + + 🛡️ Read-Only + + )}
{/* Categories */} diff --git a/apps/desktop/src/features/registry/ServerDetailModal.tsx b/apps/desktop/src/features/registry/ServerDetailModal.tsx index 183517bb..94fc7b13 100644 --- a/apps/desktop/src/features/registry/ServerDetailModal.tsx +++ b/apps/desktop/src/features/registry/ServerDetailModal.tsx @@ -33,7 +33,7 @@ export function ServerDetailModal({
{server.icon || '📦'}
-
+

{server.name}

@@ -42,6 +42,36 @@ export function ServerDetailModal({ ✓ )} + {/* Badges */} + {server.badges && server.badges.length > 0 && ( +
+ {server.badges.includes('official') && ( + + Official + + )} + {server.badges.includes('verified') && ( + + ✓ Verified + + )} + {server.badges.includes('featured') && ( + + ⭐ Featured + + )} + {server.badges.includes('sponsored') && ( + + Sponsored + + )} + {server.badges.includes('popular') && ( + + 🔥 Popular + + )} +
+ )}
{server.publisher?.name && (

@@ -61,6 +91,30 @@ export function ServerDetailModal({ {/* Content */}

+ {/* Sponsored Banner */} + {server.sponsored?.enabled && ( +
+ {server.sponsored.sponsor_logo && ( + Sponsor + )} +
+ Sponsored by + {server.sponsored.sponsor_url ? ( + + {server.sponsored.sponsor_name} + + ) : ( + {server.sponsored.sponsor_name} + )} +
+
+ )} + {/* Description */}

@@ -74,19 +128,26 @@ export function ServerDetailModal({ {/* Transport */}

- Transport + Hosting

- {server.transport.type === 'stdio' - ? '🖥️ Local Process (stdio)' - : '🌐 Remote Server (HTTP)'} + {(server.hosting_type || (server.transport.type === 'stdio' ? 'local' : 'remote')) === 'local' + ? '💻 Local Process' + : (server.hosting_type || 'remote') === 'remote' + ? '☁️ Remote Server' + : '🔄 Hybrid'} + + + ({server.transport.type})
@@ -143,6 +204,144 @@ export function ServerDetailModal({

)} + {/* Capabilities */} + {server.capabilities && ( +
+

+ Capabilities +

+
+ {server.capabilities.tools && ( + + 🛠️ Tools + + )} + {server.capabilities.resources && ( + + 📁 Resources + + )} + {server.capabilities.prompts && ( + + 💬 Prompts + + )} + {server.capabilities.read_only_mode && ( + + 🛡️ Read-Only (Safe) + + )} +
+
+ )} + + {/* Installation Info */} + {server.installation && ( +
+

Installation Info

+
+ {server.installation.difficulty && ( +
+ Difficulty: + + {server.installation.difficulty} + +
+ )} + {server.installation.estimated_time && ( +
+ Time: + {server.installation.estimated_time} +
+ )} + {server.installation.prerequisites && server.installation.prerequisites.length > 0 && ( +
+ Prerequisites: +
    + {server.installation.prerequisites.map((prereq, i) => ( +
  • {prereq}
  • + ))} +
+
+ )} +
+
+ )} + + {/* License */} + {server.license && ( +
+

License

+
+ + {server.license} + + {server.license_url && ( + + View License → + + )} +
+
+ )} + + {/* Screenshots */} + {server.media?.screenshots && server.media.screenshots.length > 0 && ( +
+

Screenshots

+
+ {server.media.screenshots.map((url, i) => ( + {`Screenshot + ))} +
+
+ )} + + {/* Links */} + {(server.media?.demo_video || server.changelog_url) && ( +
+ {server.media?.demo_video && ( + + 🎥 Watch Demo Video → + + )} + {server.changelog_url && ( + + 📝 View Changelog → + + )} +
+ )} + {/* Source */} {server.source.type === 'Registry' && (
diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 45e8b6b5..a13a2a0c 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -7,6 +7,7 @@ import { CardDescription, CardContent, Button, + Switch, } from '@mcpmux/ui'; import { Sun, @@ -15,16 +16,34 @@ import { FileText, FolderOpen, Loader2, + Power, + Minimize2, + XCircle, } from 'lucide-react'; import { useAppStore, useTheme } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; +interface StartupSettings { + autoLaunch: boolean; + startMinimized: boolean; + closeToTray: boolean; +} + export function SettingsPage() { const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); const [logsPath, setLogsPath] = useState(''); const [openingLogs, setOpeningLogs] = useState(false); + // Startup settings state + const [startupSettings, setStartupSettings] = useState({ + autoLaunch: false, + startMinimized: false, + closeToTray: true, + }); + const [loadingSettings, setLoadingSettings] = useState(true); + const [savingSettings, setSavingSettings] = useState(false); + // Load logs path on mount useEffect(() => { const loadLogsPath = async () => { @@ -38,6 +57,49 @@ export function SettingsPage() { loadLogsPath(); }, []); + // Load startup settings on mount + useEffect(() => { + const loadStartupSettings = async () => { + try { + const settings = await invoke('get_startup_settings'); + setStartupSettings(settings); + } catch (error) { + console.error('Failed to load startup settings:', error); + } finally { + setLoadingSettings(false); + } + }; + loadStartupSettings(); + }, []); + + // Save startup settings when they change + const updateStartupSetting = async ( + key: keyof StartupSettings, + value: boolean + ) => { + console.log(`[Settings] Updating ${key} to ${value}`); + + // Save old state for rollback + const oldSettings = { ...startupSettings }; + const newSettings = { ...startupSettings, [key]: value }; + + // Update UI immediately for better UX + setStartupSettings(newSettings); + setSavingSettings(true); + + try { + console.log('[Settings] Invoking update_startup_settings:', newSettings); + await invoke('update_startup_settings', { settings: newSettings }); + console.log('[Settings] Successfully saved:', newSettings); + } catch (error) { + console.error('[Settings] Failed to save:', error); + // Revert on error + setStartupSettings(oldSettings); + } finally { + setSavingSettings(false); + } + }; + const handleOpenLogs = async () => { setOpeningLogs(true); try { @@ -59,6 +121,98 @@ export function SettingsPage() { {/* Updates Section */} + {/* Startup & System Tray Section */} + + + + + Startup & System Tray + + + Control how McpMux starts and behaves with the system tray. + + + + {loadingSettings ? ( +
+ +
+ ) : ( +
+
+
+ +
+ +

+ Start McpMux automatically when you log in to your system +

+
+
+ { + console.log('Auto-launch toggled:', checked); + updateStartupSetting('autoLaunch', checked); + }} + disabled={savingSettings} + data-testid="auto-launch-switch" + /> +
+ +
+
+ +
+ +

+ Launch in background to system tray (requires auto-launch enabled) +

+
+
+ { + console.log('Start minimized toggled:', checked); + updateStartupSetting('startMinimized', checked); + }} + disabled={savingSettings || !startupSettings.autoLaunch} + data-testid="start-minimized-switch" + /> +
+ +
+
+ +
+ +

+ Keep running in system tray when window is closed (use "Quit" from tray to exit) +

+
+
+ { + console.log('Close to tray toggled:', checked); + updateStartupSetting('closeToTray', checked); + }} + disabled={savingSettings} + data-testid="close-to-tray-switch" + /> +
+ + {savingSettings && ( +
+ + Saving settings... +
+ )} +
+ )} +
+
+ {/* Appearance Section */} diff --git a/apps/desktop/src/types/registry.ts b/apps/desktop/src/types/registry.ts index 3a1e18c1..4302e1b7 100644 --- a/apps/desktop/src/types/registry.ts +++ b/apps/desktop/src/types/registry.ts @@ -60,6 +60,16 @@ export interface ServerDefinition { categories: string[]; publisher: PublisherInfo | null; source: ServerSource; + // Schema v2.1 additions + badges?: Badge[]; + hosting_type?: HostingType; + license?: string; + license_url?: string; + installation?: Installation; + capabilities?: Capabilities; + sponsored?: Sponsored; + media?: Media; + changelog_url?: string; } /** Auth configuration - matches backend snake_case serialization */ @@ -166,4 +176,45 @@ export interface SortRule { /** Home configuration */ export interface HomeConfig { featured_server_ids: string[]; +} + +// ============================================ +// Schema v2.1 Additions +// ============================================ + +/** Visual badge indicators */ +export type Badge = 'official' | 'verified' | 'featured' | 'sponsored' | 'popular'; + +/** Server hosting type */ +export type HostingType = 'local' | 'remote' | 'hybrid'; + +/** Installation metadata */ +export interface Installation { + difficulty?: 'easy' | 'moderate' | 'advanced'; + prerequisites?: string[]; + estimated_time?: string; +} + +/** MCP capabilities with read-only support */ +export interface Capabilities { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + read_only_mode?: boolean; +} + +/** Sponsorship information */ +export interface Sponsored { + enabled?: boolean; + sponsor_name?: string; + sponsor_url?: string; + sponsor_logo?: string; + campaign_id?: string; +} + +/** Rich media content */ +export interface Media { + screenshots?: string[]; + demo_video?: string; + banner?: string; } \ No newline at end of file diff --git a/crates/mcpmux-core/src/domain/config.rs b/crates/mcpmux-core/src/domain/config.rs index e25cf1c2..b2789fb7 100644 --- a/crates/mcpmux-core/src/domain/config.rs +++ b/crates/mcpmux-core/src/domain/config.rs @@ -1,6 +1,6 @@ use crate::domain::server::{ - AuthConfig, InputDefinition, PublisherInfo, ServerDefinition, ServerSource, TransportConfig, - TransportMetadata, + AuthConfig, HostingType, InputDefinition, PublisherInfo, ServerDefinition, ServerSource, + TransportConfig, TransportMetadata, }; use lazy_static::lazy_static; use regex::Regex; @@ -125,6 +125,15 @@ impl UserServerEntry { space_id: space_id.to_string(), file_path, }, + badges: vec![], + hosting_type: HostingType::default(), + license: None, + license_url: None, + installation: None, + capabilities: None, + sponsored: None, + media: None, + changelog_url: None, } } diff --git a/crates/mcpmux-core/src/domain/server.rs b/crates/mcpmux-core/src/domain/server.rs index d7c34ec2..379e9063 100644 --- a/crates/mcpmux-core/src/domain/server.rs +++ b/crates/mcpmux-core/src/domain/server.rs @@ -36,6 +36,35 @@ pub struct ServerDefinition { /// Where this server came from #[serde(default)] pub source: ServerSource, + + /// Visual badges for trust and discovery (v2.1) + #[serde(default)] + pub badges: Vec, + + /// Where the server runs: local, remote, or hybrid (v2.1) + #[serde(default)] + pub hosting_type: HostingType, + + /// SPDX license identifier (v2.1) + pub license: Option, + + /// URL to full license text (v2.1) + pub license_url: Option, + + /// Installation metadata (v2.1) + pub installation: Option, + + /// MCP capabilities (v2.1) + pub capabilities: Option, + + /// Sponsorship information (v2.1) + pub sponsored: Option, + + /// Rich media content (v2.1) + pub media: Option, + + /// Changelog URL (v2.1) + pub changelog_url: Option, // NOTE: Runtime state like 'enabled' is NOT stored here. // It is injected at the application layer by merging with DB state. } @@ -146,3 +175,79 @@ pub struct PublisherInfo { #[serde(default)] pub official: bool, } + +// ============================================ +// Schema v2.1 Additions +// ============================================ + +/// Visual badge indicators for server listings +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Badge { + Official, + Verified, + Featured, + Sponsored, + Popular, +} + +/// Where the server runs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum HostingType { + #[default] + Local, + Remote, + Hybrid, +} + +/// Installation complexity level +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstallDifficulty { + Easy, + Moderate, + Advanced, +} + +/// Installation metadata for user guidance +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Installation { + pub difficulty: Option, + #[serde(default)] + pub prerequisites: Vec, + pub estimated_time: Option, +} + +/// MCP capabilities with read-only mode support +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Capabilities { + #[serde(default)] + pub tools: bool, + #[serde(default)] + pub resources: bool, + #[serde(default)] + pub prompts: bool, + #[serde(default)] + pub read_only_mode: bool, +} + +/// Sponsorship information for commercial listings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Sponsored { + #[serde(default)] + pub enabled: bool, + pub sponsor_name: Option, + pub sponsor_url: Option, + pub sponsor_logo: Option, + pub campaign_id: Option, +} + +/// Rich media content for enhanced discovery +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Media { + #[serde(default)] + pub screenshots: Vec, + pub demo_video: Option, + pub banner: Option, +} diff --git a/package.json b/package.json index 68b76425..a8f3e0ef 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "setup": "pwsh -ExecutionPolicy Bypass -File scripts/setup-dev.ps1", + "prepare": "node -e \"try{require('fs').chmodSync('.git/hooks/pre-commit',0o755)}catch(e){}\"", "dev": "pnpm --filter @mcpmux/desktop dev", "dev:web": "pnpm --filter @mcpmux/desktop dev:web", "build": "pnpm --filter @mcpmux/desktop build", @@ -30,6 +31,7 @@ "format": "prettier --write . && cargo fmt --all", "format:check": "prettier --check . && cargo fmt --all --check", "typecheck": "pnpm -r typecheck", + "validate": "cargo fmt --all && cargo clippy --workspace -- -D warnings && cargo check --workspace && pnpm lint && pnpm typecheck", "clean": "pnpm -r clean && cargo clean" }, "devDependencies": { diff --git a/packages/ui/package.json b/packages/ui/package.json index ea5476d2..127b23c8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,7 +18,8 @@ "dev": "tsc --watch", "lint": "eslint src", "lint:fix": "eslint src --fix", - "test": "echo \"No tests yet\"" + "test": "vitest run", + "test:watch": "vitest" }, "peerDependencies": { "react": "^19.0.0", diff --git a/packages/ui/src/components/common/Switch.test.tsx b/packages/ui/src/components/common/Switch.test.tsx new file mode 100644 index 00000000..f7573852 --- /dev/null +++ b/packages/ui/src/components/common/Switch.test.tsx @@ -0,0 +1,133 @@ +/** + * Tests for Switch component + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Switch } from './Switch'; + +describe('Switch', () => { + it('renders with unchecked state', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-checked', 'false'); + }); + + it('renders with checked state', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button).toHaveAttribute('aria-checked', 'true'); + }); + + it('calls onCheckedChange when clicked', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + fireEvent.click(button); + + expect(mockHandler).toHaveBeenCalledWith(true); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it('toggles from checked to unchecked', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + fireEvent.click(button); + + expect(mockHandler).toHaveBeenCalledWith(false); + }); + + it('does not call handler when disabled', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + fireEvent.click(button); + + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('applies disabled attribute when disabled', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button).toBeDisabled(); + }); + + it('applies custom className', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button).toHaveClass('custom-class'); + }); + + it('applies data-testid when provided', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByTestId('test-switch'); + expect(button).toBeInTheDocument(); + }); + + it('has correct styles for checked state', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button.className).toMatch(/bg-\[rgb\(var\(--primary\)\)\]/); + }); + + it('has correct styles for unchecked state', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button.className).toMatch(/bg-gray-300/); + }); + + it('has disabled styling when disabled', () => { + const mockHandler = vi.fn(); + render(); + + const button = screen.getByRole('switch'); + expect(button.className).toMatch(/opacity-50/); + }); + + it('can be toggled multiple times', () => { + const mockHandler = vi.fn(); + const { rerender } = render(); + + const button = screen.getByRole('switch'); + + // First click - should call with true + fireEvent.click(button); + expect(mockHandler).toHaveBeenCalledWith(true); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // Simulate parent updating the prop + rerender(); + + // Second click - should call with false + fireEvent.click(button); + expect(mockHandler).toHaveBeenCalledWith(false); + expect(mockHandler).toHaveBeenCalledTimes(2); + + // Simulate parent updating the prop again + rerender(); + + // Third click - should call with true again + fireEvent.click(button); + expect(mockHandler).toHaveBeenCalledWith(true); + expect(mockHandler).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/ui/src/components/common/Switch.tsx b/packages/ui/src/components/common/Switch.tsx new file mode 100644 index 00000000..2ab5db29 --- /dev/null +++ b/packages/ui/src/components/common/Switch.tsx @@ -0,0 +1,54 @@ +/** + * Switch/Toggle component + * A simple toggle switch for boolean settings + */ + +import { cn } from '../../lib/cn'; + +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + className?: string; + 'data-testid'?: string; +} + +export function Switch({ + checked, + onCheckedChange, + disabled = false, + className, + 'data-testid': testId, +}: SwitchProps) { + const handleClick = () => { + if (!disabled) { + onCheckedChange(!checked); + } + }; + + return ( + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2d419480..c881f5ec 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,7 @@ export { StatusBar, StatusBarItem } from './components/layout/StatusBar'; export { Button } from './components/common/Button'; export { Input } from './components/common/Input'; export { Card, CardHeader, CardTitle, CardDescription, CardContent } from './components/common/Card'; +export { Switch } from './components/common/Switch'; // Utilities export { cn } from './lib/cn'; diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 00000000..5213ac65 --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: [path.resolve(__dirname, '../../tests/ts/setup.ts')], + include: ['src/**/*.test.{ts,tsx}'], + exclude: ['**/node_modules/**', 'dist/**'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/tests/e2e/specs/settings-desktop.wdio.ts b/tests/e2e/specs/settings-desktop.wdio.ts new file mode 100644 index 00000000..18f52de4 --- /dev/null +++ b/tests/e2e/specs/settings-desktop.wdio.ts @@ -0,0 +1,227 @@ +/** + * Desktop-only E2E tests for Settings (requires Tauri backend) + * Run with: pnpm test:e2e --spec tests/e2e/specs/settings-desktop.wdio.ts + */ + +import { expect, browser } from '@wdio/globals'; + +describe('Settings - Desktop Features', () => { + beforeEach(async () => { + // Navigate to settings page + const dashboardBtn = await $('nav button[data-testid="nav-dashboard"]'); + await dashboardBtn.waitForClickable(); + await dashboardBtn.click(); + + const settingsBtn = await $('nav button[data-testid="nav-settings"]'); + await settingsBtn.waitForClickable(); + await settingsBtn.click(); + + // Wait for settings page to load + await browser.pause(500); + }); + + describe('Startup & System Tray Settings', () => { + it('should display startup settings section', async () => { + const heading = await $('h3*=Startup & System Tray'); + await expect(heading).toBeDisplayed(); + + const description = await $('p*=Control how McpMux starts'); + await expect(description).toBeDisplayed(); + }); + + it('should display all three startup toggles', async () => { + const autoLaunchLabel = await $('label*=Launch at Startup'); + await expect(autoLaunchLabel).toBeDisplayed(); + + const startMinimizedLabel = await $('label*=Start Minimized'); + await expect(startMinimizedLabel).toBeDisplayed(); + + const closeToTrayLabel = await $('label*=Close to Tray'); + await expect(closeToTrayLabel).toBeDisplayed(); + }); + + it('should display descriptive text for each setting', async () => { + const autoLaunchDesc = await $('p*=Start McpMux automatically'); + await expect(autoLaunchDesc).toBeDisplayed(); + + const startMinimizedDesc = await $('p*=Launch in background'); + await expect(startMinimizedDesc).toBeDisplayed(); + + const closeToTrayDesc = await $('p*=Keep running in system tray'); + await expect(closeToTrayDesc).toBeDisplayed(); + }); + + it('should have functional toggle switches', async () => { + const autoLaunchSwitch = await $('[data-testid="auto-launch-switch"]'); + await expect(autoLaunchSwitch).toBeDisplayed(); + await expect(autoLaunchSwitch).toBeEnabled(); + + const closeToTraySwitch = await $('[data-testid="close-to-tray-switch"]'); + await expect(closeToTraySwitch).toBeDisplayed(); + await expect(closeToTraySwitch).toBeEnabled(); + + const startMinimizedSwitch = await $('[data-testid="start-minimized-switch"]'); + await expect(startMinimizedSwitch).toBeDisplayed(); + }); + + it('should toggle auto-launch setting', async () => { + const autoLaunchSwitch = await $('[data-testid="auto-launch-switch"]'); + + const initialState = await autoLaunchSwitch.getAttribute('aria-checked'); + + await autoLaunchSwitch.click(); + await browser.pause(500); + + const newState = await autoLaunchSwitch.getAttribute('aria-checked'); + expect(newState).not.toBe(initialState); + + // Toggle back + await autoLaunchSwitch.click(); + await browser.pause(500); + + const finalState = await autoLaunchSwitch.getAttribute('aria-checked'); + expect(finalState).toBe(initialState); + }); + + it('should toggle close to tray setting', async () => { + const closeToTraySwitch = await $('[data-testid="close-to-tray-switch"]'); + + const initialState = await closeToTraySwitch.getAttribute('aria-checked'); + + await closeToTraySwitch.click(); + await browser.pause(500); + + const newState = await closeToTraySwitch.getAttribute('aria-checked'); + expect(newState).not.toBe(initialState); + + // Toggle back + await closeToTraySwitch.click(); + await browser.pause(500); + + const finalState = await closeToTraySwitch.getAttribute('aria-checked'); + expect(finalState).toBe(initialState); + }); + + it('start minimized should be disabled when auto-launch is off', async () => { + const autoLaunchSwitch = await $('[data-testid="auto-launch-switch"]'); + const startMinimizedSwitch = await $('[data-testid="start-minimized-switch"]'); + + // Ensure auto-launch is off + const autoLaunchState = await autoLaunchSwitch.getAttribute('aria-checked'); + if (autoLaunchState === 'true') { + await autoLaunchSwitch.click(); + await browser.pause(500); + } + + // Start minimized should be disabled + const isDisabled = await startMinimizedSwitch.getAttribute('disabled'); + expect(isDisabled).toBe('true'); + + const ariaChecked = await startMinimizedSwitch.getAttribute('aria-checked'); + expect(ariaChecked).toBe('false'); + }); + + it('start minimized should be enabled when auto-launch is on', async () => { + const autoLaunchSwitch = await $('[data-testid="auto-launch-switch"]'); + const startMinimizedSwitch = await $('[data-testid="start-minimized-switch"]'); + + // Ensure auto-launch is on + const autoLaunchState = await autoLaunchSwitch.getAttribute('aria-checked'); + if (autoLaunchState === 'false') { + await autoLaunchSwitch.click(); + await browser.pause(500); + } + + // Start minimized should be enabled + const isDisabled = await startMinimizedSwitch.getAttribute('disabled'); + expect(isDisabled).toBeNull(); + }); + + it('should toggle start minimized when enabled', async () => { + const autoLaunchSwitch = await $('[data-testid="auto-launch-switch"]'); + const startMinimizedSwitch = await $('[data-testid="start-minimized-switch"]'); + + // Ensure auto-launch is on first + const autoLaunchState = await autoLaunchSwitch.getAttribute('aria-checked'); + if (autoLaunchState === 'false') { + await autoLaunchSwitch.click(); + await browser.pause(500); + } + + const initialState = await startMinimizedSwitch.getAttribute('aria-checked'); + + await startMinimizedSwitch.click(); + await browser.pause(500); + + const newState = await startMinimizedSwitch.getAttribute('aria-checked'); + expect(newState).not.toBe(initialState); + + // Toggle back + await startMinimizedSwitch.click(); + await browser.pause(500); + + const finalState = await startMinimizedSwitch.getAttribute('aria-checked'); + expect(finalState).toBe(initialState); + }); + + it('should persist settings across page reloads', async () => { + const closeToTraySwitch = await $('[data-testid="close-to-tray-switch"]'); + + const initialState = await closeToTraySwitch.getAttribute('aria-checked'); + + await closeToTraySwitch.click(); + await browser.pause(500); + + // Reload the page + await browser.refresh(); + await browser.pause(1000); + + // Navigate to settings again + const settingsBtn = await $('nav button[data-testid="nav-settings"]'); + await settingsBtn.waitForClickable(); + await settingsBtn.click(); + await browser.pause(500); + + // Verify state persisted + const persistedState = await closeToTraySwitch.getAttribute('aria-checked'); + expect(persistedState).not.toBe(initialState); + + // Restore original state + await closeToTraySwitch.click(); + await browser.pause(500); + }); + + it('should show disabled state visually for start minimized', async () => { + const autoLaunchSwitch = await $('[data-testid="auto-launch-switch"]'); + const startMinimizedSwitch = await $('[data-testid="start-minimized-switch"]'); + + // Ensure auto-launch is off + const autoLaunchState = await autoLaunchSwitch.getAttribute('aria-checked'); + if (autoLaunchState === 'true') { + await autoLaunchSwitch.click(); + await browser.pause(500); + } + + // Check that start minimized has disabled styling + const className = await startMinimizedSwitch.getAttribute('class'); + expect(className).toContain('opacity-50'); + }); + + it('all settings should work independently', async () => { + const closeToTraySwitch = await $('[data-testid="close-to-tray-switch"]'); + + // Close to tray should work regardless of auto-launch state + const initialCloseToTray = await closeToTraySwitch.getAttribute('aria-checked'); + + await closeToTraySwitch.click(); + await browser.pause(500); + + const newCloseToTray = await closeToTraySwitch.getAttribute('aria-checked'); + expect(newCloseToTray).not.toBe(initialCloseToTray); + + // Restore + await closeToTraySwitch.click(); + await browser.pause(500); + }); + }); +}); diff --git a/tests/e2e/specs/settings.spec.ts b/tests/e2e/specs/settings.spec.ts index c4afef82..a2a4e2ba 100644 --- a/tests/e2e/specs/settings.spec.ts +++ b/tests/e2e/specs/settings.spec.ts @@ -5,10 +5,10 @@ test.describe('Settings', () => { test('should display settings heading', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + // Click Settings in sidebar await page.locator('nav button:has-text("Settings")').click(); - + // Check heading await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); }); @@ -16,7 +16,7 @@ test.describe('Settings', () => { test('should display appearance settings', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); await expect(page.locator('text=Appearance').first()).toBeVisible(); @@ -27,7 +27,7 @@ test.describe('Settings', () => { test('should display logs section', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); // Use heading role to be more specific @@ -37,13 +37,13 @@ test.describe('Settings', () => { test('should switch between themes', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); // Switch to light theme await page.getByRole('button', { name: 'Light', exact: true }).click(); await page.waitForTimeout(300); - + // Switch to dark theme await page.getByRole('button', { name: 'Dark', exact: true }).click(); await page.waitForTimeout(300); @@ -54,7 +54,7 @@ test.describe('Settings', () => { test('should display update checker section', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); // Check for update checker card @@ -66,7 +66,7 @@ test.describe('Settings', () => { test('should display current version', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); // Check current version is displayed @@ -77,7 +77,7 @@ test.describe('Settings', () => { test('should have check for updates button', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); const checkButton = page.getByTestId('check-updates-btn'); @@ -86,10 +86,11 @@ test.describe('Settings', () => { await expect(checkButton).toBeEnabled(); }); - test('should show loading state when checking for updates', async ({ page }) => { + // Skip in web mode - requires Tauri API + test.skip('should show loading state when checking for updates', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); const checkButton = page.getByTestId('check-updates-btn'); @@ -100,10 +101,11 @@ test.describe('Settings', () => { await expect(checkButton).toBeDisabled(); }); - test('should display update status message', async ({ page }) => { + // Skip in web mode - requires Tauri API + test.skip('should display update status message', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); const checkButton = page.getByTestId('check-updates-btn'); @@ -117,18 +119,19 @@ test.describe('Settings', () => { // Verify one of the expected states is shown const hasMessage = await page.getByTestId('update-message').isVisible().catch(() => false); const hasUpdate = await page.getByTestId('update-available').isVisible().catch(() => false); - + expect(hasMessage || hasUpdate).toBeTruthy(); }); - test('should allow multiple update checks', async ({ page }) => { + // Skip in web mode - requires Tauri API + test.skip('should allow multiple update checks', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); const checkButton = page.getByTestId('check-updates-btn'); - + // First check await checkButton.click(); await page.waitForSelector('[data-testid="update-message"], [data-testid="update-available"]', { @@ -137,7 +140,7 @@ test.describe('Settings', () => { // Check button should be available again await expect(checkButton).toBeEnabled(); - + // Second check await checkButton.click(); await expect(checkButton).toContainText(/Checking/); @@ -145,10 +148,11 @@ test.describe('Settings', () => { }); test.describe('Logs Section', () => { - test('should display logs path', async ({ page }) => { + // Skip in web mode - requires Tauri API + test.skip('should display logs path', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); const logsPath = page.getByTestId('logs-path'); @@ -160,7 +164,7 @@ test.describe('Settings', () => { test('should have open logs folder button', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); const openButton = page.getByTestId('open-logs-btn'); @@ -171,7 +175,7 @@ test.describe('Settings', () => { test('should show description text', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); await expect(page.getByText(/Logs are rotated daily/i)).toBeVisible(); @@ -182,7 +186,7 @@ test.describe('Settings', () => { test('should display all sections in order', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); // Verify sections appear in expected order @@ -200,7 +204,7 @@ test.describe('Settings', () => { test('should be scrollable if content overflows', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Settings")').click(); // Content should be within a scrollable container