refactor(core): transition to WPE
This commit is contained in:
+7
-6
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user