diff --git a/.gitignore b/.gitignore index 3af20a931..8f354d83c 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,6 @@ bin/ /blob-report/ /playwright/.cache/ /test-videos/ + + +.vscode/ \ No newline at end of file diff --git a/docs/deep-linking.md b/docs/deep-linking.md new file mode 100644 index 000000000..0af2c5024 --- /dev/null +++ b/docs/deep-linking.md @@ -0,0 +1,217 @@ +# Deep Linking in ToolHive Studio + +ToolHive Studio supports deep linking to provide a seamless user experience when installing MCP servers. This feature allows external tools, documentation, and error messages to direct users directly to the server installation page in the UI. + +## Overview + +The deep linking system consists of: + +1. **Custom URL Scheme**: `toolhive://` protocol for OS-level integration +2. **HTTP Control Endpoint**: Fallback for when the UI is already running +3. **CLI Command Generation**: Alternative for command-line users + +## URL Scheme Format + +``` +toolhive://action?parameter=value +``` + +### Supported Actions + +#### `install-server` + +Navigate to the server installation page in the registry. + +**Parameters:** + +- `server` (required): Server name to install +- `registry` (optional): Registry name (defaults to 'official') + +**Example:** + +``` +toolhive://install-server?server=github®istry=official +``` + +## HTTP Control Endpoint + +When ToolHive Studio is running, it starts an HTTP control server on `http://127.0.0.1:51234` for programmatic navigation. + +### Endpoints + +#### `GET /health` + +Check if the control server is running. + +**Response:** + +```json +{ + "status": "ok", + "service": "toolhive-studio-control", + "timestamp": "2024-01-01T00:00:00.000Z" +} +``` + +#### `POST /navigate` + +Navigate to a deep link URL. + +**Request Body:** + +```json +{ + "url": "toolhive://install-server?server=github-mcp-server" +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Navigation handled" +} +``` + +#### `GET /navigate?url=` + +Alternative GET endpoint for navigation. + +**Example:** + +``` +GET /navigate?url=toolhive%3A//install-server%3Fserver%3Dgithub-mcp-server +``` + +## Usage Examples + +### From Documentation + +Include deep links in your MCP server documentation: + +````markdown +## Quick Install + +Click here to install in ToolHive Studio: +[Install GitHub MCP Server](toolhive://install-server?server=github-mcp-server®istry=official) + +Or run this command: + +```bash +thv run --registry official github-mcp-server +``` +```` + +### From Error Messages + +When a required secret is missing, generate a deep link: + +```typescript +const deepLink = `toolhive://install-server?server=${serverName}&secret_API_KEY=` +console.error(`Missing API_KEY. Configure it here: ${deepLink}`) +``` + +### From External Tools + +Use the HTTP control endpoint to navigate programmatically: + +```bash +# Check if ToolHive Studio is running +curl -s http://127.0.0.1:51234/health + +# Navigate to install page +curl -X POST http://127.0.0.1:51234/navigate \ + -H "Content-Type: application/json" \ + -d '{"url": "toolhive://install-server?server=my-server"}' +``` + +### From JavaScript/Node.js + +```javascript +async function openInToolHive(serverName, environment = {}, secrets = {}) { + const url = new URL('toolhive://install-server') + url.searchParams.set('server', serverName) + + // Add environment variables + for (const [key, value] of Object.entries(environment)) { + url.searchParams.set(`env_${key}`, value) + } + + // Add secrets + for (const [key, value] of Object.entries(secrets)) { + url.searchParams.set(`secret_${key}`, value) + } + + // Try HTTP control endpoint first (if UI is running) + try { + const response = await fetch('http://127.0.0.1:51234/navigate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: url.toString() }), + }) + + if (response.ok) { + console.log('Navigated via control endpoint') + return + } + } catch (error) { + // Control endpoint not available, fall back to OS + } + + // Fallback to OS deep link (will launch app if not running) + window.open(url.toString(), '_blank') +} +``` + +## Implementation Details + +### OS Integration + +The deep linking system registers the `toolhive://` protocol with the operating system: + +- **macOS**: Uses `app.setAsDefaultProtocolClient()` and handles `open-url` events +- **Windows**: Registers protocol in registry, handles via `second-instance` event +- **Linux**: Uses desktop file registration, handles via command line arguments + +### Security Considerations + +1. **Local Only**: The HTTP control endpoint only binds to `127.0.0.1` (localhost) +2. **URL Validation**: All URLs must start with `toolhive://` +3. **Parameter Sanitization**: All parameters are validated and sanitized +4. **No Sensitive Data**: Secrets in URLs are for convenience only; users should set them securely + +### Error Handling + +- Invalid URLs are logged and ignored +- Navigation failures fall back to the default page +- Network errors in the control endpoint are handled gracefully +- Missing required parameters show appropriate error messages + +## Best Practices + +1. **Always provide CLI alternatives** for users who prefer command-line tools +2. **Use environment variables** for non-sensitive configuration +3. **Prompt for secrets** rather than including them in URLs when possible +4. **Test deep links** on all supported platforms +5. **Provide fallback instructions** if deep linking fails + +## Testing + +You can test deep links using: + +```bash +# macOS +open "toolhive://install-server?server=test-server" + +# Windows +start "toolhive://install-server?server=test-server" + +# Linux +xdg-open "toolhive://install-server?server=test-server" + +# HTTP Control Endpoint +curl -X POST http://127.0.0.1:51234/navigate \ + -H "Content-Type: application/json" \ + -d '{"url": "toolhive://install-server?server=test-server"}' +``` diff --git a/knip.ts b/knip.ts index 0e95b6c88..f5fce7b87 100644 --- a/knip.ts +++ b/knip.ts @@ -30,6 +30,10 @@ export default { 'renderer/src/types/global.d.ts', 'main/src/vite-env.d.ts', ], + ignoreExportsUsedInFile: { + interface: true, + type: true, + }, ignoreDependencies: [ '@electron-forge/maker-dmg', // Used indirectly in MakerDMGWithArch '@electron-forge/publisher-github', diff --git a/main/src/control-server.ts b/main/src/control-server.ts new file mode 100644 index 000000000..4539fb1c3 --- /dev/null +++ b/main/src/control-server.ts @@ -0,0 +1,231 @@ +import { + createServer, + Server, + IncomingMessage, + ServerResponse, + get, +} from 'node:http' +import { URL } from 'node:url' +import log from './logger' +import { handleDeepLink } from './deep-link' + +const CONTROL_PORT = 51234 // Fixed port for control endpoint +const CONTROL_HOST = '127.0.0.1' + +let controlServer: Server | null = null + +/** + * Start the HTTP control server for deep link navigation + */ +export function startControlServer(): Promise { + return new Promise((resolve, reject) => { + if (controlServer) { + log.info('Control server already running') + resolve() + return + } + + controlServer = createServer((req, res) => { + handleControlRequest(req, res) + }) + + controlServer.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + log.warn(`Control server port ${CONTROL_PORT} already in use`) + // This is fine - another instance might be running + resolve() + } else { + log.error('Control server error:', error) + reject(error) + } + }) + + controlServer.listen(CONTROL_PORT, CONTROL_HOST, () => { + log.info( + `Control server listening on http://${CONTROL_HOST}:${CONTROL_PORT}` + ) + resolve() + }) + }) +} + +/** + * Stop the HTTP control server + */ +export function stopControlServer(): Promise { + return new Promise((resolve) => { + if (!controlServer) { + resolve() + return + } + + controlServer.close(() => { + log.info('Control server stopped') + controlServer = null + resolve() + }) + }) +} + +/** + * Handle incoming control requests + */ +function handleControlRequest(req: IncomingMessage, res: ServerResponse): void { + // Set CORS headers for local requests + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + + // Handle preflight requests + if (req.method === 'OPTIONS') { + res.writeHead(200) + res.end() + return + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`) + + try { + if (req.method === 'GET' && url.pathname === '/health') { + handleHealthCheck(res) + } else if (req.method === 'POST' && url.pathname === '/navigate') { + handleNavigateRequest(req, res) + } else if (req.method === 'GET' && url.pathname === '/navigate') { + handleNavigateGet(url, res) + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found' })) + } + } catch (error) { + log.error('Error handling control request:', error) + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Internal server error' })) + } +} + +/** + * Handle health check requests + */ +function handleHealthCheck(res: ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + status: 'ok', + service: 'toolhive-studio-control', + timestamp: new Date().toISOString(), + }) + ) +} + +/** + * Handle POST /navigate requests with JSON body + */ +function handleNavigateRequest( + req: IncomingMessage, + res: ServerResponse +): void { + let body = '' + + req.on('data', (chunk) => { + body += chunk.toString() + }) + + req.on('end', () => { + try { + const data = JSON.parse(body) + + if (!data.url || typeof data.url !== 'string') { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Missing or invalid url parameter' })) + return + } + + // Validate that it's a toolhive:// URL + if (!data.url.startsWith('toolhive://')) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'URL must start with toolhive://' })) + return + } + + // Handle the deep link + handleDeepLink(data.url) + .then(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ success: true, message: 'Navigation handled' }) + ) + }) + .catch((err) => { + log.error('Failed to handle deep link from control server:', err) + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Failed to handle navigation' })) + }) + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Invalid JSON body' })) + } + }) +} + +/** + * Handle GET /navigate requests with URL parameter + */ +function handleNavigateGet(url: URL, res: ServerResponse): void { + const deepLinkUrl = url.searchParams.get('url') + + if (!deepLinkUrl) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Missing url parameter' })) + return + } + + if (!deepLinkUrl.startsWith('toolhive://')) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'URL must start with toolhive://' })) + return + } + + // Handle the deep link + handleDeepLink(deepLinkUrl) + .then(() => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, message: 'Navigation handled' })) + }) + .catch((err) => { + log.error('Failed to handle deep link from control server:', err) + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Failed to handle navigation' })) + }) +} + +/** + * Get the control server URL + * @public + */ +export function getControlServerUrl(): string { + return `http://${CONTROL_HOST}:${CONTROL_PORT}` +} + +/** + * Check if the control server is running (for external tools) + * @public + */ +export async function isControlServerRunning(): Promise { + return new Promise((resolve) => { + const testReq = get( + `${getControlServerUrl()}/health`, + (res: IncomingMessage) => { + resolve(res.statusCode === 200) + } + ) + + testReq.on('error', () => { + resolve(false) + }) + + testReq.setTimeout(1000, () => { + testReq.destroy() + resolve(false) + }) + }) +} diff --git a/main/src/deep-link.ts b/main/src/deep-link.ts new file mode 100644 index 000000000..81db24ceb --- /dev/null +++ b/main/src/deep-link.ts @@ -0,0 +1,134 @@ +import { app } from 'electron' +import path from 'node:path' +import log from './logger' +import { + showMainWindow, + getMainWindow, + sendToMainWindowRenderer, +} from './main-window' + +export interface DeepLinkData { + action: string + serverName?: string + registryName?: string +} + +/** + * Register the custom protocol for deep linking + */ +export function registerProtocol(): void { + // Set as default protocol client for toolhive:// URLs + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient('toolhive', process.execPath, [ + path.resolve(process.argv[1]!), + ]) + } + } else { + app.setAsDefaultProtocolClient('toolhive') + } + + log.info('Registered toolhive:// protocol handler') +} + +/** + * Parse a toolhive:// URL into structured data + */ +export function parseDeepLink(url: string): DeepLinkData | null { + try { + const parsedUrl = new URL(url) + + if (parsedUrl.protocol !== 'toolhive:') { + log.warn(`Invalid protocol for deep link: ${parsedUrl.protocol}`) + return null + } + + const action = parsedUrl.hostname || parsedUrl.pathname.replace(/^\/+/, '') + const searchParams = parsedUrl.searchParams + + const data: DeepLinkData = { action } + + // Extract common parameters + if (searchParams.has('server')) { + data.serverName = searchParams.get('server')! + } + + if (searchParams.has('registry')) { + data.registryName = searchParams.get('registry')! + } + + log.info(`Parsed deep link: ${JSON.stringify(data)}`) + return data + } catch (error) { + log.error(`Failed to parse deep link URL: ${url}`, error) + return null + } +} + +/** + * Handle a deep link by navigating to the appropriate page + */ +export async function handleDeepLink(url: string): Promise { + log.info(`Handling deep link: ${url}`) + + const linkData = parseDeepLink(url) + if (!linkData) { + log.error(`Failed to parse deep link: ${url}`) + return + } + + try { + // Ensure the main window is visible + await showMainWindow() + + // Wait a bit for the window to be ready + await new Promise((resolve) => setTimeout(resolve, 500)) + + // Send the deep link data to the renderer process + const mainWindow = getMainWindow() + if (mainWindow && mainWindow.webContents) { + sendToMainWindowRenderer('deep-link-navigate', linkData) + log.info(`Sent deep link data to renderer: ${JSON.stringify(linkData)}`) + } else { + log.error('Main window not available for deep link navigation') + } + } catch (error) { + log.error('Failed to handle deep link:', error) + } +} + +/** + * Generate a deep link URL for installing a server + */ +export function generateInstallServerLink( + serverName: string, + registryName?: string +): string { + const url = new URL('toolhive://install-server') + + url.searchParams.set('server', serverName) + + if (registryName) { + url.searchParams.set('registry', registryName) + } + + return url.toString() +} + +/** + * Generate CLI command for users who prefer command line + */ +export function generateCliCommand( + serverName: string, + registryName?: string +): string { + let command = `thv run` + + if (registryName) { + command += ` --registry ${registryName}` + } + + command += ` ${serverName}` + + return command +} diff --git a/main/src/main-window.ts b/main/src/main-window.ts index 27a6b13a9..422290093 100644 --- a/main/src/main-window.ts +++ b/main/src/main-window.ts @@ -178,7 +178,7 @@ export async function createMainWindow( await loadWindowContent(mainWindow) // Setup developer tools in development - if (isDevelopment && import.meta.env.VITE_ENABLE_AUTO_DEVTOOLS === 'true') { + if (isDevelopment && process.env.VITE_ENABLE_AUTO_DEVTOOLS === 'true') { mainWindow.webContents.openDevTools() } diff --git a/main/src/main.ts b/main/src/main.ts index 3d3b16acc..a6f81ad60 100644 --- a/main/src/main.ts +++ b/main/src/main.ts @@ -53,6 +53,13 @@ import { resetUpdateState, checkForUpdates, } from './auto-update' +import { + handleDeepLink, + registerProtocol, + generateInstallServerLink, + generateCliCommand, +} from './deep-link' +import { startControlServer, stopControlServer } from './control-server' import Store from 'electron-store' import { getHeaders } from './headers' import { @@ -185,6 +192,13 @@ export async function blockQuit(source: string, event?: Electron.Event) { // Stop the embedded ToolHive server stopToolhive() + // Stop control server + try { + await stopControlServer() + } catch (error) { + log.error('Failed to stop control server during cleanup:', error) + } + safeTrayDestroy(getTray()) app.quit() } @@ -212,6 +226,9 @@ if (started) { app.quit() } +// Register custom protocol for deep linking +registerProtocol() + // ──────────────────────────────────────────────────────────────────────────── // Main-window management is now handled by MainWindowManager // ──────────────────────────────────────────────────────────────────────────── @@ -235,9 +252,24 @@ app.whenReady().then(async () => { () => createMainWindow() ) + // Handle deep links on app launch (macOS/Linux) + const argv = process.argv + const deepLinkUrl = argv.find((arg) => arg.startsWith('toolhive://')) + if (deepLinkUrl) { + // Delay handling to ensure app is fully ready + setTimeout(() => handleDeepLink(deepLinkUrl), 1000) + } + // Start ToolHive with tray reference await startToolhive(getTray() || undefined) + // Start control server for deep link navigation + try { + await startControlServer() + } catch (error) { + log.error('Failed to start control server:', error) + } + // Create main window try { const mainWindow = await createMainWindow() @@ -310,6 +342,28 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) +// Handle deep links on Windows/Linux (second instance) +app.on('second-instance', (_event, commandLine) => { + // Someone tried to run a second instance, focus our window instead + const mainWindow = getMainWindow() + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + + // Handle deep link from command line + const deepLinkUrl = commandLine.find((arg) => arg.startsWith('toolhive://')) + if (deepLinkUrl) { + handleDeepLink(deepLinkUrl) + } +}) + +// Handle deep links on macOS +app.on('open-url', (event, url) => { + event.preventDefault() + handleDeepLink(url) +}) + app.on('activate', async () => { try { if (BrowserWindow.getAllWindows().length === 0) { @@ -357,6 +411,11 @@ app.on('will-quit', (e) => blockQuit('will-quit', e)) } } finally { stopToolhive() + try { + await stopControlServer() + } catch (error) { + log.error('Failed to stop control server during signal cleanup:', error) + } safeTrayDestroy(getTray()) process.exit(0) } @@ -776,3 +835,18 @@ ipcMain.handle( ipcMain.handle('utils:get-workload-available-tools', async (_, workload) => getWorkloadAvailableTools(workload) ) + +// Deep link handlers +ipcMain.handle( + 'deep-link:generate-install-link', + (_, serverName: string, registryName?: string) => { + return generateInstallServerLink(serverName, registryName) + } +) + +ipcMain.handle( + 'deep-link:generate-cli-command', + (_, serverName: string, registryName?: string) => { + return generateCliCommand(serverName, registryName) + } +) diff --git a/main/src/tests/deep-link.test.ts b/main/src/tests/deep-link.test.ts new file mode 100644 index 000000000..4d5f3f93b --- /dev/null +++ b/main/src/tests/deep-link.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest' +import { + parseDeepLink, + generateInstallServerLink, + generateCliCommand, +} from '../deep-link' + +describe('Deep Link Parser', () => { + it('should parse basic install-server URL', () => { + const url = 'toolhive://install-server?server=github-mcp-server' + const result = parseDeepLink(url) + + expect(result).toEqual({ + action: 'install-server', + serverName: 'github-mcp-server', + }) + }) + + it('should parse URL with registry', () => { + const url = + 'toolhive://install-server?server=github-mcp-server®istry=official' + const result = parseDeepLink(url) + + expect(result).toEqual({ + action: 'install-server', + serverName: 'github-mcp-server', + registryName: 'official', + }) + }) + + it('should return null for invalid protocol', () => { + const url = 'https://example.com/install-server?server=test' + const result = parseDeepLink(url) + + expect(result).toBeNull() + }) + + it('should return null for malformed URL', () => { + const url = 'not-a-url' + const result = parseDeepLink(url) + + expect(result).toBeNull() + }) +}) + +describe('Deep Link Generator', () => { + it('should generate basic install server link', () => { + const link = generateInstallServerLink('github-mcp-server') + + expect(link).toBe('toolhive://install-server?server=github-mcp-server') + }) + + it('should generate link with registry', () => { + const link = generateInstallServerLink('github-mcp-server', 'official') + + expect(link).toBe( + 'toolhive://install-server?server=github-mcp-server®istry=official' + ) + }) +}) + +describe('CLI Command Generator', () => { + it('should generate basic CLI command', () => { + const command = generateCliCommand('github-mcp-server') + + expect(command).toBe('thv run github-mcp-server') + }) + + it('should generate command with registry', () => { + const command = generateCliCommand('github-mcp-server', 'official') + + expect(command).toBe('thv run --registry official github-mcp-server') + }) +}) diff --git a/main/src/toolhive-manager.ts b/main/src/toolhive-manager.ts index 5063306fd..f9338a364 100644 --- a/main/src/toolhive-manager.ts +++ b/main/src/toolhive-manager.ts @@ -10,21 +10,26 @@ import * as Sentry from '@sentry/electron/main' import { getQuittingState } from './app-state' const binName = process.platform === 'win32' ? 'thv.exe' : 'thv' -const binPath = app.isPackaged - ? path.join( - process.resourcesPath, - 'bin', - `${process.platform}-${process.arch}`, - binName - ) - : path.resolve( - __dirname, - '..', - '..', - 'bin', - `${process.platform}-${process.arch}`, - binName - ) + +// Allow override via environment variable for development +const customBinPath = process.env.TOOLHIVE_BINARY_PATH +const binPath = + customBinPath || + (app.isPackaged + ? path.join( + process.resourcesPath, + 'bin', + `${process.platform}-${process.arch}`, + binName + ) + : path.resolve( + __dirname, + '..', + '..', + 'bin', + `${process.platform}-${process.arch}`, + binName + )) let toolhiveProcess: ReturnType | undefined let toolhivePort: number | undefined diff --git a/preload/src/preload.ts b/preload/src/preload.ts index c1cf3f45e..53834fd35 100644 --- a/preload/src/preload.ts +++ b/preload/src/preload.ts @@ -202,6 +202,28 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('utils:get-workload-available-tools', workload), }, + // Deep linking functions + deepLink: { + generateInstallLink: (serverName: string, registryName?: string) => + ipcRenderer.invoke( + 'deep-link:generate-install-link', + serverName, + registryName + ), + generateCliCommand: (serverName: string, registryName?: string) => + ipcRenderer.invoke( + 'deep-link:generate-cli-command', + serverName, + registryName + ), + onNavigate: (callback: (data: unknown) => void) => { + ipcRenderer.on('deep-link-navigate', (_, data) => callback(data)) + return () => { + ipcRenderer.removeListener('deep-link-navigate', callback) + } + }, + }, + // IPC event listeners for streaming on: (channel: string, listener: (...args: unknown[]) => void) => { ipcRenderer.on(channel, (_, ...args) => listener(...args)) @@ -449,6 +471,17 @@ export interface ElectronAPI { | undefined > } + deepLink: { + generateInstallLink: ( + serverName: string, + registryName?: string + ) => Promise + generateCliCommand: ( + serverName: string, + registryName?: string + ) => Promise + onNavigate: (callback: (data: unknown) => void) => () => void + } // IPC event listeners for streaming on: (channel: string, listener: (...args: unknown[]) => void) => void diff --git a/renderer/src/common/hooks/use-deep-link.tsx b/renderer/src/common/hooks/use-deep-link.tsx new file mode 100644 index 000000000..6efe26f4a --- /dev/null +++ b/renderer/src/common/hooks/use-deep-link.tsx @@ -0,0 +1,94 @@ +import { useEffect } from 'react' +import { useRouter } from '@tanstack/react-router' +import log from 'electron-log/renderer' + +export interface DeepLinkData { + action: string + serverName?: string + registryName?: string +} + +/** + * Hook to handle deep link navigation + */ +export function useDeepLink() { + const router = useRouter() + + useEffect(() => { + if (!window.electronAPI?.deepLink) { + return + } + + const unsubscribe = window.electronAPI.deepLink.onNavigate( + (data: unknown) => { + const linkData = data as DeepLinkData + log.info('Received deep link navigation:', linkData) + + handleDeepLinkNavigation(linkData, router) + } + ) + + return unsubscribe + }, [router]) + + return { + generateInstallLink: window.electronAPI?.deepLink?.generateInstallLink, + generateCliCommand: window.electronAPI?.deepLink?.generateCliCommand, + } +} + +/** + * Handle deep link navigation based on the action + */ +function handleDeepLinkNavigation( + data: DeepLinkData, + router: ReturnType +) { + try { + switch (data.action) { + case 'install-server': + handleInstallServerLink(data, router) + break + + default: + log.warn(`Unknown deep link action: ${data.action}`) + // Navigate to default page + router.navigate({ + to: '/group/$groupName', + params: { groupName: 'default' }, + }) + } + } catch (error) { + log.error('Failed to handle deep link navigation:', error) + // Fallback to default page + router.navigate({ + to: '/group/$groupName', + params: { groupName: 'default' }, + }) + } +} + +/** + * Handle install-server deep link + */ +function handleInstallServerLink( + data: DeepLinkData, + router: ReturnType +) { + if (!data.serverName) { + log.error('Missing server name for install-server deep link') + return + } + + // Navigate to registry server detail page with server name as the route param + router.navigate({ + to: '/registry/$name', + params: { name: data.serverName }, + search: { + // Pass server name explicitly in search to trigger modal opening + server: data.serverName, + }, + }) + + log.info(`Navigated to install server: ${data.serverName}`) +} diff --git a/renderer/src/routes/(registry)/registry_.$name.tsx b/renderer/src/routes/(registry)/registry_.$name.tsx index 9d7a16854..20700755d 100644 --- a/renderer/src/routes/(registry)/registry_.$name.tsx +++ b/renderer/src/routes/(registry)/registry_.$name.tsx @@ -1,7 +1,12 @@ import { LinkViewTransition } from '@/common/components/link-view-transition' import { Button } from '@/common/components/ui/button' import { Separator } from '@/common/components/ui/separator' -import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { + createFileRoute, + Link, + useParams, + useSearch, +} from '@tanstack/react-router' import { ChevronLeft, GithubIcon, ShieldCheck, Wrench } from 'lucide-react' import { getApiV1BetaRegistryByNameServersByServerNameOptions } from '@api/@tanstack/react-query.gen' import { useSuspenseQuery } from '@tanstack/react-query' @@ -11,7 +16,7 @@ import type { RegistryImageMetadata, RegistryRemoteServerMetadata, } from '@api/types.gen' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Tooltip, TooltipContent, @@ -44,6 +49,9 @@ export const Route = createFileRoute('/(registry)/registry_/$name')({ export function RegistryServerDetail() { const { name } = useParams({ from: '/(registry)/registry_/$name' }) + const search = useSearch({ + strict: false, + }) const { data: { server: localServer, remote_server: remoteServer }, } = useSuspenseQuery( @@ -62,6 +70,14 @@ export function RegistryServerDetail() { >(null) const [isModalOpen, setIsModalOpen] = useState(false) + // Handle deep linking: auto-open modal when coming from a deep link + useEffect(() => { + if (server && search && 'server' in search && search.server) { + setSelectedServer(server) + setIsModalOpen(true) + } + }, [server, search]) + if (!server) return null const handleCardClick = ( diff --git a/renderer/src/routes/__root.tsx b/renderer/src/routes/__root.tsx index ccf848f50..00a0a603c 100644 --- a/renderer/src/routes/__root.tsx +++ b/renderer/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { Toaster } from '@/common/components/ui/sonner' import { getApiV1BetaSecretsDefaultOptions } from '@api/@tanstack/react-query.gen' +import { useDeepLink } from '@/common/hooks/use-deep-link' import '@fontsource/space-mono/400.css' import '@fontsource/atkinson-hyperlegible/400.css' import '@fontsource/atkinson-hyperlegible/700.css' @@ -47,6 +48,9 @@ function RootComponent() { const matches = useMatches() const isShutdownRoute = matches.some((match) => match.routeId === '/shutdown') + // Initialize deep link handling + useDeepLink() + return ( <> {!isShutdownRoute && } diff --git a/vitest.setup.ts b/vitest.setup.ts index bfff9b5ef..14a48db9d 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -22,6 +22,11 @@ beforeAll(() => { clearShutdownHistory: async () => ({ success: true }), } as ElectronAPI['shutdownStore'], getInstanceId: async () => 'test-instance-id', + deepLink: { + onNavigate: () => () => {}, + generateInstallLink: async () => '', + generateCliCommand: async () => '', + }, } Object.defineProperty(window, 'electronAPI', { value: electronStub as ElectronAPI, @@ -45,6 +50,31 @@ beforeAll(() => { ), })) + vi.mock('electron-log', () => ({ + default: new Proxy( + {}, + { + get: () => vi.fn(() => new Proxy({}, { get: () => vi.fn() })), + } + ), + })) + + vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/tmp/test'), + getName: vi.fn(() => 'test-app'), + on: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), + setAsDefaultProtocolClient: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), + }, + BrowserWindow: vi.fn(), + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + }, + })) + vi.mock('sonner', () => ({ Toaster: () => null, toast: {