sl-webui e2587b60fb feat: SaltyFace web app UI for Chromium kiosk (Issue #370)
Animated robot expression interface as lightweight web application:

**Architecture:**
- HTML5 Canvas rendering engine
- Node.js HTTP server (localhost:3000)
- ROSLIB WebSocket bridge for ROS2 topics
- Fullscreen responsive design (1024×600)

**Features:**
- 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking)
- Real-time ROS2 subscriptions:
  - /saltybot/state (emotion triggers)
  - /saltybot/battery (status display)
  - /saltybot/target_track (EXCITED emotion)
  - /saltybot/obstacles (ALERT emotion)
  - /social/speech/is_speaking (TALKING emotion)
  - /social/speech/is_listening (LISTENING emotion)
- Tap-to-toggle status overlay
- 60fps Canvas animation on Wayland
- ~80MB total memory (Node.js + browser)

**Files:**
- public/index.html — Main page (1024×600 fullscreen)
- public/salty-face.js — Canvas rendering + ROS2 integration
- server.js — Node.js HTTP server with CORS support
- systemd/salty-face-server.service — Auto-start systemd service
- docs/SALTY_FACE_WEB_APP.md — Complete setup & API documentation

**Integration:**
- Runs in Chromium kiosk (Issue #374)
- Depends on rosbridge_server for WebSocket bridge
- Serves on localhost:3000 (configurable)

**Next:** Issue #371 (Accessibility enhancements)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-03 16:42:41 -05:00

130 lines
4.0 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* SaltyFace Web App Server — Serves static HTML/JS to Chromium kiosk
*
* Lightweight HTTP server for SaltyFace web UI
* - Runs on localhost:3000 (configurable)
* - Serves public/ directory
* - Enables CORS for ROS bridge WebSocket
* - Suitable for systemd service or ROS2 launch
*
* Usage:
* node server.js [--port 3000] [--host 127.0.0.1]
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
// Configuration
const PORT = parseInt(process.argv.find(arg => arg.startsWith('--port='))?.split('=')[1] || 3000);
const HOST = process.argv.find(arg => arg.startsWith('--host='))?.split('=')[1] || '0.0.0.0';
const PUBLIC_DIR = path.join(__dirname, 'public');
// MIME types
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
};
// HTTP server
const server = http.createServer((req, res) => {
// CORS headers for ROS bridge
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Parse request URL
const parsedUrl = url.parse(req.url, true);
let pathname = parsedUrl.pathname;
// Default to index.html for root
if (pathname === '/') {
pathname = '/index.html';
}
// Security: prevent directory traversal
const filePath = path.normalize(path.join(PUBLIC_DIR, pathname));
if (!filePath.startsWith(PUBLIC_DIR)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Access denied');
return;
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
// File not found
console.log(`[SaltyFace] 404 ${pathname}`);
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File not found');
return;
}
// Determine MIME type
const ext = path.extname(filePath).toLowerCase();
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
// Serve file
res.writeHead(200, {
'Content-Type': mimeType,
'Content-Length': stats.size,
'Cache-Control': 'public, max-age=3600',
});
fs.createReadStream(filePath).pipe(res);
console.log(`[SaltyFace] 200 ${pathname} (${mimeType})`);
});
});
// Start server
server.listen(PORT, HOST, () => {
console.log(`
╔═══════════════════════════════════════════════════════════╗
║ SaltyFace Web App Server — Running ║
║ URL: http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}
║ Root: ${PUBLIC_DIR}
║ Serving SaltyFace UI for Chromium kiosk ║
╚═══════════════════════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n[SaltyFace] Shutting down...');
server.close(() => {
console.log('[SaltyFace] Server closed');
process.exit(0);
});
});
process.on('SIGTERM', () => {
console.log('[SaltyFace] Received SIGTERM, shutting down...');
server.close(() => {
console.log('[SaltyFace] Server closed');
process.exit(0);
});
});
// Error handling
process.on('uncaughtException', (err) => {
console.error('[SaltyFace] Uncaught exception:', err);
process.exit(1);
});