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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/desktop/src-tauri/src/commands/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,28 @@ pub async fn get_oauth_clients(
Ok(client_infos)
}

/// Approve a registered OAuth client by ID (for E2E testing).
/// In production, clients are approved via the consent flow.
#[tauri::command]
pub async fn approve_oauth_client(
client_id: String,
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
) -> Result<(), String> {
let app_state = gateway_state.read().await;
let Some(ref gw_state) = app_state.gateway_state else {
return Err("Gateway not running".to_string());
};
let state = gw_state.read().await;
let Some(repo) = state.inbound_client_repository() else {
return Err("Database not available".to_string());
};
repo.approve_client(&client_id)
.await
.map_err(|e| format!("Failed to approve client: {}", e))?;
info!("[OAuth] Approved client via test command: {}", client_id);
Ok(())
}

/// Information about a connected OAuth client
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthClientInfo {
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ pub fn run() {
commands::approve_oauth_consent,
commands::get_pending_consent,
commands::get_oauth_clients,
commands::approve_oauth_client,
commands::update_oauth_client,
commands::delete_oauth_client,
commands::get_oauth_client_grants,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/assets/client-icons/claude.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions apps/desktop/src/assets/client-icons/cursor.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/desktop/src/assets/client-icons/windsurf.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 31 additions & 9 deletions apps/desktop/src/features/clients/ClientsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { useState, useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import cursorIcon from '@/assets/client-icons/cursor.svg';
import vscodeIcon from '@/assets/client-icons/vscode.png';
import claudeIcon from '@/assets/client-icons/claude.svg';
import windsurfIcon from '@/assets/client-icons/windsurf.svg';
import {
Laptop,
Loader2,
Expand Down Expand Up @@ -75,17 +79,35 @@ const CONNECTION_MODES = [
},
];

// Client icon component
// Bundled icons for well-known AI clients (matched by client_name)
const KNOWN_CLIENT_ICONS: Record<string, string> = {
cursor: cursorIcon,
'vs code': vscodeIcon,
vscode: vscodeIcon,
'visual studio code': vscodeIcon,
'claude desktop': claudeIcon,
claude: claudeIcon,
windsurf: windsurfIcon,
codeium: windsurfIcon,
};

// Client icon component — uses bundled icon for known clients, falls back to logo_uri, then emoji
function ClientIcon({ logo_uri, client_name }: { logo_uri?: string | null; client_name: string }) {
if (logo_uri) {
return <img src={logo_uri} alt={client_name} className="w-6 h-6 rounded" />;
const iconUrl = KNOWN_CLIENT_ICONS[client_name.toLowerCase()] || logo_uri;
if (iconUrl) {
return (
<img
src={iconUrl}
alt={client_name}
className="w-full h-full object-contain rounded"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement!.append(document.createTextNode('🤖'));
}}
/>
);
}

// Default icons based on client name
if (client_name.toLowerCase().includes('cursor')) return '⚡';
if (client_name.toLowerCase().includes('vscode')) return '💻';
if (client_name.toLowerCase().includes('code')) return '📝';
return '🤖';
return <span>🤖</span>;
}

export default function ClientsPage() {
Expand Down
36 changes: 22 additions & 14 deletions apps/desktop/src/features/featuresets/FeatureSetsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,20 +172,28 @@ export function FeatureSetsPage() {
loadData(viewSpace?.id); // Refresh list to get updated member counts etc.
};

// Filter feature sets (backend already filters server-all for disabled servers)
const filteredSets = featureSets.filter(fs => {
// Hide implicit custom sets
if (fs.name.endsWith(' - Custom')) return false;

// Apply search filter
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
fs.name.toLowerCase().includes(query) ||
fs.description?.toLowerCase().includes(query) ||
fs.feature_set_type.toLowerCase().includes(query)
);
});
// Filter and sort feature sets (backend already filters server-all for disabled servers)
const filteredSets = featureSets
.filter(fs => {
// Hide implicit custom sets
if (fs.name.endsWith(' - Custom')) return false;

// Apply search filter
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
fs.name.toLowerCase().includes(query) ||
fs.description?.toLowerCase().includes(query) ||
fs.feature_set_type.toLowerCase().includes(query)
);
})
.sort((a, b) => {
// Sort order: all → default → custom → server-all
const order: Record<string, number> = { all: 0, default: 1, custom: 2, 'server-all': 3 };
const aOrder = order[a.feature_set_type] ?? 2;
const bOrder = order[b.feature_set_type] ?? 2;
return aOrder - bOrder;
});

return (
<>
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/features/registry/ServerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function ServerCard({
>
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<div className="text-3xl flex-shrink-0">{server.icon || '📦'}</div>
<div className="text-3xl flex-shrink-0 flex items-center justify-center">
{server.icon?.startsWith('http') ? (
<img src={server.icon} alt="" className="w-9 h-9 object-contain" onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentElement!.append(document.createTextNode('📦')); }} />
) : (
server.icon || '📦'
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<h3 className="font-semibold truncate" title={server.name} data-testid={`server-name-${server.id}`}>
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/features/servers/ServerCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ export function ServerCard({
<div className="flex items-center gap-4 min-w-0 flex-1">
{/* Icon */}
<div className="w-10 h-10 rounded-lg bg-[rgb(var(--surface-dim))] flex items-center justify-center flex-shrink-0 text-xl">
{server.icon || "🔌"}
{server.icon?.startsWith('http') ? (
<img src={server.icon} alt="" className="w-7 h-7 object-contain" onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentElement!.append(document.createTextNode('📦')); }} />
) : (
server.icon || "🔌"
)}
</div>

{/* Name & Status */}
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/features/servers/ServersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,7 @@ export function ServersPage() {
<button
onClick={() => toggleExpanded(server.id)}
className="p-1 rounded hover:bg-[rgb(var(--surface-hover))] transition-colors"
data-testid={`expand-server-${server.id}`}
>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-[rgb(var(--muted))]" />
Expand All @@ -908,7 +909,13 @@ export function ServersPage() {
</button>
)}

<div className="text-3xl">{server.icon || '📦'}</div>
<div className="text-3xl flex items-center justify-center">
{server.icon?.startsWith('http') ? (
<img src={server.icon} alt="" className="w-8 h-8 object-contain" onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentElement!.append(document.createTextNode('📦')); }} />
) : (
server.icon || '📦'
)}
</div>
<div>
<div className="font-medium">{server.name}</div>
<div className="text-sm text-[rgb(var(--muted))] max-w-md truncate">
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/features/spaces/SpacesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,10 @@ export function SpacesPage() {
}`}
data-testid={`space-card-${space.id}`}
>
<CardContent className="p-5">
<CardContent className="p-4">
{/* Header */}
<div className="flex items-start gap-3 mb-4">
<div className="w-11 h-11 flex items-center justify-center bg-[rgb(var(--surface))] rounded-lg text-2xl border border-[rgb(var(--border-subtle))] flex-shrink-0">
<div className="flex items-start gap-2.5 mb-3">
<div className="w-9 h-9 flex items-center justify-center bg-[rgb(var(--surface))] rounded-lg text-xl border border-[rgb(var(--border-subtle))] flex-shrink-0">
{space.icon || '🌐'}
</div>
<div className="flex-1 min-w-0">
Expand Down
12 changes: 6 additions & 6 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,29 +211,29 @@
/* Responsive auto-fill grid for cards */
.auto-fill-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 1.5rem;
}

/* Larger cards on wider screens */
@media (min-width: 1280px) {
.auto-fill-cards {
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 2rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.75rem;
}
}

@media (min-width: 1536px) {
.auto-fill-cards {
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 2rem;
}
}

@media (min-width: 1920px) {
.auto-fill-cards {
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 2.5rem;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 2rem;
}
}
}
61 changes: 61 additions & 0 deletions docs/screenshots/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Screenshot Generation

Automated screenshots of the McpMux desktop app for docs and marketing.

## Quick Start

```bash
# 1. Build the desktop app (required — E2E runs against the release binary)
pnpm build

# 2. Run the screenshot capture spec
pnpm exec wdio run tests/e2e/wdio.conf.ts --spec tests/e2e/specs/capture-screenshots.manual.ts
```

## What Gets Captured

| Screenshot | Page | Content |
|---|---|---|
| `dashboard.png` | Dashboard | Gateway status, stats cards, client config snippet |
| `servers.png` | My Servers | Installed servers with connection states |
| `discover.png` | Discover | Server registry (mock bundle with 12 servers) |
| `spaces.png` | Spaces | Workspace cards (default + 3 additional) |
| `featuresets.png` | FeatureSets | Permission bundles |
| `clients.png` | Clients | Connected AI clients (Cursor, VS Code, Claude Desktop) |
| `settings.png` | Settings | App settings page |

## Output Locations

Screenshots are saved to two locations:

- **`mcp-mux/docs/screenshots/`** — Used by the project README
- **`mcpmux.discover.ui/public/screenshots/`** — Used by the discover website

## Editing Mock Data

All screenshot mock data lives in a single file:

```
tests/e2e/mocks/screenshot-preseed.ts
```

This controls:
- **Spaces** created (name, icon)
- **Servers** installed and enabled (by ID from `fixtures.ts`)
- **FeatureSets** created (name, description)
- **OAuth Clients** shown on the Clients page (name, version, connection mode)

After editing, re-run the capture command above.

## How It Works

1. The E2E framework launches the Tauri app with a mock bundle API (port 8787)
2. The `before()` hook seeds data via `window.__TAURI_TEST_API__` (spaces, servers, feature sets)
3. OAuth clients are mocked by intercepting `window.__TAURI_INTERNALS__.invoke`
4. Each `it()` block navigates to a page and saves a screenshot

## Notes

- This spec uses `.manual.ts` (not `.wdio.ts`) so it is **excluded from `pnpm test:e2e`** — it only runs when invoked explicitly
- Server definitions come from `tests/e2e/mocks/mock-bundle-api/fixtures.ts`
- The app must be built before running (`pnpm build`) — the signing error is non-blocking
Binary file modified docs/screenshots/clients.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/discover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/featuresets.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/servers.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/spaces.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 23 additions & 15 deletions tests/e2e/helpers/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,29 @@ export const byTestId = (testId: string) => $(`[data-testid="${testId}"]`);
*/
export async function waitForModalClose(timeout = TIMEOUT.short): Promise<void> {
try {
const overlay = await $('.fixed.inset-0.bg-black\\/20');
const exists = await overlay.isExisting().catch(() => false);

if (!exists) {
return; // No modal, nothing to wait for
}

// Try to wait for it to close naturally
const closed = await overlay.waitForDisplayed({ timeout, reverse: true }).then(() => true).catch(() => false);

if (!closed) {
// Modal still open - try to dismiss it with Escape key
console.log('[waitForModalClose] Modal still displayed, trying Escape key');
await browser.keys('Escape');
await browser.pause(500);
// Match any fixed fullscreen overlay (bg-black/20, bg-black/50, bg-black/60)
const overlays = await $$('.fixed.inset-0');

for (const overlay of overlays) {
const isDisplayed = await overlay.isDisplayed().catch(() => false);
if (!isDisplayed) continue;

// Check if it looks like a modal backdrop (has bg-black in its classes)
const cls = await overlay.getAttribute('class').catch(() => '') ?? '';
if (!cls.includes('bg-black')) continue;

// Try to wait for it to close naturally
const closed = await overlay
.waitForDisplayed({ timeout, reverse: true })
.then(() => true)
.catch(() => false);

if (!closed) {
// Modal still open - try to dismiss it with Escape key
console.log('[waitForModalClose] Modal still displayed, trying Escape key');
await browser.keys('Escape');
await browser.pause(500);
}
}
} catch {
// Silently continue - modal handling shouldn't fail tests
Expand Down
Loading