diff --git a/Cargo.lock b/Cargo.lock index 9afc7585..9b44bb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2548,7 +2548,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "async-trait", @@ -2585,7 +2585,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "async-trait", @@ -2607,7 +2607,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "async-stream", @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "async-trait", @@ -2666,7 +2666,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "async-trait", diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example index 6accf9bb..4b24a0cd 100644 --- a/apps/desktop/.env.example +++ b/apps/desktop/.env.example @@ -4,4 +4,4 @@ RUST_LOG="debug" # Registry API URL # For production: https://api.mcpmux.com # For local dev: http://localhost:8787 -MCPMUX_REGISTRY_URL="http://localhost:8787" +MCPMUX_REGISTRY_URL="https://api.mcpmux.com" diff --git a/apps/desktop/public/mcpmux.svg b/apps/desktop/public/mcpmux.svg new file mode 100644 index 00000000..e940020b --- /dev/null +++ b/apps/desktop/public/mcpmux.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index b39d09bc..dbd19e79 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -5,6 +5,15 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:allow-minimize", + "core:window:allow-maximize", + "core:window:allow-toggle-maximize", + "core:window:allow-unmaximize", + "core:window:allow-close", + "core:window:allow-is-maximized", + "core:window:allow-start-dragging", + "core:window:allow-show", + "core:window:allow-set-focus", "opener:default", "deep-link:default", "updater:default" diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index 6be5e50e..70cd8637 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index e81becee..0d9ac316 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index a437dd51..9ed12df2 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index 0ca4f271..2c5a1e0b 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index b81f8203..af31e4b6 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index 624c7bfb..b857244e 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index c021d2ba..ea809389 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index 62197002..b2486e01 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index f9bc0483..57326be7 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index d5fbfb2a..68e90c53 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index 63440d79..1aa99844 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index f3f705af..82ccf6e1 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index 45563882..1c9bf61d 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/appicon-small.svg b/apps/desktop/src-tauri/icons/appicon-small.svg new file mode 100644 index 00000000..c6f80de9 --- /dev/null +++ b/apps/desktop/src-tauri/icons/appicon-small.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index 12a5bcee..c7243c37 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index b3636e4b..017080e1 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index e1cd2619..898f6558 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index de2357bd..f7141187 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -44,6 +44,9 @@ use super::gateway::GatewayAppState; /// Now only contains request_id - frontend must call get_pending_consent pub const OAUTH_CONSENT_EVENT: &str = "oauth-consent-request"; +/// Event name for server install requests sent to frontend (from deep link) +pub const SERVER_INSTALL_EVENT: &str = "server-install-request"; + /// Minimal deep link payload - only request_id /// Frontend must call get_pending_consent to get full details #[derive(Debug, Clone, Serialize)] @@ -52,6 +55,13 @@ pub struct OAuthDeepLinkPayload { pub request_id: String, } +/// Deep link payload for server installation requests +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerInstallDeepLinkPayload { + pub server_id: String, +} + /// Full consent request details returned by get_pending_consent #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -111,6 +121,9 @@ pub fn handle_deep_link(app: &tauri::AppHandle, url: &str) // Inbound OAuth - client requesting approval handle_authorize_deep_link(app, &parsed); } + Some("install") => { + handle_install_deep_link(app, &parsed); + } Some("test") => { info!("[DeepLink] Test URL received successfully!"); } @@ -157,6 +170,41 @@ fn handle_authorize_deep_link(app: &tauri::AppHandle, url: } } +/// Handle server install deep link +/// +/// Extracts server_id and emits to frontend. +/// Frontend will look up the server definition and show install modal. +fn handle_install_deep_link(app: &tauri::AppHandle, url: &Url) { + let params: HashMap<_, _> = url.query_pairs().collect(); + + let server_id = match params.get("server") { + Some(id) if !id.is_empty() => id.to_string(), + _ => { + error!("[DeepLink] Install link missing required parameter: server"); + return; + } + }; + + info!( + "[DeepLink] Server install request: server_id='{}'", + server_id + ); + + let payload = ServerInstallDeepLinkPayload { server_id }; + + if let Err(e) = app.emit(SERVER_INSTALL_EVENT, &payload) { + error!("[DeepLink] Failed to emit server install event: {}", e); + return; + } + + // Focus the main window + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + /// Handle OAuth callback deep link (legacy - for outbound OAuth server connections) /// /// NOTE: The primary OAuth callback mechanism is now the loopback HTTP server diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index e7a35ad3..ae5599a9 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -128,7 +128,7 @@ impl AppState { info!("Using spaces directory: {:?}", spaces_dir); let registry_url = std::env::var("MCPMUX_REGISTRY_URL") - .unwrap_or_else(|_| "http://localhost:8787".to_string()); + .unwrap_or_else(|_| "https://api.mcpmux.com".to_string()); info!("Using Registry API URL: {}", registry_url); let server_discovery = Arc::new( diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 754ef066..0d2278c1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -34,7 +34,8 @@ "minWidth": 800, "minHeight": 600, "center": true, - "preventOverflow": true + "preventOverflow": true, + "decorations": false } ], "security": { diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 2b607b23..8184ee9c 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -64,19 +64,11 @@ animation-name: scale-in; } -/* Logo hover effects */ +/* Logo hover effects — McpMux brand */ .logo { transition: filter 0.3s ease; } -.logo.tauri:hover { +.logo.mcpmux:hover { filter: drop-shadow(0 0 1.5em rgb(var(--primary))); } - -.logo.vite:hover { - filter: drop-shadow(0 0 1.5em #747bff); -} - -.logo.react:hover { - filter: drop-shadow(0 0 1.5em #61dafb); -} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b9817fc7..25e211c1 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,7 +8,6 @@ import { Settings, Sun, Moon, - Zap, Check, Loader2, FolderOpen, @@ -28,6 +27,7 @@ import { } from '@mcpmux/ui'; import { ThemeProvider } from '@/components/ThemeProvider'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; +import { ServerInstallModal } from '@/components/ServerInstallModal'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; import { useDataSync } from '@/hooks/useDataSync'; import { useAppStore, useActiveSpace, useViewSpace, useTheme } from '@/stores'; @@ -39,6 +39,43 @@ import { SpacesPage } from '@/features/spaces'; import { SettingsPage } from '@/features/settings'; import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents'; +/** McpMux neural router glyph — transparent background, brand colored, for inline use */ +function McpMuxGlyph({ className }: { className?: string }) { + return ( + + + + + + + + {/* Input curves */} + + + + {/* Input nodes */} + + + + {/* M hub */} + + {/* Output curves */} + + + + {/* Output nodes */} + + + + + ); +} + type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings'; function AppContent() { @@ -79,23 +116,7 @@ function AppContent() { const sidebar = ( -
-
- - McpMux -
- -
- {/* Space Switcher in sidebar */} - - + } footer={
@@ -113,7 +134,7 @@ function AppContent() { data-testid="nav-dashboard" /> } + icon={} label="My Servers" active={activeNav === 'servers'} onClick={() => setActiveNav('servers')} @@ -182,8 +203,37 @@ function AppContent() {
); + const titleBar = ( +
+ + + Mcp + Mux + +
+ +
+ ); + return ( - + + + + +
+ } + >
{activeNav === 'home' && } {activeNav === 'registry' && } @@ -203,6 +253,8 @@ function App() { {/* OAuth consent modal - shown when MCP clients request authorization */} + {/* Server install modal - shown when install deep link is received */} + ); } @@ -401,7 +453,7 @@ function DashboardView() {
Gateway: - + {gatewayStatus.url || 'http://localhost:3100'} {!gatewayStatus.running && ( @@ -411,7 +463,7 @@ function DashboardView() { {/* Config Display */}
-
+              
 {`"mcpmux": {
   "url": "${gatewayStatus.url || 'http://localhost:3100'}/mcp"
 }`}
@@ -445,4 +497,36 @@ function DashboardView() {
   );
 }
 
+/** Window control button for custom title bar */
+function WindowButton({ action }: { action: 'minimize' | 'maximize' | 'close' }) {
+  const handleClick = async () => {
+    const { getCurrentWindow } = await import('@tauri-apps/api/window');
+    const appWindow = getCurrentWindow();
+    if (action === 'minimize') appWindow.minimize();
+    else if (action === 'maximize') appWindow.toggleMaximize();
+    else appWindow.close();
+  };
+
+  return (
+    
+  );
+}
+
 export default App;
diff --git a/apps/desktop/src/components/ServerInstallModal.tsx b/apps/desktop/src/components/ServerInstallModal.tsx
new file mode 100644
index 00000000..fec47f14
--- /dev/null
+++ b/apps/desktop/src/components/ServerInstallModal.tsx
@@ -0,0 +1,343 @@
+/**
+ * Server Install Modal
+ *
+ * Displays when a deep link install request is received from the discovery UI.
+ *
+ * ## Flow
+ * 1. Deep link received with serverId only
+ * 2. Look up server definition from registry
+ * 3. Show modal with server info and space picker
+ * 4. On confirm, call install_server command
+ */
+
+import { useState, useEffect } from 'react';
+import { listen } from '@tauri-apps/api/event';
+import { Download, Check, X, AlertCircle, Loader2, Info } from 'lucide-react';
+import {
+  Button,
+  Card,
+  CardHeader,
+  CardTitle,
+  CardDescription,
+  CardContent,
+} from '@mcpmux/ui';
+import { listSpaces, type Space } from '@/lib/api/spaces';
+import {
+  getServerDefinition,
+  installServer,
+  listInstalledServers,
+} from '@/lib/api/registry';
+import type { ServerDefinition } from '@/types/registry';
+import { useViewSpace } from '@/stores';
+
+/** Deep link payload from backend */
+interface ServerInstallDeepLinkPayload {
+  serverId: string;
+}
+
+/** Modal state machine */
+type ModalState =
+  | { type: 'hidden' }
+  | { type: 'loading'; serverId: string }
+  | { type: 'error'; serverId: string; message: string }
+  | { type: 'ready'; server: ServerDefinition; alreadyInstalled: boolean }
+  | { type: 'success'; serverName: string };
+
+export function ServerInstallModal() {
+  const [modalState, setModalState] = useState({ type: 'hidden' });
+  const [selectedSpaceId, setSelectedSpaceId] = useState(null);
+  const [spaces, setSpaces] = useState([]);
+  const [isInstalling, setIsInstalling] = useState(false);
+  const [installError, setInstallError] = useState(null);
+
+  const viewSpace = useViewSpace();
+
+  // Listen for deep link events
+  useEffect(() => {
+    const unlisten = listen(
+      'server-install-request',
+      async (event) => {
+        const { serverId } = event.payload;
+        console.log('[Install] Deep link received for server:', serverId);
+
+        setModalState({ type: 'loading', serverId });
+        setInstallError(null);
+        setIsInstalling(false);
+
+        try {
+          const [spacesResult, serverDef] = await Promise.all([
+            listSpaces(),
+            getServerDefinition(serverId),
+          ]);
+
+          setSpaces(spacesResult);
+
+          // Default to current view space
+          const defaultSpaceId = viewSpace?.id ?? spacesResult[0]?.id ?? null;
+          setSelectedSpaceId(defaultSpaceId);
+
+          if (!serverDef) {
+            setModalState({
+              type: 'error',
+              serverId,
+              message: `Server "${serverId}" was not found in the registry.`,
+            });
+            return;
+          }
+
+          // Check if already installed in the default space
+          let alreadyInstalled = false;
+          if (defaultSpaceId) {
+            const installed = await listInstalledServers(defaultSpaceId);
+            alreadyInstalled = installed.some((s) => s.server_id === serverId);
+          }
+
+          setModalState({ type: 'ready', server: serverDef, alreadyInstalled });
+        } catch (err) {
+          console.error('[Install] Failed to load server details:', err);
+          setModalState({
+            type: 'error',
+            serverId,
+            message: String(err),
+          });
+        }
+      }
+    );
+
+    return () => {
+      unlisten.then((fn) => fn());
+    };
+  }, [viewSpace?.id]);
+
+  // Re-check install status when space selection changes
+  useEffect(() => {
+    if (modalState.type !== 'ready' || !selectedSpaceId) return;
+
+    listInstalledServers(selectedSpaceId)
+      .then((installed) => {
+        const alreadyInstalled = installed.some(
+          (s) => s.server_id === modalState.server.id
+        );
+        if (alreadyInstalled !== modalState.alreadyInstalled) {
+          setModalState({ ...modalState, alreadyInstalled });
+        }
+      })
+      .catch(console.error);
+  }, [selectedSpaceId]);
+
+  const handleInstall = async () => {
+    if (modalState.type !== 'ready' || !selectedSpaceId) return;
+
+    setIsInstalling(true);
+    setInstallError(null);
+
+    try {
+      await installServer(modalState.server.id, selectedSpaceId);
+      console.log('[Install] Server installed:', modalState.server.id);
+      setModalState({ type: 'success', serverName: modalState.server.name });
+
+      // Auto-dismiss after 2 seconds
+      setTimeout(() => setModalState({ type: 'hidden' }), 2000);
+    } catch (err) {
+      console.error('[Install] Failed to install server:', err);
+      setInstallError(String(err));
+    } finally {
+      setIsInstalling(false);
+    }
+  };
+
+  const handleDismiss = () => {
+    setModalState({ type: 'hidden' });
+    setInstallError(null);
+  };
+
+  // Hidden
+  if (modalState.type === 'hidden') return null;
+
+  // Loading
+  if (modalState.type === 'loading') {
+    return (
+      
+ + + +

Looking up server...

+
+
+
+ ); + } + + // Error + if (modalState.type === 'error') { + return ( +
+ + +
+
+ +
+
+ Server Not Found + + Could not find the requested server + +
+
+
+ +

+ {modalState.message} +

+ +
+
+
+ ); + } + + // Success + if (modalState.type === 'success') { + return ( +
+ + +
+ +
+
+

Installed!

+

+ {modalState.serverName} has been added to your space. +

+
+
+
+
+ ); + } + + // Ready - main install modal + const { server, alreadyInstalled } = modalState; + + return ( +
+ + +
+
+ +
+
+ Install Server + + Add this server to your space + +
+
+
+ + {/* Server Info */} +
+
+ {server.icon && ( + {server.icon} + )} +
+
{server.name}
+ {server.description && ( +
+ {server.description} +
+ )} +
+
+ {/* Transport badge */} +
+ + {server.transport.type === 'stdio' ? 'Local' : 'Remote'} + + {server.auth && server.auth.type !== 'none' && ( + + {server.auth.type === 'oauth' ? 'OAuth' : 'API Key'} + + )} +
+
+ + {/* Already Installed Warning */} + {alreadyInstalled && ( +
+ + This server is already installed in the selected space. +
+ )} + + {/* Space Picker */} +
+ + {spaces.length > 0 ? ( + + ) : ( +

+ No spaces available. Create a space first. +

+ )} +
+ + {/* Install Error */} + {installError && ( +
+ + {installError} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/components/ServerLogViewer.tsx b/apps/desktop/src/components/ServerLogViewer.tsx index 99bca323..d334627c 100644 --- a/apps/desktop/src/components/ServerLogViewer.tsx +++ b/apps/desktop/src/components/ServerLogViewer.tsx @@ -22,7 +22,7 @@ const LEVEL_COLORS: Record = { const SOURCE_COLORS: Record = { app: 'text-purple-400', - stdout: 'text-cyan-400', + stdout: 'text-primary-400', stderr: 'text-orange-400', 'http-request': 'text-blue-300', 'http-response': 'text-blue-400', diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index c222c0e6..330ff893 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -713,7 +713,7 @@ export default function ClientsPage() { onClick={() => toggleSection('quickSettings')} className={`w-full flex items-center justify-between p-4 transition-all ${ expandedSections.quickSettings - ? 'bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20' + ? 'bg-gradient-to-r from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20' : 'bg-[rgb(var(--surface))] hover:bg-[rgb(var(--surface-hover))]' }`} > @@ -1246,7 +1246,7 @@ export default function ClientsPage() { onClick={() => toggleSection('clientInfo')} className={`w-full flex items-center justify-between p-4 transition-all ${ expandedSections.clientInfo - ? 'bg-gradient-to-r from-gray-50 to-slate-50 dark:from-gray-900/20 dark:to-slate-900/20' + ? 'bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/10 dark:to-primary-800/10' : 'bg-[rgb(var(--surface))] hover:bg-[rgb(var(--surface-hover))]' }`} > diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index bf26e009..6ea56841 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -332,7 +332,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda onClick={() => toggleSection('settings')} className={`w-full flex items-center justify-between p-4 transition-all ${ expandedSections.settings - ? 'bg-gradient-to-r from-gray-50 to-slate-50 dark:from-gray-900/20 dark:to-slate-900/20' + ? 'bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/10 dark:to-primary-800/10' : 'bg-[rgb(var(--surface))] hover:bg-[rgb(var(--surface-hover))]' }`} > diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index c0c0f25a..deb43343 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -4,93 +4,92 @@ @layer base { :root { - /* Light theme - Material Design 3 inspired */ - /* Surface tones for elevation */ - --background: 250 250 250; /* Slightly off-white */ + /* Light theme — Neutral surfaces, terracotta accent */ + --background: 249 249 249; /* Clean off-white */ --foreground: 28 28 30; /* Near black */ - + --surface: 255 255 255; /* Pure white for cards */ - --surface-dim: 245 245 245; /* Slightly dimmed surface */ - --surface-hover: 240 240 243; - --surface-active: 230 230 235; - + --surface-dim: 244 244 245; /* Slightly dimmed */ + --surface-hover: 240 240 242; /* Hover */ + --surface-active: 232 232 235; /* Active */ + /* Elevated surfaces (dropdowns, modals) */ --surface-elevated: 255 255 255; --surface-overlay: 255 255 255; - - --border: 218 218 222; - --border-subtle: 235 235 238; - - --muted: 108 108 112; - --muted-foreground: 140 140 145; - - /* Primary - Cyan/Teal */ - --primary: 6 182 212; - --primary-hover: 8 145 178; + + --border: 225 220 216; /* Very slight warm tint */ + --border-subtle: 238 234 230; /* Subtle warm tint */ + + --muted: 118 110 105; /* Slightly warm gray */ + --muted-foreground: 152 144 138; + + /* Primary — Terracotta */ + --primary: 218 119 86; /* #DA7756 */ + --primary-hover: 184 85 58; /* #B8553A (sienna) */ --primary-foreground: 255 255 255; - + /* Semantic colors */ --success: 34 197 94; --warning: 245 158 11; --error: 239 68 68; --info: 59 130 246; - + /* Card specific */ --card: 255 255 255; --card-foreground: 28 28 30; - + /* Input */ --input: 255 255 255; - --input-border: 209 213 219; - - /* Shadows - visible in light mode */ + --input-border: 215 210 206; + + /* Shadows — neutral with subtle warmth */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.06); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.06); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.06); } .dark { - /* Dark theme - Material Design 3 dark */ - --background: 18 18 20; /* Dark background */ - --foreground: 245 245 247; /* Light text */ - - --surface: 28 28 32; /* Card surface */ - --surface-dim: 22 22 26; - --surface-hover: 42 42 48; - --surface-active: 55 55 62; - - /* Elevated surfaces - slightly lighter in dark mode */ - --surface-elevated: 38 38 44; - --surface-overlay: 45 45 52; - - --border: 55 55 62; - --border-subtle: 42 42 48; - - --muted: 156 156 162; - --muted-foreground: 120 120 128; - - /* Primary - brighter in dark */ - --primary: 34 211 238; - --primary-hover: 6 182 212; - --primary-foreground: 18 18 20; - - /* Semantic colors - slightly brighter */ + /* Dark theme — Neutral dark with warm accent */ + --background: 20 20 22; /* Dark neutral */ + --foreground: 245 244 243; /* Slightly warm white */ + + --surface: 30 30 33; /* Card surface */ + --surface-dim: 24 24 27; + --surface-hover: 44 42 46; + --surface-active: 56 54 58; + + /* Elevated surfaces */ + --surface-elevated: 38 37 41; + --surface-overlay: 46 44 48; + + --border: 56 53 50; /* Slight warm tint */ + --border-subtle: 44 42 40; + + --muted: 160 155 150; /* Warm gray */ + --muted-foreground: 125 120 115; + + /* Primary — Warm Coral (brighter for dark mode readability) */ + --primary: 232 149 106; /* #E8956A */ + --primary-hover: 218 119 86; /* #DA7756 */ + --primary-foreground: 20 20 22; + + /* Semantic colors — slightly brighter */ --success: 74 222 128; --warning: 251 191 36; --error: 248 113 113; --info: 96 165 250; - + /* Card */ - --card: 28 28 32; - --card-foreground: 245 245 247; - + --card: 30 30 33; + --card-foreground: 245 244 243; + /* Input */ - --input: 38 38 44; - --input-border: 55 55 62; - - /* Shadows - more subtle in dark mode */ + --input: 38 37 41; + --input-border: 56 53 50; + + /* Shadows */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.3); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3); diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 9459baa9..980c939a 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,21 +1,23 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { invoke } from '@tauri-apps/api/core'; +import { emit } from '@tauri-apps/api/event'; import App from './App'; import './index.css'; // Expose Tauri API for E2E testing -// This allows tests to set up data programmatically +// This allows tests to set up data and simulate events programmatically declare global { interface Window { __TAURI_TEST_API__?: { invoke: typeof invoke; + emit: typeof emit; }; } } // Always expose for now - can be gated by env var if needed -window.__TAURI_TEST_API__ = { invoke }; +window.__TAURI_TEST_API__ = { invoke, emit }; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index 1226a997..8735f744 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -9,19 +9,34 @@ export default { theme: { extend: { colors: { - // Brand colors - using cyan/teal for McpMux identity + // McpMux brand colors — terracotta/warm palette primary: { - 50: '#ecfeff', - 100: '#cffafe', - 200: '#a5f3fc', - 300: '#67e8f9', - 400: '#22d3ee', - 500: '#06b6d4', - 600: '#0891b2', - 700: '#0e7490', - 800: '#155e75', - 900: '#164e63', - 950: '#083344', + 50: '#fef7f4', + 100: '#fde8df', + 200: '#fbd0be', + 300: '#f6ab8a', + 400: '#E8956A', + 500: '#DA7756', + 600: '#C96442', + 700: '#B8553A', + 800: '#8B3D20', + 900: '#6B2E18', + 950: '#3D1A0D', + }, + // McpMux extended brand palette + mcpmux: { + terracotta: '#DA7756', + sienna: '#B8553A', + deep: '#C2593A', + warm: '#E8956A', + ember: '#C96442', + amber: '#D4945A', + cream: '#FDF2E9', + 'cream-deep': '#F5E0D0', + dark: '#1A120E', + 'dark-surface': '#2D1A12', + ochre: '#C68B59', + burnt: '#8B3D20', }, // Surface colors (uses CSS variables) surface: { diff --git a/crates/mcpmux-core/src/service/registry_api_client.rs b/crates/mcpmux-core/src/service/registry_api_client.rs index ce44b5e6..a3a5d0cf 100644 --- a/crates/mcpmux-core/src/service/registry_api_client.rs +++ b/crates/mcpmux-core/src/service/registry_api_client.rs @@ -219,8 +219,11 @@ mod tests { #[tokio::test] async fn test_fetch_bundle_from_local() { - // Assumes local dev server is running: pnpm dev - let client = RegistryApiClient::new("http://localhost:8787".to_string()); + // Uses deployed API by default, or MCPMUX_REGISTRY_URL env var + let client = RegistryApiClient::new( + std::env::var("MCPMUX_REGISTRY_URL") + .unwrap_or_else(|_| "https://api.mcpmux.com".to_string()), + ); let result = client.fetch_bundle(None).await; @@ -241,7 +244,10 @@ mod tests { #[tokio::test] async fn test_fetch_bundle_with_etag() { - let client = RegistryApiClient::new("http://localhost:8787".to_string()); + let client = RegistryApiClient::new( + std::env::var("MCPMUX_REGISTRY_URL") + .unwrap_or_else(|_| "https://api.mcpmux.com".to_string()), + ); // First fetch to get ETag let first_result = client.fetch_bundle(None).await; diff --git a/packages/ui/src/components/layout/AppShell.tsx b/packages/ui/src/components/layout/AppShell.tsx index c381a35d..2175e28b 100644 --- a/packages/ui/src/components/layout/AppShell.tsx +++ b/packages/ui/src/components/layout/AppShell.tsx @@ -5,14 +5,25 @@ interface AppShellProps { sidebar: ReactNode; children: ReactNode; statusBar?: ReactNode; + titleBar?: ReactNode; + windowControls?: ReactNode; className?: string; } -export function AppShell({ sidebar, children, statusBar, className }: AppShellProps) { +export function AppShell({ sidebar, children, statusBar, titleBar, windowControls, className }: AppShellProps) { return (
- {/* Title bar (draggable) */} -
+ {/* Custom title bar */} + {titleBar && ( +
+ {/* Draggable area — fills space between logo and window controls */} +
+ {titleBar} +
+ {/* Window controls — outside drag region so clicks work */} + {windowControls} +
+ )} {/* Main content */}
@@ -36,4 +47,3 @@ export function AppShell({ sidebar, children, statusBar, className }: AppShellPr
); } - diff --git a/scripts/generate-icns.mjs b/scripts/generate-icns.mjs new file mode 100644 index 00000000..2b0a037b --- /dev/null +++ b/scripts/generate-icns.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Generate a macOS ICNS file from the McpMux branding SVG. + * ICNS format with PNG payloads for modern macOS. + */ + +import sharp from 'sharp'; +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const ICONS_DIR = join(import.meta.dirname, '..', 'apps', 'desktop', 'src-tauri', 'icons'); +const SVG_FULL = join(import.meta.dirname, '..', '..', 'mcpmux.space', 'branding', 'icons', 'appicon-512.svg'); +const SVG_SMALL = join(ICONS_DIR, 'appicon-small.svg'); + +const svgFullBuffer = readFileSync(SVG_FULL); +const svgSmallBuffer = readFileSync(SVG_SMALL); + +const SMALL_THRESHOLD = 64; +function svgFor(size) { + return size <= SMALL_THRESHOLD ? svgSmallBuffer : svgFullBuffer; +} + +// ICNS icon types that accept PNG data +const ICNS_TYPES = [ + { type: 'ic07', size: 128 }, // 128x128 + { type: 'ic08', size: 256 }, // 256x256 + { type: 'ic09', size: 512 }, // 512x512 + { type: 'ic10', size: 1024 }, // 1024x1024 (512@2x) + { type: 'ic11', size: 32 }, // 16x16@2x + { type: 'ic12', size: 64 }, // 32x32@2x + { type: 'ic13', size: 256 }, // 128x128@2x + { type: 'ic14', size: 512 }, // 256x256@2x +]; + +async function main() { + console.log('Generating icon.icns...'); + + const entries = []; + for (const { type, size } of ICNS_TYPES) { + const svg = svgFor(size); + const png = await sharp(svg, { density: Math.round(72 * size / 512 * 4) }) + .resize(size, size) + .png() + .toBuffer(); + entries.push({ type, data: png }); + } + + // Calculate total size + let totalSize = 8; // ICNS header + for (const { data } of entries) { + totalSize += 8 + data.length; // type(4) + length(4) + data + } + + const icns = Buffer.alloc(totalSize); + let offset = 0; + + // ICNS magic header + icns.write('icns', offset, 'ascii'); offset += 4; + icns.writeUInt32BE(totalSize, offset); offset += 4; + + // Write each entry + for (const { type, data } of entries) { + icns.write(type, offset, 'ascii'); offset += 4; + icns.writeUInt32BE(8 + data.length, offset); offset += 4; + data.copy(icns, offset); offset += data.length; + } + + const outPath = join(ICONS_DIR, 'icon.icns'); + writeFileSync(outPath, icns); + console.log(` ✓ icon.icns (${totalSize} bytes, ${entries.length} sizes)`); +} + +main().catch(console.error); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 00000000..b5874e0b --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Generate Tauri app icons from the McpMux branding SVG. + * Requires: sharp (installed as workspace devDependency) + * + * Generates: + * - 32x32.png (tray icon) + * - 128x128.png + * - 128x128@2x.png (256x256) + * - icon.png (512x512) + * - icon.ico (multi-resolution Windows icon) + * - Square*.png (Windows Store icons) + * - StoreLogo.png (50x50) + */ + +import sharp from 'sharp'; +import { readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +const ICONS_DIR = join(import.meta.dirname, '..', 'apps', 'desktop', 'src-tauri', 'icons'); +const SVG_FULL = join(import.meta.dirname, '..', '..', 'mcpmux.space', 'branding', 'icons', 'appicon-512.svg'); +const SVG_SMALL = join(ICONS_DIR, 'appicon-small.svg'); + +const svgFullBuffer = readFileSync(SVG_FULL); +const svgSmallBuffer = readFileSync(SVG_SMALL); + +// Use simplified SVG for sizes ≤ 64px (taskbar/tray), full detail for larger +const SMALL_THRESHOLD = 64; +function svgFor(size) { + return size <= SMALL_THRESHOLD ? svgSmallBuffer : svgFullBuffer; +} + +// PNG sizes to generate +const pngSizes = [ + { name: '32x32.png', size: 32 }, + { name: '128x128.png', size: 128 }, + { name: '128x128@2x.png', size: 256 }, + { name: 'icon.png', size: 512 }, + // Windows Store icons + { name: 'Square30x30Logo.png', size: 30 }, + { name: 'Square44x44Logo.png', size: 44 }, + { name: 'Square71x71Logo.png', size: 71 }, + { name: 'Square89x89Logo.png', size: 89 }, + { name: 'Square107x107Logo.png', size: 107 }, + { name: 'Square142x142Logo.png', size: 142 }, + { name: 'Square150x150Logo.png', size: 150 }, + { name: 'Square284x284Logo.png', size: 284 }, + { name: 'Square310x310Logo.png', size: 310 }, + { name: 'StoreLogo.png', size: 50 }, +]; + +async function generatePngs() { + for (const { name, size } of pngSizes) { + const outPath = join(ICONS_DIR, name); + const svg = svgFor(size); + await sharp(svg, { density: Math.round(72 * size / 512 * 4) }) + .resize(size, size) + .png() + .toFile(outPath); + console.log(` ✓ ${name} (${size}x${size})${size <= SMALL_THRESHOLD ? ' [simplified]' : ''}`); + } +} + +/** + * Generate a minimal ICO file containing multiple resolutions. + * ICO format: ICONDIR header + ICONDIRENTRY[] + PNG data chunks + */ +async function generateIco() { + const icoSizes = [16, 24, 32, 48, 64, 128, 256]; + const pngBuffers = []; + + for (const size of icoSizes) { + const svg = svgFor(size); + const buf = await sharp(svg, { density: Math.round(72 * size / 512 * 4) }) + .resize(size, size) + .png() + .toBuffer(); + pngBuffers.push({ size, data: buf }); + } + + // ICO file format + const ICONDIR_SIZE = 6; + const ICONDIRENTRY_SIZE = 16; + const headerSize = ICONDIR_SIZE + ICONDIRENTRY_SIZE * pngBuffers.length; + + let totalSize = headerSize; + for (const { data } of pngBuffers) { + totalSize += data.length; + } + + const ico = Buffer.alloc(totalSize); + let offset = 0; + + // ICONDIR + ico.writeUInt16LE(0, offset); offset += 2; // Reserved + ico.writeUInt16LE(1, offset); offset += 2; // Type: 1 = ICO + ico.writeUInt16LE(pngBuffers.length, offset); offset += 2; // Count + + // ICONDIRENTRY for each image + let dataOffset = headerSize; + for (const { size, data } of pngBuffers) { + ico.writeUInt8(size >= 256 ? 0 : size, offset); offset += 1; // Width + ico.writeUInt8(size >= 256 ? 0 : size, offset); offset += 1; // Height + ico.writeUInt8(0, offset); offset += 1; // Color palette + ico.writeUInt8(0, offset); offset += 1; // Reserved + ico.writeUInt16LE(1, offset); offset += 2; // Color planes + ico.writeUInt16LE(32, offset); offset += 2; // Bits per pixel + ico.writeUInt32LE(data.length, offset); offset += 4; // Size of PNG data + ico.writeUInt32LE(dataOffset, offset); offset += 4; // Offset to PNG data + dataOffset += data.length; + } + + // PNG data + for (const { data } of pngBuffers) { + data.copy(ico, offset); + offset += data.length; + } + + const outPath = join(ICONS_DIR, 'icon.ico'); + writeFileSync(outPath, ico); + console.log(` ✓ icon.ico (${icoSizes.join(', ')}px)`); +} + +async function main() { + console.log('Generating McpMux icons from branding SVG...'); + console.log(` Full: ${SVG_FULL}`); + console.log(` Small (≤${SMALL_THRESHOLD}px): ${SVG_SMALL}`); + console.log(` Output: ${ICONS_DIR}\n`); + + await generatePngs(); + await generateIco(); + + console.log('\nDone! All icons generated.'); + console.log('Note: icon.icns (macOS) should be generated on macOS using iconutil.'); +} + +main().catch(console.error); diff --git a/tests/e2e/helpers/tauri-api.ts b/tests/e2e/helpers/tauri-api.ts index 120b7fe3..8b21bf6c 100644 --- a/tests/e2e/helpers/tauri-api.ts +++ b/tests/e2e/helpers/tauri-api.ts @@ -14,6 +14,16 @@ export async function invoke(command: string, args?: Record) }, command, args || {}) as Promise; } +// Emit a Tauri event (for simulating deep link events in tests) +export async function emitEvent(event: string, payload: unknown): Promise { + return browser.execute(async (evt: string, data: unknown) => { + if (!window.__TAURI_TEST_API__?.emit) { + throw new Error('Tauri Test API emit not available'); + } + return window.__TAURI_TEST_API__.emit(evt, data); + }, event, payload) as Promise; +} + // ============================================================================ // Space API // ============================================================================ diff --git a/tests/e2e/specs/deeplink-install.wdio.ts b/tests/e2e/specs/deeplink-install.wdio.ts new file mode 100644 index 00000000..96fdee96 --- /dev/null +++ b/tests/e2e/specs/deeplink-install.wdio.ts @@ -0,0 +1,250 @@ +/** + * E2E Tests: Deep Link Server Install + * Tests the mcpmux://install?server=xxx flow triggered from the discovery UI. + * Uses data-testid only (ADR-003). + */ + +import { byTestId, TIMEOUT, waitForModalClose } from '../helpers/selectors'; +import { + emitEvent, + getActiveSpace, + listInstalledServers, + uninstallServer, +} from '../helpers/tauri-api'; + +const ECHO_SERVER_ID = 'echo-server'; + +/** Simulate a deep link install event (as if mcpmux://install?server=xxx was received) */ +async function simulateInstallDeepLink(serverId: string) { + await emitEvent('server-install-request', { serverId }); +} + +describe('Deep Link Install - Valid Server', () => { + let activeSpaceId: string; + + before(async () => { + // Get active space for cleanup + const space = await getActiveSpace(); + activeSpaceId = space?.id || ''; + + // Ensure echo-server is not installed (clean state) + if (activeSpaceId) { + const installed = await listInstalledServers(activeSpaceId); + const echoInstalled = installed.some( + (s) => s.server_id === ECHO_SERVER_ID + ); + if (echoInstalled) { + try { + await uninstallServer(ECHO_SERVER_ID, activeSpaceId); + await browser.pause(1000); + } catch (e) { + console.log('[setup] Could not uninstall echo-server:', e); + } + } + } + }); + + it('TC-DL-001: Deep link shows install modal with server info', async () => { + await simulateInstallDeepLink(ECHO_SERVER_ID); + await browser.pause(3000); // Wait for server definition lookup + + await browser.saveScreenshot( + './tests/e2e/screenshots/dl-01-install-modal.png' + ); + + // Modal should be displayed (either loading or ready) + const modal = await byTestId('install-modal'); + const loading = await byTestId('install-modal-loading'); + const modalDisplayed = await modal.isDisplayed().catch(() => false); + const loadingDisplayed = await loading.isDisplayed().catch(() => false); + + expect(modalDisplayed || loadingDisplayed).toBe(true); + + // If ready, verify server name is shown + if (modalDisplayed) { + const serverName = await byTestId('install-modal-server-name'); + const nameDisplayed = await serverName.isDisplayed().catch(() => false); + if (nameDisplayed) { + const text = await serverName.getText(); + expect(text).toContain('Echo'); + } + } + }); + + it('TC-DL-002: Install modal shows space picker', async () => { + const modal = await byTestId('install-modal'); + const isDisplayed = await modal.isDisplayed().catch(() => false); + + if (isDisplayed) { + const spaceSelect = await byTestId('install-modal-space-select'); + const selectDisplayed = await spaceSelect.isDisplayed().catch(() => false); + expect(selectDisplayed).toBe(true); + + await browser.saveScreenshot( + './tests/e2e/screenshots/dl-02-space-picker.png' + ); + } else { + // Modal might still be loading or already closed on slow CI + const pageSource = await browser.getPageSource(); + const hasModal = + pageSource.includes('Install Server') || + pageSource.includes('Looking up server'); + expect(hasModal).toBe(true); + } + }); + + it('TC-DL-003: Clicking Install button installs the server', async () => { + const modal = await byTestId('install-modal'); + const isDisplayed = await modal.isDisplayed().catch(() => false); + + if (isDisplayed) { + const installBtn = await byTestId('install-modal-install-btn'); + await installBtn.waitForClickable({ timeout: TIMEOUT.medium }); + await installBtn.click(); + await browser.pause(3000); + + await browser.saveScreenshot( + './tests/e2e/screenshots/dl-03-after-install.png' + ); + + // Should show success state or have auto-dismissed + const success = await byTestId('install-modal-success'); + const successDisplayed = await success.isDisplayed().catch(() => false); + + if (successDisplayed) { + const successMsg = await byTestId('install-modal-success-message'); + const msgText = await successMsg.getText().catch(() => ''); + expect(msgText).toContain('has been added'); + } + + // Wait for auto-dismiss + await browser.pause(3000); + await waitForModalClose(); + } + + // Verify server is actually installed via API + if (activeSpaceId) { + const installed = await listInstalledServers(activeSpaceId); + const echoInstalled = installed.some( + (s) => s.server_id === ECHO_SERVER_ID + ); + expect(echoInstalled).toBe(true); + } + }); + + it('TC-DL-004: Deep link for already-installed server shows warning', async () => { + // Echo server was installed in TC-DL-003 + await simulateInstallDeepLink(ECHO_SERVER_ID); + await browser.pause(3000); + + await browser.saveScreenshot( + './tests/e2e/screenshots/dl-04-already-installed.png' + ); + + const modal = await byTestId('install-modal'); + const isDisplayed = await modal.isDisplayed().catch(() => false); + + if (isDisplayed) { + // Should show "already installed" warning + const alreadyInstalled = await byTestId('install-modal-already-installed'); + const warningDisplayed = await alreadyInstalled + .isDisplayed() + .catch(() => false); + expect(warningDisplayed).toBe(true); + + // Install button should be disabled + const installBtn = await byTestId('install-modal-install-btn'); + const isDisabled = await installBtn.getAttribute('disabled'); + expect(isDisabled).not.toBeNull(); + + // Dismiss the modal + const cancelBtn = await byTestId('install-modal-cancel-btn'); + await cancelBtn.click(); + await browser.pause(1000); + await waitForModalClose(); + } + }); + + it('TC-DL-005: Cancel button dismisses the modal', async () => { + // First uninstall so we get a clean modal + if (activeSpaceId) { + try { + await uninstallServer(ECHO_SERVER_ID, activeSpaceId); + await browser.pause(1000); + } catch (e) { + /* ignore */ + } + } + + await simulateInstallDeepLink(ECHO_SERVER_ID); + await browser.pause(3000); + + const modal = await byTestId('install-modal'); + const isDisplayed = await modal.isDisplayed().catch(() => false); + + if (isDisplayed) { + const cancelBtn = await byTestId('install-modal-cancel-btn'); + await cancelBtn.click(); + await browser.pause(1000); + + // Modal should be gone + const modalAfter = await byTestId('install-modal'); + const stillDisplayed = await modalAfter.isDisplayed().catch(() => false); + expect(stillDisplayed).toBe(false); + } + + await browser.saveScreenshot( + './tests/e2e/screenshots/dl-05-dismissed.png' + ); + }); + + after(async () => { + // Cleanup: uninstall echo-server if it was installed + if (activeSpaceId) { + try { + await uninstallServer(ECHO_SERVER_ID, activeSpaceId); + } catch (e) { + /* ignore */ + } + } + await waitForModalClose(); + }); +}); + +describe('Deep Link Install - Invalid Server', () => { + it('TC-DL-006: Deep link with unknown server ID shows error', async () => { + await simulateInstallDeepLink('nonexistent-server-12345'); + await browser.pause(3000); + + await browser.saveScreenshot( + './tests/e2e/screenshots/dl-06-not-found.png' + ); + + const errorModal = await byTestId('install-modal-error'); + const isDisplayed = await errorModal.isDisplayed().catch(() => false); + + if (isDisplayed) { + // Error message should mention the server was not found + const errorMsg = await byTestId('install-modal-error-message'); + const text = await errorMsg.getText().catch(() => ''); + expect(text).toContain('not found'); + + // Close the error modal + const closeBtn = await byTestId('install-modal-close-btn'); + await closeBtn.click(); + await browser.pause(1000); + await waitForModalClose(); + } else { + // On slow CI, check page source + const pageSource = await browser.getPageSource(); + const hasError = + pageSource.includes('not found') || + pageSource.includes('Server Not Found'); + expect(hasError).toBe(true); + } + }); + + after(async () => { + await waitForModalClose(); + }); +});