diff --git a/electron/main.cjs b/electron/main.cjs index c617aa6e..dd5f2c21 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,5 +1,6 @@ const { app, BrowserWindow, shell, ipcMain } = require('electron'); const path = require('path'); +const fs = require('fs'); let mainWindow = null; @@ -34,7 +35,8 @@ function createWindow() { webPreferences: { nodeIntegration: false, contextIsolation: true, - webSecurity: !isDev + webSecurity: !isDev, + preload: path.join(__dirname, 'preload.js') }, show: false }); @@ -90,6 +92,75 @@ ipcMain.handle('get-platform', () => { return process.platform; }); +// Server configuration handlers +ipcMain.handle('get-server-config', () => { + try { + const userDataPath = app.getPath('userData'); + const configPath = path.join(userDataPath, 'server-config.json'); + + if (fs.existsSync(configPath)) { + const configData = fs.readFileSync(configPath, 'utf8'); + return JSON.parse(configData); + } + return null; + } catch (error) { + console.error('Error reading server config:', error); + return null; + } +}); + +ipcMain.handle('save-server-config', (event, config) => { + try { + const userDataPath = app.getPath('userData'); + const configPath = path.join(userDataPath, 'server-config.json'); + + // Ensure userData directory exists + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); + } + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return { success: true }; + } catch (error) { + console.error('Error saving server config:', error); + return { success: false, error: error.message }; + } +}); + +ipcMain.handle('test-server-connection', async (event, serverUrl) => { + try { + const { default: fetch } = await import('node-fetch'); + + // Try multiple endpoints to test the connection + const testUrls = [ + `${serverUrl}/health`, + `${serverUrl}/version`, + `${serverUrl}/users/registration-allowed` + ]; + + for (const testUrl of testUrls) { + try { + const response = await fetch(testUrl, { + method: 'GET', + timeout: 5000 + }); + + if (response.ok) { + // If we get a 200 response, it's likely a valid Termix server + return { success: true, status: response.status, testedUrl: testUrl }; + } + } catch (urlError) { + // Continue to next URL if this one fails + continue; + } + } + + return { success: false, error: 'Server is not responding or not a valid Termix server' }; + } catch (error) { + return { success: false, error: error.message }; + } +}); + app.whenReady().then(() => { createWindow(); console.log('Termix started successfully'); diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 00000000..8ffd70d1 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,37 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +console.log('Preload script loaded'); + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +contextBridge.exposeInMainWorld('electronAPI', { + // App info + getAppVersion: () => ipcRenderer.invoke('get-app-version'), + getPlatform: () => ipcRenderer.invoke('get-platform'), + + // Server configuration + getServerConfig: () => ipcRenderer.invoke('get-server-config'), + saveServerConfig: (config) => ipcRenderer.invoke('save-server-config', config), + testServerConnection: (serverUrl) => ipcRenderer.invoke('test-server-connection', serverUrl), + + // File dialogs + showSaveDialog: (options) => ipcRenderer.invoke('show-save-dialog', options), + showOpenDialog: (options) => ipcRenderer.invoke('show-open-dialog', options), + + // Update events + onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback), + onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', callback), + + // Utility + removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), + isElectron: true, + isDev: process.env.NODE_ENV === 'development', + + // Generic invoke method + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args) +}); + +// Also set the legacy IS_ELECTRON flag for backward compatibility +window.IS_ELECTRON = true; + +console.log('electronAPI exposed to window'); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 12603f7f..a2817a37 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -151,6 +151,24 @@ "failedToLoadAlerts": "Failed to load alerts", "failedToDismissAlert": "Failed to dismiss alert" }, + "serverConfig": { + "title": "Server Configuration", + "description": "Configure the Termix server URL to connect to your backend services", + "serverUrl": "Server URL", + "enterServerUrl": "Please enter a server URL", + "testConnectionFirst": "Please test the connection first", + "connectionSuccess": "Connection successful!", + "connectionFailed": "Connection failed", + "connectionError": "Connection error occurred", + "connected": "Connected", + "disconnected": "Disconnected", + "configSaved": "Configuration saved successfully", + "saveFailed": "Failed to save configuration", + "saveError": "Error saving configuration", + "saving": "Saving...", + "saveConfig": "Save Configuration", + "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 or https://your-server.com)" + }, "common": { "close": "Close", "online": "Online", @@ -212,6 +230,7 @@ "email": "Email", "submit": "Submit", "cancel": "Cancel", + "change": "Change", "save": "Save", "delete": "Delete", "edit": "Edit", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index e48b2f46..89330ab9 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -152,6 +152,24 @@ "failedToLoadAlerts": "加载警报失败", "failedToDismissAlert": "关闭警报失败" }, + "serverConfig": { + "title": "服务器配置", + "description": "配置 Termix 服务器 URL 以连接到您的后端服务", + "serverUrl": "服务器 URL", + "enterServerUrl": "请输入服务器 URL", + "testConnectionFirst": "请先测试连接", + "connectionSuccess": "连接成功!", + "connectionFailed": "连接失败", + "connectionError": "连接发生错误", + "connected": "已连接", + "disconnected": "未连接", + "configSaved": "配置保存成功", + "saveFailed": "保存配置失败", + "saveError": "保存配置时出错", + "saving": "保存中...", + "saveConfig": "保存配置", + "helpText": "输入您的 Termix 服务器运行地址(例如:http://localhost:8081 或 https://your-server.com)" + }, "common": { "close": "关闭", "online": "在线", @@ -212,6 +230,7 @@ "email": "邮箱", "submit": "提交", "cancel": "取消", + "change": "更改", "save": "保存", "delete": "删除", "edit": "编辑", diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index d1f7ada7..f2176d2e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,18 +1,17 @@ interface ElectronAPI { - getBackendPort: () => Promise; getAppVersion: () => Promise; getPlatform: () => Promise; - restartBackend: () => Promise<{ success: boolean; port?: number; error?: string }>; + getServerConfig: () => Promise<{ serverUrl: string; lastUpdated: string } | null>; + saveServerConfig: (config: { serverUrl: string; lastUpdated: string }) => Promise<{ success: boolean; error?: string }>; + testServerConnection: (serverUrl: string) => Promise<{ success: boolean; error?: string; status?: number }>; showSaveDialog: (options: any) => Promise; showOpenDialog: (options: any) => Promise; - onBackendStarted: (callback: (data: { port: number }) => void) => void; - onBackendLog: (callback: (data: string) => void) => void; - onBackendError: (callback: (data: string) => void) => void; onUpdateAvailable: (callback: () => void) => void; onUpdateDownloaded: (callback: () => void) => void; removeAllListeners: (channel: string) => void; isElectron: boolean; isDev: boolean; + invoke: (channel: string, ...args: any[]) => Promise; } interface Window { diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index abb700d0..ab980ae3 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -172,7 +172,14 @@ export const Terminal = forwardRef(function SSHTerminal( const wsUrl = isDev ? 'ws://localhost:8082' : isElectron - ? 'ws://127.0.0.1:8082' + ? (() => { + // Get configured server URL from window object (set by main-axios) + const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081'; + // Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path + const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://'; + const wsHost = baseUrl.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); // Remove port if present + return `${wsProtocol}${wsHost}/ssh/websocket/`; + })() : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; const ws = new WebSocket(wsUrl); @@ -405,7 +412,14 @@ export const Terminal = forwardRef(function SSHTerminal( const wsUrl = isDev ? 'ws://localhost:8082' : isElectron - ? 'ws://127.0.0.1:8082' + ? (() => { + // Get configured server URL from window object (set by main-axios) + const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081'; + // Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path + const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://'; + const wsHost = baseUrl.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); // Remove port if present + return `${wsProtocol}${wsHost}/ssh/websocket/`; + })() : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; connectToHost(cols, rows); diff --git a/src/ui/Desktop/ElectronOnly/ServerConfig.tsx b/src/ui/Desktop/ElectronOnly/ServerConfig.tsx new file mode 100644 index 00000000..90fffbce --- /dev/null +++ b/src/ui/Desktop/ElectronOnly/ServerConfig.tsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button.tsx'; +import { Input } from '@/components/ui/input.tsx'; +import { Label } from '@/components/ui/label.tsx'; +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert.tsx'; +import { useTranslation } from 'react-i18next'; +import { getServerConfig, saveServerConfig, testServerConnection, type ServerConfig } from '@/ui/main-axios.ts'; +import { CheckCircle, XCircle, Server, Wifi } from 'lucide-react'; + +interface ServerConfigProps { + onServerConfigured: (serverUrl: string) => void; + onCancel?: () => void; + isFirstTime?: boolean; +} + +export function ServerConfig({ onServerConfigured, onCancel, isFirstTime = false }: ServerConfigProps) { + const { t } = useTranslation(); + const [serverUrl, setServerUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [testing, setTesting] = useState(false); + const [error, setError] = useState(null); + const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown'); + + useEffect(() => { + loadServerConfig(); + }, []); + + const loadServerConfig = async () => { + try { + const config = await getServerConfig(); + if (config?.serverUrl) { + setServerUrl(config.serverUrl); + setConnectionStatus('success'); + } + } catch (error) { + console.error('Failed to load server config:', error); + } + }; + + const handleTestConnection = async () => { + if (!serverUrl.trim()) { + setError(t('serverConfig.enterServerUrl')); + return; + } + + setTesting(true); + setError(null); + + try { + // Normalize URL + let normalizedUrl = serverUrl.trim(); + if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) { + normalizedUrl = `http://${normalizedUrl}`; + } + + const result = await testServerConnection(normalizedUrl); + + if (result.success) { + setConnectionStatus('success'); + } else { + setConnectionStatus('error'); + setError(result.error || t('serverConfig.connectionFailed')); + } + } catch (error) { + setConnectionStatus('error'); + setError(t('serverConfig.connectionError')); + } finally { + setTesting(false); + } + }; + + const handleSaveConfig = async () => { + if (!serverUrl.trim()) { + setError(t('serverConfig.enterServerUrl')); + return; + } + + if (connectionStatus !== 'success') { + setError(t('serverConfig.testConnectionFirst')); + return; + } + + setLoading(true); + setError(null); + + try { + // Normalize URL + let normalizedUrl = serverUrl.trim(); + if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) { + normalizedUrl = `http://${normalizedUrl}`; + } + + const config: ServerConfig = { + serverUrl: normalizedUrl, + lastUpdated: new Date().toISOString() + }; + + const success = await saveServerConfig(config); + + if (success) { + onServerConfigured(normalizedUrl); + } else { + setError(t('serverConfig.saveFailed')); + } + } catch (error) { + setError(t('serverConfig.saveError')); + } finally { + setLoading(false); + } + }; + + const handleUrlChange = (value: string) => { + setServerUrl(value); + setConnectionStatus('unknown'); + setError(null); + }; + + return ( +
+
+
+ +
+

