commit 38aba593450f01292f7f85892c077301745263b8 Author: Tueem Date: Sat Jul 4 14:16:05 2026 +0200 INITIAL diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/display-manager/Dockerfile b/display-manager/Dockerfile new file mode 100644 index 0000000..89e47b0 --- /dev/null +++ b/display-manager/Dockerfile @@ -0,0 +1,23 @@ +FROM debian:bullseye-slim + +RUN apt-get update && apt-get install -y \ + sway \ + xwayland \ + dbus-x11 \ + libwayland-client0 \ + libwayland-server0 \ + libwayland-egl1 \ + mesa-vulkan-drivers \ + libvulkan1 \ + fonts-liberation \ + x11-xserver-utils \ + && rm -rf /var/lib/apt/lists/* + +COPY sway.conf /etc/sway/config +COPY start.sh /start.sh +RUN chmod +x /start.sh + +# Need to run as root to access DRM devices on many kiosk setups +USER root + +CMD ["/start.sh"] diff --git a/display-manager/start.sh b/display-manager/start.sh new file mode 100644 index 0000000..289a56b --- /dev/null +++ b/display-manager/start.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Ensure runtime directory exists +mkdir -p /run/wayland +chmod 0700 /run/wayland + +# Set env vars for Wayland/Sway +export XDG_RUNTIME_DIR=/run/wayland +export WAYLAND_DISPLAY=wayland-1 +export WLR_BACKENDS=drm,libinput +# Fallback to headless if DRM is not available (useful for testing on mac/desktop without actual DRM) +# export WLR_BACKENDS=headless + +# Run sway +echo "Starting Sway..." +exec sway -d -c /etc/sway/config diff --git a/display-manager/sway.conf b/display-manager/sway.conf new file mode 100644 index 0000000..50826c5 --- /dev/null +++ b/display-manager/sway.conf @@ -0,0 +1,29 @@ +# Default sway config for Kiosk + +# Disable default bar +bar { + mode invisible +} + +# Remove window borders and title bars +default_border none +default_floating_border none +hide_edge_borders both + +# Map workspaces to specific outputs +# Note: In a real Pi 4 environment, the outputs might be named HDMI-A-1 and HDMI-A-2. +# You can customize these output names based on `swaymsg -t get_outputs`. +workspace 1 output HDMI-A-1 +workspace 2 output HDMI-A-2 + +# Set a black background +output * bg #000000 solid_color + +# Idle configuration +# We don't want the screen to turn off in a kiosk. +exec swayidle -w \ + timeout 3600 'swaymsg "output * dpms on"' \ + resume 'swaymsg "output * dpms on"' + +# Include any other specific configurations +include /etc/sway/config.d/* diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6411c95 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.8' + +services: + display-manager: + build: ./display-manager + privileged: true + restart: always + volumes: + - wayland-socket:/run/wayland + - /dev:/dev + environment: + - XDG_RUNTIME_DIR=/run/wayland + - WAYLAND_DISPLAY=wayland-1 + + kiosk-1: + build: ./kiosk + restart: always + depends_on: + - display-manager + ports: + - "5011:5011" + volumes: + - wayland-socket:/run/wayland + environment: + - XDG_RUNTIME_DIR=/run/wayland + - WAYLAND_DISPLAY=wayland-1 + - SWAYSOCK=/run/wayland/sway-ipc.sock + - WORKSPACE=1 + - PORT=5011 + - LAUNCH_URL=https://github.com + - KIOSK=1 + - GPU=1 + + kiosk-2: + build: ./kiosk + restart: always + depends_on: + - display-manager + ports: + - "5012:5012" + volumes: + - wayland-socket:/run/wayland + environment: + - XDG_RUNTIME_DIR=/run/wayland + - WAYLAND_DISPLAY=wayland-1 + - SWAYSOCK=/run/wayland/sway-ipc.sock + - WORKSPACE=2 + - PORT=5012 + - LAUNCH_URL=https://google.com + - KIOSK=1 + - GPU=1 + +volumes: + wayland-socket: diff --git a/kiosk/Dockerfile b/kiosk/Dockerfile new file mode 100644 index 0000000..aa69225 --- /dev/null +++ b/kiosk/Dockerfile @@ -0,0 +1,24 @@ +FROM node:18-bullseye-slim + +# Install dependencies for Chromium, Wayland, and screenshot tool +RUN apt-get update && apt-get install -y \ + chromium \ + sway \ + grim \ + jq \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY server.js ./ +COPY start.sh /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"] diff --git a/kiosk/package.json b/kiosk/package.json new file mode 100644 index 0000000..7b1c569 --- /dev/null +++ b/kiosk/package.json @@ -0,0 +1,14 @@ +{ + "name": "kiosk-api", + "version": "1.0.0", + "description": "Multi-monitor kiosk API based on balena browser block", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "puppeteer-core": "^20.0.0", + "body-parser": "^1.20.2" + } +} diff --git a/kiosk/server.js b/kiosk/server.js new file mode 100644 index 0000000..b4b7b0d --- /dev/null +++ b/kiosk/server.js @@ -0,0 +1,231 @@ +const express = require('express'); +const bodyParser = require('body-parser'); +const puppeteer = require('puppeteer-core'); +const { spawn, exec } = 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 WORKSPACE = process.env.WORKSPACE || '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 buildFlags() { + const flags = [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--remote-debugging-port=9222', + '--enable-features=UseOzonePlatform', + '--ozone-platform=wayland', + `--user-data-dir=/data/chromium-${WORKSPACE}`, + '--window-position=0,0' + ]; + + if (isKiosk) { + flags.push('--kiosk'); + } + + 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(); + // Start with a specific title so we can target it with swaymsg + const initialUrl = `data:text/html,KioskInit${WORKSPACE}Loading...`; + + console.log('Starting Chromium with flags:', flags.join(' ')); + + browserProcess = spawn('/usr/bin/chromium', [...flags, initialUrl], { + stdio: 'inherit' + }); + + // Wait a bit for the window to appear in Sway + setTimeout(() => { + console.log(`Assigning window to workspace ${WORKSPACE}...`); + exec(`swaymsg "[title=KioskInit${WORKSPACE}] move to workspace ${WORKSPACE}"`, (err, stdout, stderr) => { + if (err) console.error('Swaymsg error:', stderr); + else console.log('Swaymsg success:', stdout); + }); + }, 2000); + + // 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(); + + 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) => { + // Stub for local service discovery + 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 (needRestart) { + 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`; + // Using grim instead of scrot for Wayland + // We specify the output by workspace name if needed, but grim captures the whole screen if no output is specified. + // To capture just the workspace, we can use grim with swaymsg to find the coordinates, + // or simply rely on grim which by default captures everything. + // To be safe and capture just the output for this workspace: + exec(`grim -o HDMI-A-${WORKSPACE} ${file}`, (err) => { + if (err) { + // Fallback if HDMI-A-X is not found (e.g. testing locally) + exec(`grim ${file}`, (err2) => { + if (err2) return res.status(500).send('Screenshot failed'); + res.sendFile(file); + }); + } else { + res.sendFile(file); + } + }); +}); + +// Start initially +startChromium(); + +app.listen(PORT, () => { + console.log(`Kiosk API running on port ${PORT}`); +}); diff --git a/kiosk/start.sh b/kiosk/start.sh new file mode 100644 index 0000000..b8a3eb3 --- /dev/null +++ b/kiosk/start.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Wait for Wayland socket to be available +echo "Waiting for Wayland display $WAYLAND_DISPLAY..." +while [ ! -e "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]; do + sleep 1 +done + +echo "Wayland display found. Starting API server..." + +# Start the Node.js API server +exec node server.js