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
11 changes: 6 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ serde_json.workspace = true
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-deep-link = "2"
tauri-plugin-updater = "2"
tauri-plugin-autostart = "2"
Expand Down
16 changes: 11 additions & 5 deletions apps/desktop/src-tauri/src/commands/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,10 @@ fn create_gateway_dependencies(
builder.build().map_err(|e: String| e)
}

/// Get gateway status
/// Get gateway status, optionally scoped to a specific space
#[tauri::command]
pub async fn get_gateway_status(
space_id: Option<String>,
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
server_manager_state: State<'_, Arc<RwLock<ServerManagerState>>>,
) -> Result<GatewayStatus, String> {
Expand All @@ -519,19 +520,24 @@ pub async fn get_gateway_status(
0
};

// Get connected count from ServerManager
// Get connected count from ServerManager, scoped to space if provided
let connected_backends = {
let sm_state = server_manager_state.read().await;
if let Some(ref manager) = sm_state.manager {
manager.connected_count().await
if let Some(ref sid) = space_id {
let uuid = Uuid::parse_str(sid).map_err(|e| e.to_string())?;
manager.connected_count_for_space(&uuid).await
} else {
manager.connected_count().await
}
} else {
0
}
};

info!(
"[Gateway] get_gateway_status: running={}, url={:?}, sessions={}, backends={}",
state.running, state.url, active_sessions, connected_backends
"[Gateway] get_gateway_status: running={}, url={:?}, sessions={}, backends={}, space={:?}",
state.running, state.url, active_sessions, connected_backends, space_id
);

Ok(GatewayStatus {
Expand Down
26 changes: 17 additions & 9 deletions apps/desktop/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ pub struct StartupSettings {
impl Default for StartupSettings {
fn default() -> Self {
Self {
auto_launch: false,
start_minimized: false,
auto_launch: true,
start_minimized: true,
close_to_tray: true, // Default to close-to-tray behavior
}
}
Expand All @@ -44,20 +44,22 @@ pub async fn get_startup_settings(
.is_enabled()
.map_err(|e| format!("Failed to check auto-launch status: {}", e))?;

// Get other settings from database
// Get other settings from database; use defaults when key is missing or DB read fails (e.g. no settings yet)
let start_minimized = settings_repo
.get("startup.start_minimized")
.await
.map_err(|e| format!("Failed to get start_minimized setting: {}", e))?
.ok()
.flatten()
.map(|v| v == "true")
.unwrap_or(false);
.unwrap_or(true);

let close_to_tray = settings_repo
.get("ui.close_to_tray")
.await
.map_err(|e| format!("Failed to get close_to_tray setting: {}", e))?
.ok()
.flatten()
.map(|v| v == "true")
.unwrap_or(true); // Default to true
.unwrap_or(true);

Ok(StartupSettings {
auto_launch,
Expand Down Expand Up @@ -96,6 +98,12 @@ pub async fn update_startup_settings(
info!("[Settings] Auto-launch unchanged, skipping OS update");
}

// Mark autostart as explicitly configured so first-launch logic won't re-enable it
settings_repo
.set("startup.autostart_configured", "true")
.await
.map_err(|e| format!("Failed to save autostart_configured flag: {}", e))?;

// Update other settings in database
settings_repo
.set(
Expand Down Expand Up @@ -127,8 +135,8 @@ mod tests {
#[test]
fn test_startup_settings_default() {
let settings = StartupSettings::default();
assert_eq!(settings.auto_launch, false);
assert_eq!(settings.start_minimized, false);
assert_eq!(settings.auto_launch, true);
assert_eq!(settings.start_minimized, true);
assert_eq!(settings.close_to_tray, true);
}

Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,61 @@ pub fn run() {
}
}

// Enable auto-start on first launch if not already configured.
// The OS-level autostart is only set if not previously enabled/disabled by the user.
// This ensures fresh installs get autostart without requiring manual Settings toggle.
{
let autostart_manager: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager> = app.state();
match autostart_manager.is_enabled() {
Ok(false) => {
// Check if user has ever explicitly configured autostart
let app_state: tauri::State<'_, AppState> = app.state();
let was_configured = tauri::async_runtime::block_on(async {
app_state.settings_repository
.get("startup.autostart_configured")
.await
.ok()
.flatten()
.is_some()
});

if !was_configured {
// First launch: enable autostart and mark as configured
if let Err(e) = autostart_manager.enable() {
warn!("[Autostart] Failed to enable on first launch: {}", e);
} else {
info!("[Autostart] Enabled on first launch");
}
tauri::async_runtime::block_on(async {
let _ = app_state.settings_repository
.set("startup.autostart_configured", "true")
.await;
});
}
}
Ok(true) => {
info!("[Autostart] Already enabled");
}
Err(e) => {
warn!("[Autostart] Failed to check status: {}", e);
}
}
}

// Register deep link protocol in OS (Windows registry / Linux xdg-mime)
// NSIS writes to HKCU, MSI writes to HKLM — both register during install.
// This register_all() call is a safety net for dev mode and edge cases
// (e.g. AppImage on Linux, portable installs).
#[cfg(any(windows, target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
if let Err(e) = app.deep_link().register_all() {
warn!("[DeepLink] Failed to register protocol schemes: {}", e);
} else {
info!("[DeepLink] Protocol schemes registered successfully");
}
}

// Register deep link handler for when app receives URLs
#[cfg(desktop)]
{
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ function DashboardView() {
import('@/lib/api/featureSets').then((m) =>
viewSpace?.id ? m.listFeatureSetsBySpace(viewSpace.id) : m.listFeatureSets()
),
import('@/lib/api/gateway').then((m) => m.getGatewayStatus()),
import('@/lib/api/gateway').then((m) => m.getGatewayStatus(viewSpace?.id)),
import('@/lib/api/registry').then((m) => m.listInstalledServers(viewSpace?.id)),
]);
console.log('[Dashboard] Gateway status received:', gateway);
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/components/ConfigEditorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces';
import { refreshRegistry } from '@/lib/api/registry';
import Editor, { type Monaco } from '@monaco-editor/react';
import type { editor } from 'monaco-editor';
import { useToast, ToastContainer } from '@mcpmux/ui';
import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json';

interface ConfigEditorModalProps {
Expand All @@ -23,6 +24,7 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
const [editorReady, setEditorReady] = useState(false);
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const { toasts, success, error: showError } = useToast();

// Delay editor mount to avoid glitch during modal open
useEffect(() => {
Expand Down Expand Up @@ -61,6 +63,7 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
} catch (e) {
setIsValidJson(false);
setError(`Invalid JSON: ${(e as Error).message}`);
showError('Invalid JSON', (e as Error).message);
return;
}

Expand All @@ -69,10 +72,14 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
await saveSpaceConfig(spaceId, content);
// Refresh server discovery to pick up new/changed servers
await refreshRegistry();

success('Configuration saved', 'Space configuration updated successfully');
onSaved();
onClose();
} catch (e) {
setError(String(e));
const errorMsg = e instanceof Error ? e.message : String(e);
setError(errorMsg);
showError('Failed to save configuration', errorMsg);
} finally {
setIsSaving(false);
}
Expand Down Expand Up @@ -150,7 +157,9 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
}, [handleFormat, onClose]);

return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<>
<ToastContainer toasts={toasts} onClose={(id) => toasts.find(t => t.id === id)?.onClose(id)} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-[rgb(var(--surface))] w-full max-w-4xl h-[80vh] rounded-xl shadow-2xl flex flex-col border border-[rgb(var(--border))]">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[rgb(var(--border))]">
Expand Down Expand Up @@ -256,5 +265,6 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
)}
</div>
</div>
</>
);
}
11 changes: 7 additions & 4 deletions apps/desktop/src/components/ServerLogViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState, useRef } from 'react';
import { X, Download, Trash2, RefreshCw } from 'lucide-react';
import { useToast, ToastContainer } from '@mcpmux/ui';
import { getServerLogs, clearServerLogs, getServerLogFile, type ServerLogEntry } from '@/lib/api/logs';

interface ServerLogViewerProps {
Expand Down Expand Up @@ -38,6 +39,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView
const [autoRefresh, setAutoRefresh] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(true);
const { toasts, success, error: showError, dismiss } = useToast();

const loadLogs = async () => {
try {
Expand Down Expand Up @@ -97,19 +99,19 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView
try {
await clearServerLogs(serverId);
setLogs([]);
success('Logs cleared', `All logs for "${serverName}" have been cleared`);
} catch (e) {
alert(`Failed to clear logs: ${e}`);
showError('Failed to clear logs', e instanceof Error ? e.message : String(e));
}
};

const handleOpenInEditor = async () => {
try {
const filePath = await getServerLogFile(serverId);
// Copy path to clipboard for user to paste into their editor
await navigator.clipboard.writeText(filePath);
alert(`Log file path copied to clipboard:\n${filePath}\n\nPaste this into your file explorer or text editor.`);
success('Path copied', `Log file path copied to clipboard`);
} catch (e) {
alert(`Failed to get log file path: ${e}`);
showError('Failed to get log file path', e instanceof Error ? e.message : String(e));
}
};

Expand All @@ -131,6 +133,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<ToastContainer toasts={toasts} onClose={dismiss} />
<div className="bg-[rgb(var(--card))] border border-[rgb(var(--border-subtle))] rounded-xl shadow-xl w-[90vw] h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[rgb(var(--border-subtle))]">
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/components/SpaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
Plus,
Loader2,
} from 'lucide-react';
import { Button } from '@mcpmux/ui';
import { Button, useToast, ToastContainer } from '@mcpmux/ui';
import {
useAppStore,
useActiveSpace,
Expand All @@ -25,6 +25,7 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) {
const [newName, setNewName] = useState('');
const [showCreateInput, setShowCreateInput] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { toasts, success, error: showError, dismiss } = useToast();

const spaces = useSpaces();
const activeSpace = useActiveSpace();
Expand Down Expand Up @@ -56,8 +57,10 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) {
await setActiveSpaceAPI(spaceId);
setActiveSpaceInStore(spaceId);
setIsOpen(false);
const activatedSpace = spaces.find(s => s.id === spaceId);
success('Space activated', `Switched to "${activatedSpace?.name || 'Space'}"`);
} catch (e) {
console.error('Failed to switch space:', e);
showError('Failed to switch space', e instanceof Error ? e.message : String(e));
}
};

Expand All @@ -73,15 +76,17 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) {
setNewName('');
setShowCreateInput(false);
setIsOpen(false);
success('Space created', `"${space.name}" has been created and activated`);
} catch (e) {
console.error('Failed to create space:', e);
showError('Failed to create space', e instanceof Error ? e.message : String(e));
} finally {
setIsCreating(false);
}
};

return (
<div ref={dropdownRef} className={`relative ${className}`}>
<ToastContainer toasts={toasts} onClose={dismiss} />
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
Expand Down
Loading