diff --git a/src/config.tsx b/src/config.tsx index 9aac39b..f095029 100644 --- a/src/config.tsx +++ b/src/config.tsx @@ -15,5 +15,7 @@ export const THEMES = { export const CONFIG = { // Port for the Vite Frontend - FRONTEND_PORT: serverConfig.frontendPort + FRONTEND_PORT: serverConfig.frontendPort, + MOUSE_SENSITIVITY: serverConfig.mouseSensitivity ?? 1.0, + NETWORK_POLLING_INTERVAL: serverConfig.networkPollingInterval ?? 5000 }; diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index c7bd3a5..13a3183 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import QRCode from 'qrcode'; import { CONFIG, APP_CONFIG, THEMES } from '../config'; @@ -7,10 +7,18 @@ export const Route = createFileRoute('/settings')({ component: SettingsPage, }) +/** + * SettingsPage component for configuring server IP, ports, and mouse sensitivity. + * Also handles real-time IP detection and QR code generation. + * + * @returns {JSX.Element} The rendered settings interface. + */ function SettingsPage() { const [ip, setIp] = useState(''); const [frontendPort, setFrontendPort] = useState(String(CONFIG.FRONTEND_PORT)); + const [pingDelay, setPingDelay] = useState(CONFIG.NETWORK_POLLING_INTERVAL ?? 5000); + // Client Side Settings (LocalStorage) const [invertScroll, setInvertScroll] = useState(() => { if (typeof window === 'undefined') return false; @@ -74,39 +82,85 @@ function SettingsPage() { .catch((e) => console.error('QR Error:', e)); }, [ip]); - // Effect: Auto-detect LAN IP from Server (only if on localhost) - useEffect(() => { + // WebSocket Management for IP Auto-detection + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const retryCountRef = useRef(0); + const MAX_RETRIES = 5; + + const connectWebSocket = useCallback(() => { if (typeof window === 'undefined') return; if (window.location.hostname !== 'localhost') return; - console.log('Attempting to auto-detect IP...'); + // Clean up existing socket if any + if (wsRef.current) { + wsRef.current.close(); + } + + console.log(`Starting IP auto-detection stream... (Attempt ${retryCountRef.current + 1})`); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws`; const socket = new WebSocket(wsUrl); + wsRef.current = socket; socket.onopen = () => { - console.log('Connected to local server for IP detection'); + console.log('Connected to local server for live IP tracking'); + retryCountRef.current = 0; // Reset retry count on success socket.send(JSON.stringify({ type: 'get-ip' })); }; socket.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.type === 'server-ip' && data.ip) { - console.log('Auto-detected IP:', data.ip); + // Handle both initial response and subsequent broadcasts + if ((data.type === 'server-ip' || data.type === 'connected') && data.ip) { + console.log('Received IP Update:', data.ip); setIp(data.ip); - socket.close(); } } catch (e) { - console.error(e); + console.error('WS Message Error:', e); } }; - return () => { - if (socket.readyState === WebSocket.OPEN) socket.close(); + socket.onclose = () => { + console.log('IP detection socket closed.'); + wsRef.current = null; + + // Reconnect logic with exponential backoff + if (retryCountRef.current < MAX_RETRIES) { + const timeout = Math.min(1000 * Math.pow(2, retryCountRef.current), 10000); + console.log(`Reconnecting in ${timeout}ms...`); + reconnectTimeoutRef.current = setTimeout(() => { + retryCountRef.current++; + connectWebSocket(); + }, timeout); + } else { + console.log('Max WebSocket retries reached. Stopping auto-detection.'); + } }; + + socket.onerror = (err) => { + console.error('WebSocket Error:', err); + // Verify if closing triggers onclose, usually it does. + socket.close(); + }; + }, []); + useEffect(() => { + connectWebSocket(); + + return () => { + if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); + if (wsRef.current) { + // Prevent auto-reconnection on unmount + wsRef.current.onclose = null; + wsRef.current.close(); + } + }; + }, [connectWebSocket]); + + const displayUrl = typeof window !== 'undefined' ? `${window.location.protocol}//${ip}:${CONFIG.FRONTEND_PORT}/trackpad` : `http://${ip}:${CONFIG.FRONTEND_PORT}/trackpad`; @@ -138,7 +192,7 @@ function SettingsPage() {

Client Settings

-
+
+
+ + setPingDelay(parseInt(e.target.value))} + /> +
+ 1s (Fast) + 5s (Default) + 30s (Slow) +
+
+
Warning @@ -241,6 +317,7 @@ function SettingsPage() { type: 'update-config', config: { frontendPort: port, + networkPollingInterval: pingDelay, } })); @@ -285,7 +362,8 @@ function SettingsPage() {
- - + + ) } + diff --git a/src/server-config.json b/src/server-config.json index ec9ca9e..2c4fc5d 100644 --- a/src/server-config.json +++ b/src/server-config.json @@ -1,5 +1,8 @@ { "host": "0.0.0.0", "frontendPort": 3000, - "address": "rein.local" + "address": "rein.local", + "mouseInvert": true, + "mouseSensitivity": 1.0, + "networkPollingInterval": 5000 } \ No newline at end of file diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 5c35971..366d2d6 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -4,12 +4,23 @@ import os from 'os'; import fs from 'fs'; import { Server, IncomingMessage } from 'http'; import { Socket } from 'net'; +import path from 'path'; -// Helper to find LAN IP +// interactions with the file system should be absolute to avoid CWD fragility +const CONFIG_PATH = path.resolve(__dirname, '../../src/server-config.json'); + +/** + * Retrieves the local IPv4 address of the host machine. + * Filters for non-internal (non-loopback) IPv4 interfaces. + * + * @returns {string} The local IPv4 address or 'localhost' if no interface is found. + */ function getLocalIp() { const nets = os.networkInterfaces(); for (const name of Object.keys(nets)) { - for (const net of nets[name]!) { + const ifaceList = nets[name]; + if (!ifaceList) continue; + for (const net of ifaceList) { if (net.family === 'IPv4' && !net.internal) { return net.address; } @@ -18,14 +29,74 @@ function getLocalIp() { return 'localhost'; } +/** + * Initializes the WebSocket server and sets up network interface polling. + * Handles WebSocket upgrades, client connections, and real-time IP broadcasts. + * + * @param {Server} server - The HTTP server instance to attach the WebSocket server to. + */ export function createWsServer(server: Server) { const wss = new WebSocketServer({ noServer: true }); const inputHandler = new InputHandler(); - const LAN_IP = getLocalIp(); + + let currentIp = getLocalIp(); const MAX_PAYLOAD_SIZE = 10 * 1024; // 10KB limit console.log(`WebSocket Server initialized (Upgrade mode)`); - console.log(`WS LAN IP: ${LAN_IP}`); + console.log(`Initial WS LAN IP: ${currentIp}`); + + // Frequency for network interface polling (ms) + // Read directly from config file + let pollingInterval = 5000; + + const startPolling = () => { + try { + if (fs.existsSync(CONFIG_PATH)) { + const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + if (typeof config.networkPollingInterval === 'number' && Number.isFinite(config.networkPollingInterval)) { + // Enforce minimum 100ms to prevent busy loops + pollingInterval = Math.max(100, config.networkPollingInterval); + } + } + } catch (e) { + console.error('Failed to read initial polling interval:', e); + } + return setInterval(pollIp, pollingInterval); + }; + + const pollIp = () => { + const newIp = getLocalIp(); + if (newIp !== currentIp) { + console.log(`Network Change Detected! IP: ${currentIp} -> ${newIp}`); + currentIp = newIp; + + // Broadcast the new IP to all connected clients + const updateMsg = JSON.stringify({ type: 'server-ip', ip: currentIp }); + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(updateMsg); + } + }); + } + }; + + let pollingIntervalId = startPolling(); + + + // Cleanup interval when the WebSocket server closes + wss.on('close', () => { + console.log('Clearing network polling interval'); + clearInterval(pollingIntervalId); + }); + + // Also handle process exit to ensure cleanup + const cleanup = () => { + clearInterval(pollingIntervalId); + process.exit(0); + }; + + process.once('SIGTERM', cleanup); + process.once('SIGINT', cleanup); server.on('upgrade', (request: IncomingMessage, socket: Socket, head: Buffer) => { const pathname = request.url; @@ -40,7 +111,8 @@ export function createWsServer(server: Server) { wss.on('connection', (ws: WebSocket) => { console.log('Client connected to /ws'); - ws.send(JSON.stringify({ type: 'connected', serverIp: LAN_IP })); + // Send current IP immediately on connection + ws.send(JSON.stringify({ type: 'connected', ip: currentIp })); ws.on('message', async (data: string) => { try { @@ -55,21 +127,46 @@ export function createWsServer(server: Server) { const msg = JSON.parse(raw); if (msg.type === 'get-ip') { - ws.send(JSON.stringify({ type: 'server-ip', ip: LAN_IP })); + ws.send(JSON.stringify({ type: 'server-ip', ip: currentIp })); return; } if (msg.type === 'update-config') { console.log('Updating config:', msg.config); try { - const configPath = './src/server-config.json'; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const current = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, 'utf-8')) : {}; - const newConfig = { ...current, ...msg.config }; + const current = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {}; + + // Whitelist allowed keys to prevent arbitrary file writes + const ALLOWED_KEYS = ['frontendPort', 'networkPollingInterval', 'mouseInvert', 'mouseSensitivity']; + const cleanConfig: Record = {}; + + // Validate and copy only allowed keys + for (const key of ALLOWED_KEYS) { + if (Object.prototype.hasOwnProperty.call(msg.config, key)) { + const val = msg.config[key]; + // Basic type validation + if (key === 'frontendPort' && (typeof val !== 'number' || !Number.isFinite(val))) continue; + if (key === 'networkPollingInterval' && (typeof val !== 'number' || !Number.isFinite(val))) continue; + if (key === 'mouseInvert' && typeof val !== 'boolean') continue; + if (key === 'mouseSensitivity' && (typeof val !== 'number' || !Number.isFinite(val))) continue; + + cleanConfig[key] = val; + } + } + + const newConfig = { ...current, ...cleanConfig }; + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(newConfig, null, 2)); + + // Restart polling if interval changed + if (cleanConfig.networkPollingInterval && cleanConfig.networkPollingInterval !== pollingInterval) { + console.log(`Polling interval changed: ${pollingInterval} -> ${cleanConfig.networkPollingInterval}`); + clearInterval(pollingIntervalId); + pollingIntervalId = startPolling(); + } - fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)); ws.send(JSON.stringify({ type: 'config-updated', success: true })); - console.log('Config updated. Vite should auto-restart.'); + console.log('Config updated.'); } catch (e) { console.error('Failed to update config:', e); ws.send(JSON.stringify({ type: 'config-updated', success: false, error: String(e) })); @@ -92,3 +189,4 @@ export function createWsServer(server: Server) { }; }); } +