const express = require('express'); const bodyParser = require('body-parser'); const puppeteer = require('puppeteer-core'); const { spawn, exec, execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const app = express(); app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); const PORT = process.env.PORT || 5011; const MONITOR = process.env.MONITOR || 'HDMI-1'; // State variables let browserProcess = null; let browser = null; let page = null; let autorefreshInterval = null; let currentUrl = process.env.LAUNCH_URL || 'about:blank'; let isKiosk = process.env.KIOSK !== '0'; // default 1 let isGpu = process.env.GPU !== '0'; // default 1 let chromiumFlags = []; function getMonitorGeometry(monitorName) { try { const stdout = execSync('xrandr').toString(); const lines = stdout.split('\n'); for (const line of lines) { if (line.includes(monitorName) && line.includes('connected')) { // Match geometry string like 1920x1080+1920+0 const match = line.match(/(\d+)x(\d+)\+(\d+)\+(\d+)/); if (match) { return { width: match[1], height: match[2], x: match[3], y: match[4] }; } } } } catch (e) { console.error('Failed to parse xrandr:', e); } // Fallback geometry if xrandr fails return { width: 1920, height: 1080, x: 0, y: 0 }; } function buildFlags() { const geometry = getMonitorGeometry(MONITOR); console.log(`Monitor ${MONITOR} resolved to position ${geometry.x},${geometry.y} with size ${geometry.width}x${geometry.height}`); const flags = [ '--no-sandbox', '--disable-dev-shm-usage', '--remote-debugging-port=9222', `--user-data-dir=/data/chromium-${MONITOR}`, `--window-position=${geometry.x},${geometry.y}`, `--window-size=${geometry.width},${geometry.height}` ]; if (isKiosk) { flags.push('--kiosk'); // To ensure full screen stays on the target monitor flags.push(`--app=${currentUrl}`); } if (!isGpu) { flags.push('--disable-gpu'); flags.push('--disable-software-rasterizer'); } else { flags.push('--enable-gpu'); } chromiumFlags = flags; return flags; } async function startChromium() { if (browserProcess) { console.log('Stopping existing Chromium...'); browserProcess.kill('SIGTERM'); await new Promise(resolve => setTimeout(resolve, 2000)); } const flags = buildFlags(); console.log('Starting Chromium with flags:', flags.join(' ')); // If kiosk is enabled, we pass the URL via --app to enforce window boundaries // Otherwise we just pass the URL as a positional argument. const args = [...flags]; if (!isKiosk) { args.push(currentUrl); } browserProcess = spawn('/usr/bin/chromium', args, { stdio: 'inherit' }); // Connect Puppeteer setTimeout(async () => { try { console.log('Connecting puppeteer...'); browser = await puppeteer.connect({ browserURL: 'http://localhost:9222', defaultViewport: null }); const pages = await browser.pages(); page = pages.length > 0 ? pages[0] : await browser.newPage(); if (!isKiosk) { console.log(`Navigating to ${currentUrl}`); await page.goto(currentUrl); } } catch (e) { console.error('Puppeteer connect error:', e); } }, 3000); } // API Endpoints app.get('/ping', (req, res) => { res.status(200).send('OK'); }); app.post('/refresh', async (req, res) => { if (page) { await page.reload(); res.status(200).send('Refreshed'); } else { res.status(503).send('Browser not ready'); } }); app.post('/autorefresh/:interval', (req, res) => { const interval = parseInt(req.params.interval, 10); if (autorefreshInterval) { clearInterval(autorefreshInterval); autorefreshInterval = null; } if (interval > 0 && interval <= 60) { autorefreshInterval = setInterval(() => { if (page) page.reload().catch(console.error); }, interval * 1000); res.status(200).send(`Autorefresh enabled: ${interval}s`); } else { res.status(200).send('Autorefresh disabled'); } }); app.post('/scan', (req, res) => { res.status(200).send('Scanned'); }); app.get('/url', (req, res) => { res.status(200).send(currentUrl); }); app.post('/url', async (req, res) => { let needRestart = false; if (req.body.url) currentUrl = req.body.url; if (req.body.gpu !== undefined) { const newGpu = req.body.gpu === '1'; if (newGpu !== isGpu) { isGpu = newGpu; needRestart = true; } } if (req.body.kiosk !== undefined) { const newKiosk = req.body.kiosk === '1'; if (newKiosk !== isKiosk) { isKiosk = newKiosk; needRestart = true; } } // If we're in kiosk mode, --app flag is used so changing URL requires restart to stay full screen // If we're not in kiosk mode, we can just navigate the page if (needRestart || isKiosk) { await startChromium(); } else if (page) { await page.goto(currentUrl); } res.status(200).send(`URL set to ${currentUrl}`); }); app.get('/gpu', (req, res) => { res.status(200).send(isGpu ? '1' : '0'); }); app.put('/gpu/:value', async (req, res) => { const val = req.params.value === '1'; if (val !== isGpu) { isGpu = val; await startChromium(); } res.status(200).send(isGpu ? '1' : '0'); }); app.get('/kiosk', (req, res) => { res.status(200).send(isKiosk ? '1' : '0'); }); app.put('/kiosk/:value', async (req, res) => { const val = req.params.value === '1'; if (val !== isKiosk) { isKiosk = val; await startChromium(); } res.status(200).send(isKiosk ? '1' : '0'); }); app.get('/flags', (req, res) => { res.status(200).json(chromiumFlags); }); app.get('/version', (req, res) => { exec('/usr/bin/chromium --version', (err, stdout) => { if (err) res.status(500).send('Unknown'); else res.status(200).send(stdout.trim()); }); }); app.get('/screenshot', (req, res) => { const file = `/tmp/screenshot-${Date.now()}.png`; // We use scrot for X11 screenshots. exec(`scrot ${file}`, (err) => { if (err) return res.status(500).send('Screenshot failed'); res.sendFile(file); }); }); // Start initially startChromium(); app.listen(PORT, () => { console.log(`Kiosk API running on port ${PORT} for monitor ${MONITOR}`); });