From 948078a586ec7d84262caf00a80a2e830939185c Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Fri, 6 Feb 2026 07:53:20 +0800 Subject: [PATCH] feat: implement Tauri updater functionality - Add UpdateChecker component with full UI/UX for checking and installing updates - Add SettingsPage integrating updater, theme, and logs functionality - Initialize tauri-plugin-updater in Rust backend (lib.rs) - Add auto-check for updates on app startup (5 second delay) - Install @tauri-apps/plugin-updater and plugin-process dependencies - Enhance E2E tests with 11 new test cases for updater functionality - Add Tauri plugin mocks to test setup Fixes runtime 'plugin updater not found' error by registering plugin in Builder chain --- .vscode/settings.json | 22 +- Cargo.lock | 10 +- apps/desktop/package.json | 2 + apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src/App.tsx | 146 ++------- .../src/features/settings/SettingsPage.tsx | 147 +++++++++ .../src/features/settings/UpdateChecker.tsx | 291 ++++++++++++++++++ apps/desktop/src/features/settings/index.ts | 2 + pnpm-lock.yaml | 20 ++ tests/e2e/specs/settings.spec.ts | 159 ++++++++++ tests/ts/setup.ts | 11 + 11 files changed, 670 insertions(+), 141 deletions(-) create mode 100644 apps/desktop/src/features/settings/SettingsPage.tsx create mode 100644 apps/desktop/src/features/settings/UpdateChecker.tsx create mode 100644 apps/desktop/src/features/settings/index.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 9733074a..6eca534f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,13 +1,13 @@ { - "[rust]": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "rust-lang.rust-analyzer" - }, - "rust-analyzer.rustfmt.extraArgs": [ - "+nightly" - ], - "files.eol": "\n", - "files.insertFinalNewline": true, - "files.trimTrailingWhitespace": true, - "editor.formatOnSave": true + "[rust]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "rust-lang.rust-analyzer" + }, + "rust-analyzer.rustfmt.extraArgs": [ + "+nightly" + ], + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + "editor.formatOnSave": true } diff --git a/Cargo.lock b/Cargo.lock index ccdcb8ac..1080684a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2498,7 +2498,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.0.3" +version = "0.0.5" dependencies = [ "anyhow", "async-trait", @@ -2533,7 +2533,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.0.3" +version = "0.0.5" dependencies = [ "anyhow", "async-trait", @@ -2555,7 +2555,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.0.3" +version = "0.0.5" dependencies = [ "anyhow", "async-stream", @@ -2595,7 +2595,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.0.3" +version = "0.0.5" dependencies = [ "anyhow", "async-trait", @@ -2614,7 +2614,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.0.3" +version = "0.0.5" dependencies = [ "anyhow", "async-trait", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f8f88584..086ea6f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,6 +19,8 @@ "@monaco-editor/react": "^4.7.0", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-updater": "^2", "immer": "^11.0.1", "lucide-react": "^0.561.0", "react": "^19.1.0", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 49d39034..ccd43237 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -189,6 +189,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { // This callback is called when a second instance is launched info!("Second instance detected, focusing existing window"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 080363e4..0f5aeadd 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -36,6 +36,7 @@ import { FeatureSetsPage } from '@/features/featuresets'; import { ClientsPage } from '@/features/clients'; import { ServersPage } from '@/features/servers'; import { SpacesPage } from '@/features/spaces'; +import { SettingsPage } from '@/features/settings'; import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents'; type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings'; @@ -46,6 +47,25 @@ function AppContent() { const [activeNav, setActiveNav] = useState('home'); + // Auto-check for updates on startup (silent check after 5 seconds) + useEffect(() => { + const checkForUpdates = async () => { + try { + const { check } = await import('@tauri-apps/plugin-updater'); + const update = await check(); + if (update) { + console.log(`[Auto-Update] Update available: ${update.version}`); + // User can check Settings page to see the update + } + } catch (error) { + console.error('[Auto-Update] Failed to check for updates:', error); + } + }; + + const timer = setTimeout(checkForUpdates, 5000); + return () => clearTimeout(timer); + }, []); + // Get state from store const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); @@ -171,7 +191,7 @@ function AppContent() { {activeNav === 'spaces' && } {activeNav === 'featuresets' && } {activeNav === 'clients' && } - {activeNav === 'settings' && } + {activeNav === 'settings' && } ); @@ -425,128 +445,4 @@ function DashboardView() { ); } -function SettingsView() { - const theme = useTheme(); - const setTheme = useAppStore((state) => state.setTheme); - const [logsPath, setLogsPath] = useState(''); - const [openingLogs, setOpeningLogs] = useState(false); - - // Load logs path on mount - useEffect(() => { - const loadLogsPath = async () => { - try { - const { invoke } = await import('@tauri-apps/api/core'); - const path = await invoke('get_logs_path'); - setLogsPath(path); - } catch (error) { - console.error('Failed to get logs path:', error); - } - }; - loadLogsPath(); - }, []); - - const handleOpenLogs = async () => { - setOpeningLogs(true); - try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('open_logs_folder'); - } catch (error) { - console.error('Failed to open logs folder:', error); - } finally { - setOpeningLogs(false); - } - }; - - return ( -
-
-

Settings

-

Configure McpMux preferences.

-
- - - - Appearance - Customize the look and feel of McpMux. - - -
-
- -
- - - -
-
-
-
-
- - - - - - Logs - - View application logs for debugging and troubleshooting. - - -
-
- -

- {logsPath || 'Loading...'} -

-
-
- -
-

- Logs are rotated daily. Each file contains detailed debug information including thread IDs and source locations. -

-
-
-
-
- ); -} - export default App; diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx new file mode 100644 index 00000000..45e8b6b5 --- /dev/null +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, +} from '@mcpmux/ui'; +import { + Sun, + Moon, + Monitor, + FileText, + FolderOpen, + Loader2, +} from 'lucide-react'; +import { useAppStore, useTheme } from '@/stores'; +import { UpdateChecker } from './UpdateChecker'; + +export function SettingsPage() { + const theme = useTheme(); + const setTheme = useAppStore((state) => state.setTheme); + const [logsPath, setLogsPath] = useState(''); + const [openingLogs, setOpeningLogs] = useState(false); + + // Load logs path on mount + useEffect(() => { + const loadLogsPath = async () => { + try { + const path = await invoke('get_logs_path'); + setLogsPath(path); + } catch (error) { + console.error('Failed to get logs path:', error); + } + }; + loadLogsPath(); + }, []); + + const handleOpenLogs = async () => { + setOpeningLogs(true); + try { + await invoke('open_logs_folder'); + } catch (error) { + console.error('Failed to open logs folder:', error); + } finally { + setOpeningLogs(false); + } + }; + + return ( +
+
+

Settings

+

Configure McpMux preferences.

+
+ + {/* Updates Section */} + + + {/* Appearance Section */} + + + Appearance + Customize the look and feel of McpMux. + + +
+
+ +
+ + + +
+
+
+
+
+ + {/* Logs Section */} + + + + + Logs + + View application logs for debugging and troubleshooting. + + +
+
+ +

+ {logsPath || 'Loading...'} +

+
+
+ +
+

+ Logs are rotated daily. Each file contains detailed debug information including thread IDs and source locations. +

+
+
+
+
+ ); +} diff --git a/apps/desktop/src/features/settings/UpdateChecker.tsx b/apps/desktop/src/features/settings/UpdateChecker.tsx new file mode 100644 index 00000000..6b9f2b94 --- /dev/null +++ b/apps/desktop/src/features/settings/UpdateChecker.tsx @@ -0,0 +1,291 @@ +import { useState } from 'react'; +import { check, Update } from '@tauri-apps/plugin-updater'; +import { relaunch } from '@tauri-apps/plugin-process'; +import { + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@mcpmux/ui'; +import { Download, Loader2, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; + +interface DownloadEvent { + event: 'Started' | 'Progress' | 'Finished'; + data?: { + contentLength?: number; + chunkLength?: number; + }; +} + +export function UpdateChecker() { + const [checking, setChecking] = useState(false); + const [downloading, setDownloading] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); + const [downloadProgress, setDownloadProgress] = useState({ downloaded: 0, total: 0 }); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [currentVersion, setCurrentVersion] = useState(''); + + // Load current version on mount + useState(() => { + invoke('get_version') + .then(setCurrentVersion) + .catch((err) => console.error('Failed to get version:', err)); + }); + + const checkForUpdates = async () => { + setChecking(true); + setMessage(null); + setUpdateInfo(null); + + try { + console.log('[Updater] Checking for updates...'); + const update = await check(); + + if (update) { + console.log( + `[Updater] Update available: ${update.version} from ${update.date || 'N/A'}` + ); + setUpdateInfo(update); + setMessage({ + type: 'success', + text: `Version ${update.version} is available!`, + }); + } else { + console.log('[Updater] No updates available'); + setMessage({ + type: 'success', + text: "You're running the latest version!", + }); + } + } catch (error) { + console.error('[Updater] Check failed:', error); + setMessage({ + type: 'error', + text: `Failed to check for updates: ${error}`, + }); + } finally { + setChecking(false); + } + }; + + const installUpdate = async () => { + if (!updateInfo) return; + + setDownloading(true); + setDownloadProgress({ downloaded: 0, total: 0 }); + setMessage(null); + + try { + console.log('[Updater] Starting download and install...'); + + await updateInfo.downloadAndInstall((event: DownloadEvent) => { + switch (event.event) { + case 'Started': + console.log(`[Updater] Downloading ${event.data?.contentLength || 0} bytes`); + setDownloadProgress({ + downloaded: 0, + total: event.data?.contentLength || 0, + }); + break; + case 'Progress': + setDownloadProgress((prev) => ({ + ...prev, + downloaded: prev.downloaded + (event.data?.chunkLength || 0), + })); + break; + case 'Finished': + console.log('[Updater] Download finished, installing...'); + break; + } + }); + + console.log('[Updater] Update installed successfully, relaunching app...'); + // Note: On Windows, the app will exit automatically before this point + await relaunch(); + } catch (error) { + console.error('[Updater] Installation failed:', error); + setMessage({ + type: 'error', + text: `Failed to install update: ${error}`, + }); + setDownloading(false); + } + }; + + const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`; + }; + + const progressPercent = + downloadProgress.total > 0 + ? Math.round((downloadProgress.downloaded / downloadProgress.total) * 100) + : 0; + + return ( + + + + + Software Updates + + + Keep your application up to date with the latest features and fixes. + + + +
+ {/* Current Version */} +
+ +

+ v{currentVersion || '0.0.5'} +

+
+ + {/* Check Button */} + {!updateInfo && ( + + )} + + {/* Status Message */} + {message && !updateInfo && ( +
+ {message.type === 'success' ? ( + + ) : ( + + )} + {message.text} +
+ )} + + {/* Update Available Card */} + {updateInfo && ( +
+
+

+ Update Available: v{updateInfo.version} +

+ {updateInfo.date && ( +

+ Released: {new Date(updateInfo.date).toLocaleDateString()} +

+ )} +
+ + {/* Release Notes */} + {updateInfo.body && ( +
+

What's New:

+
+ {updateInfo.body} +
+
+ )} + + {/* Download Progress */} + {downloading && downloadProgress.total > 0 && ( +
+
+ Downloading... + + {formatBytes(downloadProgress.downloaded)} / {formatBytes(downloadProgress.total)} ({progressPercent}%) + +
+
+
+
+
+ )} + + {/* Install Button */} +
+ + {!downloading && ( + + )} +
+ + {downloading && ( +

+ Note: On Windows, the app will close automatically to install the update. +

+ )} +
+ )} + + {/* Error Message for Update Available State */} + {message && updateInfo && message.type === 'error' && ( +
+ + {message.text} +
+ )} +
+ + + ); +} diff --git a/apps/desktop/src/features/settings/index.ts b/apps/desktop/src/features/settings/index.ts new file mode 100644 index 00000000..ef119a39 --- /dev/null +++ b/apps/desktop/src/features/settings/index.ts @@ -0,0 +1,2 @@ +export { SettingsPage } from './SettingsPage'; +export { UpdateChecker } from './UpdateChecker'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f29dd2a..d7e907ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,12 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.2 + '@tauri-apps/plugin-process': + specifier: ^2 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2 + version: 2.10.0 immer: specifier: ^11.0.1 version: 11.0.1 @@ -1256,6 +1262,12 @@ packages: '@tauri-apps/plugin-opener@2.5.2': resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==} + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.0': + resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -5157,6 +5169,14 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.10.1 + + '@tauri-apps/plugin-updater@2.10.0': + dependencies: + '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/tests/e2e/specs/settings.spec.ts b/tests/e2e/specs/settings.spec.ts index 958aa24a..c4afef82 100644 --- a/tests/e2e/specs/settings.spec.ts +++ b/tests/e2e/specs/settings.spec.ts @@ -49,4 +49,163 @@ test.describe('Settings', () => { await page.waitForTimeout(300); await expect(page.locator('html')).toHaveClass(/dark/); }); + + test.describe('Software Updates', () => { + test('should display update checker section', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + // Check for update checker card + await expect(page.getByTestId('update-checker')).toBeVisible(); + await expect(page.getByText('Software Updates')).toBeVisible(); + await expect(page.getByText(/Keep your application up to date/)).toBeVisible(); + }); + + test('should display current version', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + // Check current version is displayed + await expect(page.getByTestId('current-version')).toBeVisible(); + await expect(page.getByTestId('current-version')).toContainText('v'); + }); + + test('should have check for updates button', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const checkButton = page.getByTestId('check-updates-btn'); + await expect(checkButton).toBeVisible(); + await expect(checkButton).toHaveText(/Check for Updates/); + await expect(checkButton).toBeEnabled(); + }); + + test('should show loading state when checking for updates', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const checkButton = page.getByTestId('check-updates-btn'); + await checkButton.click(); + + // Button should show loading state briefly + await expect(checkButton).toContainText(/Checking/); + await expect(checkButton).toBeDisabled(); + }); + + test('should display update status message', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const checkButton = page.getByTestId('check-updates-btn'); + await checkButton.click(); + + // Wait for check to complete (should show either update available or up to date) + await page.waitForSelector('[data-testid="update-message"], [data-testid="update-available"]', { + timeout: 10000, + }); + + // Verify one of the expected states is shown + const hasMessage = await page.getByTestId('update-message').isVisible().catch(() => false); + const hasUpdate = await page.getByTestId('update-available').isVisible().catch(() => false); + + expect(hasMessage || hasUpdate).toBeTruthy(); + }); + + test('should allow multiple update checks', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const checkButton = page.getByTestId('check-updates-btn'); + + // First check + await checkButton.click(); + await page.waitForSelector('[data-testid="update-message"], [data-testid="update-available"]', { + timeout: 10000, + }); + + // Check button should be available again + await expect(checkButton).toBeEnabled(); + + // Second check + await checkButton.click(); + await expect(checkButton).toContainText(/Checking/); + }); + }); + + test.describe('Logs Section', () => { + test('should display logs path', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const logsPath = page.getByTestId('logs-path'); + await expect(logsPath).toBeVisible(); + // Should not show "Loading..." after page loads + await expect(logsPath).not.toContainText('Loading...'); + }); + + test('should have open logs folder button', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const openButton = page.getByTestId('open-logs-btn'); + await expect(openButton).toBeVisible(); + await expect(openButton).toContainText('Open Logs Folder'); + }); + + test('should show description text', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + await expect(page.getByText(/Logs are rotated daily/i)).toBeVisible(); + }); + }); + + test.describe('Page Layout', () => { + test('should display all sections in order', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + // Verify sections appear in expected order + const sections = [ + page.getByText('Software Updates'), + page.getByText('Appearance'), + page.locator('h3:has-text("Logs"), h2:has-text("Logs")').first(), + ]; + + for (const section of sections) { + await expect(section).toBeVisible(); + } + }); + + test('should be scrollable if content overflows', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + // Content should be within a scrollable container + const mainContent = page.locator('[class*="space-y-6"]').first(); + await expect(mainContent).toBeVisible(); + }); + }); }); diff --git a/tests/ts/setup.ts b/tests/ts/setup.ts index 56eda384..02b6e5d1 100644 --- a/tests/ts/setup.ts +++ b/tests/ts/setup.ts @@ -18,6 +18,17 @@ vi.mock('@tauri-apps/plugin-opener', () => ({ open: vi.fn(), })); +// Mock Tauri updater plugin +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: vi.fn(), + Update: vi.fn(), +})); + +// Mock Tauri process plugin +vi.mock('@tauri-apps/plugin-process', () => ({ + relaunch: vi.fn(), +})); + // Mock window.matchMedia for responsive components Object.defineProperty(window, 'matchMedia', { writable: true,