{t('serverConfig.title')}

+

+ {t('serverConfig.description')} +

+
+
+
+ +
+ handleUrlChange(e.target.value)} + className="flex-1 h-10" + disabled={loading} + /> + +
+
+ + {connectionStatus !== 'unknown' && ( +
+ {connectionStatus === 'success' ? ( + <> + + {t('serverConfig.connected')} + + ) : ( + <> + + {t('serverConfig.disconnected')} + + )} +
+ )} + + {error && ( + + {t('common.error')} + {error} + + )} + + +
+ {onCancel && !isFirstTime && ( + + )} + +
+ +
+ {t('serverConfig.helpText')} +
+
+
+ ); +} diff --git a/src/ui/Desktop/Homepage/HomepageAuth.tsx b/src/ui/Desktop/Homepage/HomepageAuth.tsx index 933d5ebf..304c1392 100644 --- a/src/ui/Desktop/Homepage/HomepageAuth.tsx +++ b/src/ui/Desktop/Homepage/HomepageAuth.tsx @@ -20,8 +20,11 @@ import { completePasswordReset, getOIDCAuthorizeUrl, verifyTOTPLogin, - setCookie + setCookie, + getServerConfig, + type ServerConfig } from "../../main-axios.ts"; +import {ServerConfig as ServerConfigComponent} from "@/ui/Desktop/ElectronOnly/ServerConfig.tsx"; function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { @@ -410,6 +413,66 @@ export function HomepageAuth({ ); + // Check if we need to show server config for Electron + const [showServerConfig, setShowServerConfig] = useState(null); + const [currentServerUrl, setCurrentServerUrl] = useState(''); + + useEffect(() => { + const checkServerConfig = async () => { + if ((window as any).electronAPI) { + try { + const config = await getServerConfig(); + console.log('Desktop HomepageAuth - Server config check:', config); + setCurrentServerUrl(config?.serverUrl || ''); + setShowServerConfig(!config || !config.serverUrl); + } catch (error) { + console.log('Desktop HomepageAuth - No server config found, showing config screen'); + setShowServerConfig(true); + } + } else { + setShowServerConfig(false); + } + }; + + checkServerConfig(); + }, []); + + if (showServerConfig === null) { + // Still checking + return ( +
+
+
+
+
+ ); + } + + if (showServerConfig) { + console.log('Desktop HomepageAuth - SHOWING SERVER CONFIG SCREEN'); + return ( +
+ { + console.log('Server configured, reloading page'); + window.location.reload(); + }} + onCancel={() => { + console.log('Cancelled server config, going back to login'); + setShowServerConfig(false); + }} + isFirstTime={!currentServerUrl} + /> +
+ ); + } + return (
)} -
+
+ {(window as any).electronAPI && currentServerUrl && ( +
+
+ +
+ {currentServerUrl} +
+
+ +
+ )}
)} diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index b1e428c8..5225ef25 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -220,7 +220,14 @@ export const Terminal = forwardRef(function SSHTerminal( const wsUrl = isDev ? 'ws://localhost:8082' : isElectron - ? 'ws://127.0.0.1:8082' + ? (() => { + // Get configured server URL from window object (set by main-axios) + const baseUrl = (window as any).configuredServerUrl || 'http://127.0.0.1:8081'; + // Convert HTTP/HTTPS to WS/WSS and use nginx reverse proxy path + const wsProtocol = baseUrl.startsWith('https://') ? 'wss://' : 'ws://'; + const wsHost = baseUrl.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); // Remove port if present + return `${wsProtocol}${wsHost}/ssh/websocket/`; + })() : `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`; const ws = new WebSocket(wsUrl); diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 8f728103..0a612c3a 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -259,13 +259,77 @@ const isDev = process.env.NODE_ENV === 'development' && let apiHost = import.meta.env.VITE_API_HOST || 'localhost'; let apiPort = 8081; +let configuredServerUrl: string | null = null; if (isElectron) { apiPort = 8081; } +// Server configuration management for Electron +export interface ServerConfig { + serverUrl: string; + lastUpdated: string; +} + +export async function getServerConfig(): Promise { + if (!isElectron) return null; + + try { + const result = await (window as any).electronAPI?.invoke('get-server-config'); + return result; + } catch (error) { + console.error('Failed to get server config:', error); + return null; + } +} + +export async function saveServerConfig(config: ServerConfig): Promise { + if (!isElectron) return false; + + try { + const result = await (window as any).electronAPI?.invoke('save-server-config', config); + if (result?.success) { + configuredServerUrl = config.serverUrl; + updateApiInstances(); + return true; + } + return false; + } catch (error) { + console.error('Failed to save server config:', error); + return false; + } +} + +export async function testServerConnection(serverUrl: string): Promise<{ success: boolean; error?: string }> { + if (!isElectron) return { success: false, error: 'Not in Electron environment' }; + + try { + const result = await (window as any).electronAPI?.invoke('test-server-connection', serverUrl); + return result; + } catch (error) { + console.error('Failed to test server connection:', error); + return { success: false, error: 'Connection test failed' }; + } +} + +// Initialize server configuration on load +if (isElectron) { + getServerConfig().then(config => { + if (config?.serverUrl) { + configuredServerUrl = config.serverUrl; + updateApiInstances(); + } + }); +} + function getApiUrl(path: string, defaultPort: number): string { if (isElectron) { + if (configuredServerUrl) { + // In Electron with configured server, all requests go through nginx reverse proxy + // Use the same base URL for all services (nginx routes to correct backend port) + const baseUrl = configuredServerUrl.replace(/\/$/, ''); + return `${baseUrl}${path}`; + } return `http://127.0.0.1:${defaultPort}${path}`; } else if (isDev) { return `http://${apiHost}:${defaultPort}${path}`; @@ -305,7 +369,29 @@ export let authApi = createApiInstance( 'AUTH' ); -// Function to update API instances with new port (for Electron) +// Function to update API instances with new server configuration +function updateApiInstances() { + systemLogger.info('Updating API instances with new server configuration', { + operation: 'api_instance_update', + configuredServerUrl + }); + + sshHostApi = createApiInstance(getApiUrl('/ssh', 8081), 'SSH_HOST'); + tunnelApi = createApiInstance(getApiUrl('/ssh', 8083), 'TUNNEL'); + fileManagerApi = createApiInstance(getApiUrl('/ssh/file_manager', 8084), 'FILE_MANAGER'); + statsApi = createApiInstance(getApiUrl('', 8085), 'STATS'); + authApi = createApiInstance(getApiUrl('', 8081), 'AUTH'); + + // Make configuredServerUrl available globally for components that need it + (window as any).configuredServerUrl = configuredServerUrl; + + systemLogger.success('All API instances updated successfully', { + operation: 'api_instance_update_complete', + configuredServerUrl + }); +} + +// Function to update API instances with new port (for Electron) - kept for backward compatibility function updateApiPorts(port: number) { systemLogger.info('Updating API instances with new port', { operation: 'api_port_update', @@ -313,16 +399,7 @@ function updateApiPorts(port: number) { }); apiPort = port; - sshHostApi = createApiInstance(`http://127.0.0.1:${port}/ssh`, 'SSH_HOST'); - tunnelApi = createApiInstance(`http://127.0.0.1:${port}/ssh`, 'TUNNEL'); - fileManagerApi = createApiInstance(`http://127.0.0.1:${port}/ssh/file_manager`, 'FILE_MANAGER'); - statsApi = createApiInstance(`http://127.0.0.1:${port}`, 'STATS'); - authApi = createApiInstance(`http://127.0.0.1:${port}`, 'AUTH'); - - systemLogger.success('All API instances updated successfully', { - operation: 'api_port_update_complete', - port - }); + updateApiInstances(); } // ============================================================================ @@ -1104,8 +1181,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string) export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> { try { - const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : ''); - const response = await apiInstance.get(`/alerts/user/${userId}`); + const response = await authApi.get(`/alerts/user/${userId}`); return response.data; } catch (error) { handleApiError(error, 'fetch user alerts'); @@ -1114,9 +1190,7 @@ export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> export async function dismissAlert(userId: string, alertId: string): Promise { try { - // Use the general API instance since alerts endpoint is at root level - const apiInstance = createApiInstance(isDev ? `http://${apiHost}:8081` : ''); - const response = await apiInstance.post('/alerts/dismiss', { userId, alertId }); + const response = await authApi.post('/alerts/dismiss', { userId, alertId }); return response.data; } catch (error) { handleApiError(error, 'dismiss alert');