Skip to content

Commit 4b033a1

Browse files
author
Mohammod Al Amin Ashik
committed
feat: improve UX and fix Windows console visibility issues
This commit addresses two key issues and enhances the auto-start functionality: Issue #40: Settings save feedback - Added Toast notification system to UI package - Success toast shows when settings are saved - Error toast shows if save fails - Auto-dismiss after 3 seconds with manual close option - Added comprehensive unit and e2e tests for toast functionality Issue #39: Hidden console windows for docker servers - Fixed stdio servers opening visible terminal windows on Windows - Added CREATE_NO_WINDOW flag to child process creation - Only affects Windows builds via conditional compilation Auto-start improvements: - Changed default auto_launch to true for gateway apps - Auto-enable on first launch without requiring Settings UI interaction - Added startup.autostart_configured flag to respect user choices - User can still disable in Settings and it persists across launches Deep link registry documentation: - Updated spike doc with correct HKLM vs HKCU registry behavior - NSIS installs write to HKCU (per-user) - MSI installs write to HKLM (per-machine) - Both merge into HKCR at runtime Tests: - Added Toast component unit tests - Added useToast hook unit tests - Extended SettingsPage e2e tests for startup settings - Added toast visibility and dismissal tests Fixes #39, #40
1 parent d4fc849 commit 4b033a1

15 files changed

Lines changed: 703 additions & 10 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ serde_json.workspace = true
1717
[dependencies]
1818
tauri = { version = "2", features = ["tray-icon"] }
1919
tauri-plugin-opener = "2"
20-
tauri-plugin-single-instance = "2"
20+
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
2121
tauri-plugin-deep-link = "2"
2222
tauri-plugin-updater = "2"
2323
tauri-plugin-autostart = "2"

