From caac696244b3d516a847947182dbb73fe6b8cc47 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 31 Jan 2026 12:25:23 +0100 Subject: [PATCH] Update terminal panel --- AppImage/components/terminal-panel.tsx | 144 +++++++++++++++++++++- AppImage/scripts/flask_terminal_routes.py | 22 +++- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index c0227d7e..2c94db9c 100644 --- a/AppImage/components/terminal-panel.tsx +++ b/AppImage/components/terminal-panel.tsx @@ -34,6 +34,7 @@ interface TerminalInstance { ws: WebSocket | null isConnected: boolean fitAddon: any // Added fitAddon to TerminalInstance + pingInterval?: ReturnType | null // Heartbeat interval to keep connection alive } function getWebSocketUrl(): string { @@ -131,6 +132,10 @@ const proxmoxCommands = [ { cmd: "clear", desc: "Clear terminal screen" }, ] +function reconnectTerminal(id: string) { + // Implementation of reconnectTerminal function +} + export const TerminalPanel: React.FC = ({ websocketUrl, onClose }) => { const [terminals, setTerminals] = useState([]) const [activeTerminalId, setActiveTerminalId] = useState("") @@ -171,6 +176,29 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl } }, []) + // Handle page visibility change for automatic reconnection when user returns + // This is especially important for mobile/tablet devices (iPad) where switching apps + // puts the browser tab in background and may close WebSocket connections + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // When page becomes visible again, check all terminal connections + terminals.forEach((terminal) => { + if (terminal.ws && terminal.ws.readyState !== WebSocket.OPEN && terminal.term) { + // Terminal is disconnected, attempt to reconnect + reconnectTerminal(terminal.id) + } + }) + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [terminals]) + const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => { // Bloquear solo en pantallas muy pequeñas (móviles) if (window.innerWidth < 640 && !isTablet) { @@ -273,6 +301,81 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl return () => clearTimeout(debounce) }, [searchQuery]) + // Function to reconnect a terminal when connection is lost + // This is called when page visibility changes (user returns from another app) + const reconnectTerminal = async (terminalId: string) => { + const terminal = terminals.find(t => t.id === terminalId) + if (!terminal || !terminal.term) return + + // Show reconnecting message + terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m') + + const wsUrl = websocketUrl || getWebSocketUrl() + const ws = new WebSocket(wsUrl) + + ws.onopen = () => { + // Clear any existing ping interval + if (terminal.pingInterval) { + clearInterval(terminal.pingInterval) + } + + // Start heartbeat ping every 25 seconds to keep connection alive + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } else { + clearInterval(pingInterval) + } + }, 25000) + + setTerminals((prev) => + prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t)) + ) + terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m') + + // Sync terminal size + if (terminal.fitAddon) { + try { + terminal.fitAddon.fit() + ws.send(JSON.stringify({ + type: 'resize', + cols: terminal.term.cols, + rows: terminal.term.rows, + })) + } catch (err) { + console.warn('[Terminal] resize on reconnect failed:', err) + } + } + } + + ws.onmessage = (event) => { + terminal.term.write(event.data) + } + + ws.onerror = () => { + terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m') + } + + ws.onclose = () => { + setTerminals((prev) => prev.map((t) => { + if (t.id === terminalId) { + if (t.pingInterval) { + clearInterval(t.pingInterval) + } + return { ...t, isConnected: false, pingInterval: null } + } + return t + })) + terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m') + } + + terminal.term.onData((data: string) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + }) + } + const addNewTerminal = () => { if (terminals.length >= 4) return @@ -286,6 +389,7 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl ws: null, isConnected: false, fitAddon: null, // Added fitAddon initialization + pingInterval: null, // Added pingInterval initialization }, ]) setActiveTerminalId(newId) @@ -294,6 +398,10 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl const closeTerminal = (id: string) => { const terminal = terminals.find((t) => t.id === id) if (terminal) { + // Clear heartbeat interval + if (terminal.pingInterval) { + clearInterval(terminal.pingInterval) + } if (terminal.ws) { terminal.ws.close() } @@ -423,8 +531,18 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl } ws.onopen = () => { + // Start heartbeat ping every 25 seconds to keep connection alive + // This prevents disconnection when switching apps on mobile/tablet (iPad) + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } else { + clearInterval(pingInterval) + } + }, 25000) + setTerminals((prev) => - prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws, fitAddon } : t)), + prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: true, term, ws, fitAddon, pingInterval } : t)), ) syncSizeWithBackend() } @@ -435,12 +553,28 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl ws.onerror = (error) => { console.error("[v0] TerminalPanel: WebSocket error:", error) - setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t))) + setTerminals((prev) => prev.map((t) => { + if (t.id === terminal.id) { + if (t.pingInterval) { + clearInterval(t.pingInterval) + } + return { ...t, isConnected: false, pingInterval: null } + } + return t + })) term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m") } ws.onclose = () => { - setTerminals((prev) => prev.map((t) => (t.id === terminal.id ? { ...t, isConnected: false } : t))) + setTerminals((prev) => prev.map((t) => { + if (t.id === terminal.id) { + if (t.pingInterval) { + clearInterval(t.pingInterval) + } + return { ...t, isConnected: false, pingInterval: null } + } + return t + })) term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m") } @@ -518,8 +652,10 @@ export const TerminalPanel: React.FC = ({ websocketUrl, onCl } } - const handleClose = () => { +const handleClose = () => { terminals.forEach((terminal) => { + // Clear heartbeat interval + if (terminal.pingInterval) clearInterval(terminal.pingInterval) if (terminal.ws) terminal.ws.close() if (terminal.term) terminal.term.dispose() }) diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index e03a8784..bc5f5a2c 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -181,11 +181,23 @@ def terminal_websocket(ws): except Exception: msg = None - if isinstance(msg, dict) and msg.get('type') == 'resize': - cols = int(msg.get('cols', 120)) - rows = int(msg.get('rows', 30)) - set_winsize(master_fd, rows, cols) - handled = True + if isinstance(msg, dict): + msg_type = msg.get('type') + + # Handle ping messages (heartbeat to keep connection alive) + if msg_type == 'ping': + try: + ws.send(json.dumps({'type': 'pong'})) + except: + pass + handled = True + + # Handle resize messages + elif msg_type == 'resize': + cols = int(msg.get('cols', 120)) + rows = int(msg.get('rows', 30)) + set_winsize(master_fd, rows, cols) + handled = True if handled: # Control message processed, do not send to bash