+
{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();
+ });
+});