apps/desktop/src-tauri/src/commands/settings.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub struct StartupSettings {
2222
impl Default for StartupSettings {
2323
fn default() -> Self {
2424
Self {
25-
auto_launch: false,
25+
auto_launch: true,
2626
start_minimized: true,
2727
close_to_tray: true, // Default to close-to-tray behavior
2828
}
@@ -96,6 +96,12 @@ pub async fn update_startup_settings(
9696
info!("[Settings] Auto-launch unchanged, skipping OS update");
9797
}
9898

99+
// Mark autostart as explicitly configured so first-launch logic won't re-enable it
100+
settings_repo
101+
.set("startup.autostart_configured", "true")
102+
.await
103+
.map_err(|e| format!("Failed to save autostart_configured flag: {}", e))?;
104+
99105
// Update other settings in database
100106
settings_repo
101107
.set(
@@ -127,7 +133,7 @@ mod tests {
127133
#[test]
128134
fn test_startup_settings_default() {
129135
let settings = StartupSettings::default();
130-
assert_eq!(settings.auto_launch, false);
136+
assert_eq!(settings.auto_launch, true);
131137
assert_eq!(settings.start_minimized, true);
132138
assert_eq!(settings.close_to_tray, true);
133139
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,61 @@ pub fn run() {
569569
}
570570
}
571571

572+
// Enable auto-start on first launch if not already configured.
573+
// The OS-level autostart is only set if not previously enabled/disabled by the user.
574+
// This ensures fresh installs get autostart without requiring manual Settings toggle.
575+
{
576+
let autostart_manager: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager> = app.state();
577+
match autostart_manager.is_enabled() {
578+
Ok(false) => {
579+
// Check if user has ever explicitly configured autostart
580+
let app_state: tauri::State<'_, AppState> = app.state();
581+
let was_configured = tauri::async_runtime::block_on(async {
582+
app_state.settings_repository
583+
.get("startup.autostart_configured")
584+
.await
585+
.ok()
586+
.flatten()
587+
.is_some()
588+
});
589+
590+
if !was_configured {
591+
// First launch: enable autostart and mark as configured
592+
if let Err(e) = autostart_manager.enable() {
593+
warn!("[Autostart] Failed to enable on first launch: {}", e);
594+
} else {
595+
info!("[Autostart] Enabled on first launch");
596+
}
597+
tauri::async_runtime::block_on(async {
598+
let _ = app_state.settings_repository
599+
.set("startup.autostart_configured", "true")
600+
.await;
601+
});
602+
}
603+
}
604+
Ok(true) => {
605+
info!("[Autostart] Already enabled");
606+
}
607+
Err(e) => {
608+
warn!("[Autostart] Failed to check status: {}", e);
609+
}
610+
}
611+
}
612+
613+
// Register deep link protocol in OS (Windows registry / Linux xdg-mime)
614+
// NSIS writes to HKCU, MSI writes to HKLM — both register during install.
615+
// This register_all() call is a safety net for dev mode and edge cases
616+
// (e.g. AppImage on Linux, portable installs).
617+
#[cfg(any(windows, target_os = "linux"))]
618+
{
619+
use tauri_plugin_deep_link::DeepLinkExt;
620+
if let Err(e) = app.deep_link().register_all() {
621+
warn!("[DeepLink] Failed to register protocol schemes: {}", e);
622+
} else {
623+
info!("[DeepLink] Protocol schemes registered successfully");
624+
}
625+
}
626+
572627
// Register deep link handler for when app receives URLs
573628
#[cfg(desktop)]
574629
{

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
CardContent,
99
Button,
1010
Switch,
11+
useToast,
12+
ToastContainer,
1113
} from '@mcpmux/ui';
1214
import {
1315
Sun,
@@ -34,6 +36,7 @@ export function SettingsPage() {
3436
const setTheme = useAppStore((state) => state.setTheme);
3537
const [logsPath, setLogsPath] = useState<string>('');
3638
const [openingLogs, setOpeningLogs] = useState(false);
39+
const { toasts, success, error } = useToast();
3740

3841
// Startup settings state
3942
const [startupSettings, setStartupSettings] = useState<StartupSettings>({
@@ -91,8 +94,14 @@ export function SettingsPage() {
9194
console.log('[Settings] Invoking update_startup_settings:', newSettings);
9295
await invoke('update_startup_settings', { settings: newSettings });
9396
console.log('[Settings] Successfully saved:', newSettings);
94-
} catch (error) {
95-
console.error('[Settings] Failed to save:', error);
97+
98+
// Show success toast
99+
success('Settings saved', 'Your preferences have been updated');
100+
} catch (err) {
101+
console.error('[Settings] Failed to save:', err);
102+
// Show error toast
103+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
104+
error('Failed to save settings', errorMessage);
96105
// Revert on error
97106
setStartupSettings(oldSettings);
98107
} finally {
@@ -112,11 +121,13 @@ export function SettingsPage() {
112121
};
113122

114123
return (
115-
<div className="space-y-6">
116-
<div>
117-
<h1 className="text-2xl font-bold">Settings</h1>
118-
<p className="text-[rgb(var(--muted))]">Configure McpMux preferences.</p>
119-
</div>
124+
<>
125+
<ToastContainer toasts={toasts} onClose={(id) => toasts.find(t => t.id === id)?.onClose(id)} />
126+
<div className="space-y-6">
127+
<div>
128+
<h1 className="text-2xl font-bold">Settings</h1>
129+
<p className="text-[rgb(var(--muted))]">Configure McpMux preferences.</p>
130+
</div>
120131

121132
{/* Updates Section */}
122133
<UpdateChecker />
@@ -297,5 +308,6 @@ export function SettingsPage() {
297308
</CardContent>
298309
</Card>
299310
</div>
311+
</>
300312
);
301313
}

crates/mcpmux-mcp/src/transports.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ use std::collections::HashMap;
99
use std::process::Stdio;
1010
use std::sync::Arc;
1111

12+
#[cfg(windows)]
13+
#[allow(unused_imports)] // Trait is used via method call in closure
14+
use std::os::windows::process::CommandExt;
15+
1216
use anyhow::{Context, Result};
1317
use rmcp::{
1418
model::{
@@ -140,6 +144,13 @@ impl McpSession {
140144
.envs(&env)
141145
.stderr(Stdio::null())
142146
.kill_on_drop(true);
147+
148+
// On Windows, prevent console window from appearing
149+
#[cfg(windows)]
150+
{
151+
const CREATE_NO_WINDOW: u32 = 0x08000000;
152+
cmd.creation_flags(CREATE_NO_WINDOW);
153+
}
143154
})
144155
).context(format!(
145156
"Failed to spawn child process. Command not found: {}. Ensure it's installed and in PATH.",

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
},
3939
"dependencies": {
4040
"clsx": "^2.1.1",
41+
"lucide-react": "^0.468.0",
4142
"tailwind-merge": "^3.4.0"
4243
}
4344
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useEffect } from 'react';
2+
import { X, CheckCircle, XCircle, AlertCircle, Info } from 'lucide-react';
3+
import { cn } from '../../lib/cn';
4+
5+
export type ToastType = 'success' | 'error' | 'warning' | 'info';
6+
7+
export interface ToastProps {
8+
id: string;
9+
type: ToastType;
10+
title: string;
11+
message?: string;
12+
duration?: number;
13+
onClose: (id: string) => void;
14+
}
15+
16+
const iconMap = {
17+
success: CheckCircle,
18+
error: XCircle,
19+
warning: AlertCircle,
20+
info: Info,
21+
};
22+
23+
const colorMap = {
24+
success: 'text-green-500',
25+
error: 'text-red-500',
26+
warning: 'text-yellow-500',
27+
info: 'text-blue-500',
28+
};
29+
30+
export function Toast({
31+
id,
32+
type,
33+
title,
34+
message,
35+
duration = 3000,
36+
onClose,
37+
}: ToastProps) {
38+
const Icon = iconMap[type];
39+
40+
useEffect(() => {
41+
if (duration > 0) {
42+
const timer = setTimeout(() => {
43+
onClose(id);
44+
}, duration);
45+
return () => clearTimeout(timer);
46+
}
47+
}, [id, duration, onClose]);
48+
49+
return (
50+
<div
51+
className={cn(
52+
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
53+
'bg-surface border-[rgb(var(--border))]',
54+
'animate-in slide-in-from-right-full duration-300'
55+
)}
56+
role="alert"
57+
data-testid={`toast-${type}`}
58+
>
59+
<Icon className={cn('h-5 w-5 mt-0.5 flex-shrink-0', colorMap[type])} />
60+
<div className="flex-1 min-w-0">
61+
<p className="text-sm font-medium">{title}</p>
62+
{message && (
63+
<p className="text-xs text-[rgb(var(--muted))] mt-1">{message}</p>
64+
)}
65+
</div>
66+
<button
67+
onClick={() => onClose(id)}
68+
className="text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] transition-colors flex-shrink-0"
69+
aria-label="Close notification"
70+
data-testid="toast-close"
71+
>
72+
<X className="h-4 w-4" />
73+
</button>
74+
</div>
75+
);
76+
}
77+
78+
export function ToastContainer({
79+
toasts,
80+
onClose,
81+
}: {
82+
toasts: ToastProps[];
83+
onClose: (id: string) => void;
84+
}) {
85+
return (
86+
<div
87+
className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full"
88+
data-testid="toast-container"
89+
>
90+
{toasts.map((toast) => (
91+
<Toast key={toast.id} {...toast} onClose={onClose} />
92+
))}
93+
</div>
94+
);
95+
}

packages/ui/src/hooks/useToast.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useState, useCallback } from 'react';
2+
import { ToastProps, ToastType } from '../components/common/Toast';
3+
4+
export interface ToastOptions {
5+
title: string;
6+
message?: string;
7+
type?: ToastType;
8+
duration?: number;
9+
}
10+
11+
export function useToast() {
12+
const [toasts, setToasts] = useState<ToastProps[]>([]);
13+
14+
const showToast = useCallback((options: ToastOptions) => {
15+
const id = `toast-${Date.now()}-${Math.random()}`;
16+
const toast: ToastProps = {
17+
id,
18+
type: options.type || 'info',
19+
title: options.title,
20+
message: options.message,
21+
duration: options.duration ?? 3000,
22+
onClose: (toastId: string) => {
23+
setToasts((prev) => prev.filter((t) => t.id !== toastId));
24+
},
25+
};
26+
27+
setToasts((prev) => [...prev, toast]);
28+
return id;
29+
}, []);
30+
31+
const success = useCallback(
32+
(title: string, message?: string, duration?: number) => {
33+
return showToast({ title, message, type: 'success', duration });
34+
},
35+
[showToast]
36+
);
37+
38+
const error = useCallback(
39+
(title: string, message?: string, duration?: number) => {
40+
return showToast({ title, message, type: 'error', duration });
41+
},
42+
[showToast]
43+
);
44+
45+
const warning = useCallback(
46+
(title: string, message?: string, duration?: number) => {
47+
return showToast({ title, message, type: 'warning', duration });
48+
},
49+
[showToast]
50+
);
51+
52+
const info = useCallback(
53+
(title: string, message?: string, duration?: number) => {
54+
return showToast({ title, message, type: 'info', duration });
55+
},
56+
[showToast]
57+
);
58+
59+
const dismiss = useCallback((id: string) => {
60+
setToasts((prev) => prev.filter((t) => t.id !== id));
61+
}, []);
62+
63+
return {
64+
toasts,
65+
showToast,
66+
success,
67+
error,
68+
warning,
69+
info,
70+
dismiss,
71+
};
72+
}

packages/ui/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ export { Button } from './components/common/Button';
1414
export { Input } from './components/common/Input';
1515
export { Card, CardHeader, CardTitle, CardDescription, CardContent } from './components/common/Card';
1616
export { Switch } from './components/common/Switch';
17+
export { Toast, ToastContainer } from './components/common/Toast';
18+
export type { ToastProps, ToastType } from './components/common/Toast';
19+
20+
// Hooks
21+
export { useToast } from './hooks/useToast';
22+
export type { ToastOptions } from './hooks/useToast';
1723

1824
// Utilities
1925
export { cn } from './lib/cn';

0 commit comments

Comments
 (0)