refactor(core): transition to WPE

This commit is contained in:
2026-07-06 18:10:12 +02:00
parent 2415502d4b
commit e3b4692802
4 changed files with 92 additions and 144 deletions
+7 -6
View File
@@ -1,12 +1,16 @@
FROM node:18-bullseye-slim FROM node:18-bookworm-slim
# Install dependencies for Chromium and X11 # Install dependencies for WPE WebKit (Cog), X11, and DBus
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
chromium \ cog \
wpewebkit \
x11-xserver-utils \ x11-xserver-utils \
wmctrl \
xdotool \
scrot \ scrot \
jq \ jq \
curl \ curl \
dbus-x11 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -18,7 +22,4 @@ COPY server.js ./
COPY start.sh /start.sh COPY start.sh /start.sh
RUN chmod +x /start.sh RUN chmod +x /start.sh
# Provide a default user data directory
RUN mkdir -p /data/chromium && chown -R node:node /data
CMD ["/start.sh"] CMD ["/start.sh"]
+1 -2
View File
@@ -1,14 +1,13 @@
{ {
"name": "kiosk-api", "name": "kiosk-api",
"version": "1.0.0", "version": "1.0.0",
"description": "Multi-monitor kiosk API based on balena browser block", "description": "Multi-monitor kiosk API using WPE WebKit",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",
"puppeteer-core": "^20.0.0",
"body-parser": "^1.20.2" "body-parser": "^1.20.2"
} }
} }
+73 -128
View File
@@ -1,9 +1,7 @@
const express = require('express'); const express = require('express');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const puppeteer = require('puppeteer-core'); const { spawn, execSync, exec } = require('child_process');
const { spawn, exec, execSync } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const app = express(); const app = express();
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true }));
@@ -14,14 +12,11 @@ const MONITOR = process.env.MONITOR || 'HDMI-1';
// State variables // State variables
let browserProcess = null; let browserProcess = null;
let browser = null;
let page = null;
let autorefreshInterval = null; let autorefreshInterval = null;
let currentUrl = process.env.LAUNCH_URL || 'about:blank'; let currentUrl = process.env.LAUNCH_URL || 'https://duckduckgo.com';
let isKiosk = process.env.KIOSK !== '0'; // default 1 let isKiosk = 1; // Cog is always full-screen/kiosk implicitly
let isGpu = process.env.GPU !== '0'; // default 1 let isGpu = 1; // WPE is inherently hardware-accelerated
let chromiumFlags = [];
function getMonitorGeometry(monitorName) { function getMonitorGeometry(monitorName) {
try { try {
@@ -29,7 +24,6 @@ function getMonitorGeometry(monitorName) {
const lines = stdout.split('\n'); const lines = stdout.split('\n');
for (const line of lines) { for (const line of lines) {
if (line.startsWith(monitorName + ' ') && line.includes(' connected')) { if (line.startsWith(monitorName + ' ') && line.includes(' connected')) {
// Match geometry string like 1920x1080+1920+0
const match = line.match(/(\d+)x(\d+)\+(\d+)\+(\d+)/); const match = line.match(/(\d+)x(\d+)\+(\d+)\+(\d+)/);
if (match) { if (match) {
return { return {
@@ -50,7 +44,6 @@ function getMonitorGeometry(monitorName) {
function ensureMonitorConnected() { function ensureMonitorConnected() {
return new Promise(resolve => { return new Promise(resolve => {
const check = () => { const check = () => {
// Run xrandr --auto to ensure any newly connected displays are configured
try { try {
execSync('xrandr --auto'); execSync('xrandr --auto');
} catch(e) {} } catch(e) {}
@@ -68,91 +61,70 @@ function ensureMonitorConnected() {
}); });
} }
function buildFlags(geometry) { function positionCogWindow(geometry) {
console.log(`Monitor ${MONITOR} resolved to position ${geometry.x},${geometry.y} with size ${geometry.width}x${geometry.height}`); // Wait for the window to appear, then use wmctrl to move it
console.log('Attempting to position Cog window...');
const flags = [ let attempts = 10;
'--no-sandbox', const tryMove = () => {
'--disable-dev-shm-usage', try {
'--remote-debugging-port=9222', // Find the window ID of the Cog browser.
`--user-data-dir=/data/chromium-${MONITOR}`, // Cog's window title or class is usually 'cog'
`--window-position=${geometry.x},${geometry.y}`, const stdout = execSync('wmctrl -l').toString();
`--window-size=${geometry.width},${geometry.height}`, if (stdout.toLowerCase().includes('cog')) {
'--autoplay-policy=no-user-gesture-required' // Find WID
]; const line = stdout.split('\n').find(l => l.toLowerCase().includes('cog'));
if (line) {
if (isKiosk) { const wid = line.split(' ')[0];
flags.push('--kiosk'); console.log(`Found Cog window: ${wid}. Positioning to ${geometry.x},${geometry.y}...`);
// To ensure full screen stays on the target monitor // Remove maximization first
flags.push(`--app=${currentUrl}`); try { execSync(`wmctrl -i -r ${wid} -b remove,maximized_vert,maximized_horz`); } catch (e) {}
// Move and resize: gravity,x,y,w,h
execSync(`wmctrl -i -r ${wid} -e 0,${geometry.x},${geometry.y},${geometry.width},${geometry.height}`);
console.log('Window positioned successfully.');
return; // Done
}
}
} catch (e) {
console.log('wmctrl check failed', e.message);
} }
if (!isGpu) { attempts--;
flags.push('--disable-gpu'); if (attempts > 0) {
flags.push('--disable-software-rasterizer'); setTimeout(tryMove, 1000);
} else { } else {
flags.push('--enable-gpu'); console.error('Failed to find and position Cog window.');
} }
};
chromiumFlags = flags; setTimeout(tryMove, 2000);
return flags;
} }
async function startChromium() { async function startBrowser() {
console.log(`Checking connection status for ${MONITOR}...`); console.log(`Checking connection status for ${MONITOR}...`);
const geometry = await ensureMonitorConnected(); const geometry = await ensureMonitorConnected();
if (browserProcess) { if (browserProcess) {
console.log('Stopping existing Chromium...'); console.log('Stopping existing Cog...');
browserProcess.kill('SIGTERM'); browserProcess.kill('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
const flags = buildFlags(geometry); console.log(`Starting Cog to navigate to ${currentUrl}`);
console.log('Starting Chromium with flags:', flags.join(' ')); // Launch Cog. We use the X11 platform if available.
const args = [currentUrl];
// If kiosk is enabled, we pass the URL via --app to enforce window boundaries const env = Object.assign({}, process.env, {
// Otherwise we just pass the URL as a positional argument. COG_PLATFORM_NAME: 'x11',
const args = [...flags]; // Ensures background opacity might work and standard behaviors
if (!isKiosk) {
args.push(currentUrl);
}
browserProcess = spawn('/usr/bin/chromium', args, {
stdio: 'inherit'
}); });
// Connect Puppeteer with retries browserProcess = spawn('cog', args, {
let retries = 15; stdio: 'inherit',
const tryConnect = async () => { env: env
try {
console.log('Connecting puppeteer...');
browser = await puppeteer.connect({
browserURL: 'http://127.0.0.1:9222',
defaultViewport: null
}); });
const pages = await browser.pages(); // Call position function which will wait for window to exist and move it
page = pages.length > 0 ? pages[0] : await browser.newPage(); positionCogWindow(geometry);
if (!isKiosk) {
console.log(`Navigating to ${currentUrl}`);
await page.goto(currentUrl);
}
console.log('Puppeteer connected successfully!');
} catch (e) {
if (retries > 0) {
console.log(`Puppeteer connect failed, retrying in 2s... (${retries} attempts left)`);
retries--;
setTimeout(tryConnect, 2000);
} else {
console.error('Puppeteer connect error (fatal):', e);
}
}
};
setTimeout(tryConnect, 2000);
} }
// API Endpoints // API Endpoints
@@ -161,11 +133,11 @@ app.get('/ping', (req, res) => {
res.status(200).send('OK'); res.status(200).send('OK');
}); });
app.post('/refresh', async (req, res) => { app.post('/refresh', (req, res) => {
if (page) { try {
await page.reload(); execSync('cogctl reload');
res.status(200).send('Refreshed'); res.status(200).send('Refreshed');
} else { } catch (e) {
res.status(503).send('Browser not ready'); res.status(503).send('Browser not ready');
} }
}); });
@@ -179,7 +151,7 @@ app.post('/autorefresh/:interval', (req, res) => {
if (interval > 0 && interval <= 60) { if (interval > 0 && interval <= 60) {
autorefreshInterval = setInterval(() => { autorefreshInterval = setInterval(() => {
if (page) page.reload().catch(console.error); try { execSync('cogctl reload'); } catch(e) {}
}, interval * 1000); }, interval * 1000);
res.status(200).send(`Autorefresh enabled: ${interval}s`); res.status(200).send(`Autorefresh enabled: ${interval}s`);
} else { } else {
@@ -195,70 +167,44 @@ app.get('/url', (req, res) => {
res.status(200).send(currentUrl); res.status(200).send(currentUrl);
}); });
app.post('/url', async (req, res) => { app.post('/url', (req, res) => {
let needRestart = false;
if (req.body.url) currentUrl = req.body.url; if (req.body.url) currentUrl = req.body.url;
if (req.body.gpu !== undefined) { // Cogctl can directly tell the browser to open a URL without restarting it
const newGpu = req.body.gpu === '1'; try {
if (newGpu !== isGpu) { console.log(`Sending cogctl open ${currentUrl}`);
isGpu = newGpu; execSync(`cogctl open "${currentUrl}"`);
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}`); res.status(200).send(`URL set to ${currentUrl}`);
} catch (e) {
console.error('Failed to set URL via cogctl. Attempting restart...', e.message);
startBrowser().then(() => {
res.status(200).send(`URL set to ${currentUrl} (restarted)`);
});
}
}); });
app.get('/gpu', (req, res) => { app.get('/gpu', (req, res) => {
res.status(200).send(isGpu ? '1' : '0'); res.status(200).send('1'); // Always 1 for WPE
}); });
app.put('/gpu/:value', async (req, res) => { app.put('/gpu/:value', (req, res) => {
const val = req.params.value === '1'; res.status(200).send('1'); // Ignored for WPE
if (val !== isGpu) {
isGpu = val;
await startChromium();
}
res.status(200).send(isGpu ? '1' : '0');
}); });
app.get('/kiosk', (req, res) => { app.get('/kiosk', (req, res) => {
res.status(200).send(isKiosk ? '1' : '0'); res.status(200).send('1'); // Always 1 for WPE
}); });
app.put('/kiosk/:value', async (req, res) => { app.put('/kiosk/:value', (req, res) => {
const val = req.params.value === '1'; res.status(200).send('1'); // Ignored for WPE
if (val !== isKiosk) {
isKiosk = val;
await startChromium();
}
res.status(200).send(isKiosk ? '1' : '0');
}); });
app.get('/flags', (req, res) => { app.get('/flags', (req, res) => {
res.status(200).json(chromiumFlags); res.status(200).json(['WPE WebKit (Cog)']);
}); });
app.get('/version', (req, res) => { app.get('/version', (req, res) => {
exec('/usr/bin/chromium --version', (err, stdout) => { exec('cog --version', (err, stdout) => {
if (err) res.status(500).send('Unknown'); if (err) res.status(500).send('Unknown');
else res.status(200).send(stdout.trim()); else res.status(200).send(stdout.trim());
}); });
@@ -266,7 +212,6 @@ app.get('/version', (req, res) => {
app.get('/screenshot', (req, res) => { app.get('/screenshot', (req, res) => {
const file = `/tmp/screenshot-${Date.now()}.png`; const file = `/tmp/screenshot-${Date.now()}.png`;
// We use scrot for X11 screenshots.
exec(`scrot ${file}`, (err) => { exec(`scrot ${file}`, (err) => {
if (err) return res.status(500).send('Screenshot failed'); if (err) return res.status(500).send('Screenshot failed');
res.sendFile(file); res.sendFile(file);
@@ -274,7 +219,7 @@ app.get('/screenshot', (req, res) => {
}); });
// Start initially // Start initially
startChromium(); startBrowser();
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Kiosk API running on port ${PORT} for monitor ${MONITOR}`); console.log(`Kiosk API running on port ${PORT} for monitor ${MONITOR}`);
+5 -2
View File
@@ -6,10 +6,13 @@ while [ ! -S /tmp/.X11-unix/X0 ]; do
sleep 1 sleep 1
done done
echo "X11 display found. Starting API server..." echo "X11 display found. Initializing D-Bus and starting API server..."
# Disable screen blanking for this display as well just in case # Disable screen blanking
xset s off -dpms || true xset s off -dpms || true
# Initialize a local D-Bus session. This is required for cogctl to communicate with cog.
export DBUS_SESSION_BUS_ADDRESS=$(dbus-daemon --session --print-address --fork)
# Start the Node.js API server # Start the Node.js API server
exec node server.js exec node server.js