From 87c823b98927905cf3a70096a9f5f9b7b6e83b05 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Fri, 12 Jun 2026 18:12:20 +0400 Subject: [PATCH 01/18] Run Chromium as a Wayland client instead of X11 The block no longer starts its own X server. Chromium runs with --ozone-platform=wayland against the socket that a companion display (compositor) block exposes on a shared /run volume. - Replace the X11 packages and the startx/Xwrapper setup with the Mesa GL/GBM/VA-API drivers - Map the host GIDs of /dev/dri, /dev/video* and /dev/snd/* into the container so the unprivileged chromium user can use the GPU, V4L2 video decode and ALSA - ENABLE_GPU now enables GPU rendering and best-effort hardware video decode; DISABLE_VIDEO_DECODE opts out of decode - Add /diagnostics/{version,gpu,media} endpoints, gated behind ENABLE_DIAGNOSTICS (off by default) - Take /screenshot through the DevTools protocol instead of scrot - Move to debian:13.2-slim and chrome-launcher 1.x Change-type: major Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- .gitignore | 1 + Dockerfile.template | 10 +- balena.yml | 7 +- build/install_chromium | 33 +- package.json | 72 ++--- readme.md | 61 +++- src/server.js | 716 ++++++++++++++++++++++++++++------------- src/start.sh | 93 +++--- 8 files changed, 654 insertions(+), 339 deletions(-) diff --git a/.gitignore b/.gitignore index 0b78e87..65ef416 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ sha256bca9ad158da383a9af76908776cf6cd8b06359a8a1529c4862fd17fd3f2fc7b3 Dockerfile.raspberrypi3 Dockerfile.raspberrypi4-64 +.DS_Store diff --git a/Dockerfile.template b/Dockerfile.template index 0816c5a..bcdedbb 100644 --- a/Dockerfile.template +++ b/Dockerfile.template @@ -1,4 +1,4 @@ -FROM debian:13 +FROM debian:13.2-slim ENV DEBIAN_FRONTEND=noninteractive @@ -52,14 +52,6 @@ RUN useradd chromium -m -s /bin/bash -G root || true && \ COPY ./public-html /home/chromium -# udev rule to set specific permissions -RUN echo 'SUBSYSTEM=="vchiq",GROUP="video",MODE="0660"' > /etc/udev/rules.d/10-vchiq-permissions.rules -RUN usermod -a -G audio,video,tty chromium - -# Set up the audio block. This won't have any effect if the audio block is not being used. -RUN curl -skL https://raw.githubusercontent.com/balena-labs-projects/audio/master/scripts/alsa-bridge/debian-setup.sh| sh -ENV PULSE_SERVER=tcp:audio:4317 - COPY VERSION . # Start app diff --git a/balena.yml b/balena.yml index 8627e68..d49a7a0 100644 --- a/balena.yml +++ b/balena.yml @@ -44,13 +44,12 @@ assets: data: url: 'https://raw.githubusercontent.com/balena-labs-projects/browser/master/logo.png' data: - defaultDeviceType: raspberrypi3 + defaultDeviceType: raspberrypi4-64 supportedDeviceTypes: - raspberrypi4-64 - - fincm3 - - raspberrypi3 - raspberrypi3-64 - raspberrypi400-64 - raspberrypi5 - intel-nuc - - genericx86-64-ext + - generic-amd64 + - generic-aarch64 \ No newline at end of file diff --git a/build/install_chromium b/build/install_chromium index 83459a1..7d3bfe5 100755 --- a/build/install_chromium +++ b/build/install_chromium @@ -12,11 +12,11 @@ BASE_DIR="/usr/src/build" MACHINE_NAME="$1" + # FORCE_RPI_REPO=0 never includes the repo # FORCE_RPI_REPO=1 always includes the repo -# If the machine name starts with "raspberry", or if the machine -# is a balenaFin, include the repo -if [ "${FORCE_RPI_REPO}" != "0" ] && [ "${FORCE_RPI_REPO}" = "1" -o "${MACHINE_NAME:0:9}" = "raspberry" -o "${MACHINE_NAME}" = "fincm3" ] +# If the machine name starts with "raspberry" +if [ "${FORCE_RPI_REPO}" != "0" ] && [ "${FORCE_RPI_REPO}" = "1" -o "${MACHINE_NAME:0:9}" = "raspberry" ] then echo "Enabling raspios repository" @@ -28,21 +28,24 @@ fi # which is not provided by the upstream package ln -sf /usr/bin/chromium /usr/bin/chromium-browser -# mimic previous balenalib "install_packages" behavior: -apt-get update && apt-get install -y --no-install-recommends \ +# Install hardware acceleration libraries and base system libraries +# Notice the inclusion of $PLATFORM_SPECIFIC_PACKAGES at the end of the package list +apt-get update && apt-get install -y --no-install-recommends \ chromium \ chromium-common \ ca-certificates \ curl \ lsb-release \ - mesa-vdpau-drivers \ + mesa-va-drivers \ mesa-vulkan-drivers \ - scrot \ - x11-xserver-utils \ - xserver-xorg-input-evdev \ - xserver-xorg-legacy \ - xserver-xorg-video-fbdev \ - xserver-xorg xinit \ - xinput \ - xterm && \ - rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/* + v4l-utils \ + libgl1-mesa-dri \ + libegl-mesa0 \ + libglx-mesa0 \ + libgbm1 \ + libegl1 \ + libgles2 \ + libgl1 \ + fonts-freefont-ttf \ + fonts-liberation \ + && rm -rf /var/cache/apt/archives/* /var/lib/apt/lists/* \ No newline at end of file diff --git a/package.json b/package.json index eb4d1ed..b90c8bf 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,39 @@ { - "name": "browserV2", - "version": "0.0.1", - "description": "balenaBlock that provides a chromium browser wrapped in some balena genius", - "main": "index.js", - "scripts": { - "test": "exit 0" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/balena-labs-projects/browser.git" - }, + "name": "browserV2", + "version": "0.0.1", + "description": "balenaBlock that provides a chromium browser wrapped in some balena genius", + "main": "index.js", + "scripts": { + "test": "exit 0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/balena-labs-projects/browser.git" + }, "private": true, - "keywords": [ - "balena", - "browser", - "chromium" - ], - "author": "Phil Wilson ", - "license": "Apache-2.0", - "jshintConfig": { - "esversion": 8 - }, - "bugs": { - "url": "https://github.com/balena-labs-projects/browser/issues" - }, - "homepage": "https://github.com/balena-labs-projects/browser#readme", - "dependencies": { - "chrome-launcher": "^0.13.4", - "body-parser": "^1.19.0", - "compression": "^1.7.4", - "dateformat": "^3.0.3", - "debug": "^4.1.1", - "express": "^4.17.1", - "bent": "7.3.10", - "set-interval-async": "^1.0.34" - } + "keywords": [ + "balena", + "browser", + "chromium" + ], + "author": "Phil Wilson ", + "license": "Apache-2.0", + "jshintConfig": { + "esversion": 8 + }, + "bugs": { + "url": "https://github.com/balena-labs-projects/browser/issues" + }, + "homepage": "https://github.com/balena-labs-projects/browser#readme", + "dependencies": { + "bent": "7.3.10", + "body-parser": "^1.19.0", + "chrome-launcher": "^1.2.1", + "chrome-remote-interface": "^0.34.0", + "compression": "^1.7.4", + "dateformat": "^3.0.3", + "debug": "^4.1.1", + "express": "^4.17.1", + "set-interval-async": "^1.0.34" } - +} \ No newline at end of file diff --git a/readme.md b/readme.md index 4012c56..4794b50 100644 --- a/readme.md +++ b/readme.md @@ -87,7 +87,8 @@ The following environment variables allow configuration of the `browser` block: |`ROTATE_DISPLAY`|`normal`, `left`, `right`, `inverted`|`normal`|Rotates the display| |`ROTATE_DELAY`|`n`|`3`|Add an artificial delay (in seconds) before applying display rotation| |`TOUCHSCREEN`|`string`|N\A|Name of Touch Input to rotate| -|`ENABLE_GPU`|`0`, `1`|0|Enables the GPU rendering. Necessary for Pi3B+ to display YouTube videos.
`0` = off, `1` = on| +|`ENABLE_GPU`|`0`, `1`|0|Master hardware-acceleration switch. Enables GPU **rendering** (rasterization, compositing, WebGL/canvas) and, by default, best-effort hardware **video decode**. On Raspberry Pi, decode is handled by the Pi-patched Chromium (verify via `MojoVideoDecoder`/`V4L2VideoDecoder` in `chrome://media-internals`); on x86 it enables the Mesa VA-API path.
`0` = off, `1` = on| +|`DISABLE_VIDEO_DECODE`|`0`, `1`|0|Opt **out** of hardware video decode while keeping GPU rendering on. Use on devices where the decode path misbehaves. No effect unless `ENABLE_GPU=1`.
`0` = decode stays on, `1` = decode off| |`FORCE_VULKAN`|`0`, `1`|undefined|In combination with `ENABLE_GPU`, forces vulkan to either be enabled or disabled in chromium. If undefined or an invalid value, vulkan is only automatically enabled on the Raspberry Pi 5| |`WINDOW_SIZE`|`x,y`|Detected screen resolution|Sets the browser window size, such as `800,600`.
**Note:** Reverse the dimensions if you also rotate the display to `left` or `right` | |`WINDOW_POSITION`|`x,y`|`0,0`|Specifies the browser window position on the screen| @@ -219,8 +220,7 @@ Returns the flags Chromium was started with Returns the version of Chromium that `browser` is running #### **GET** /screenshot -Uses [scrot](https://opensource.com/article/17/11/taking-screen-captures-linux-command-line-scrot) to take a screenshot of the chromium window. -The screenshot will be saved as a temporary file in the container. +Captures the current page through the Chromium DevTools Protocol and returns it as a PNG image. --- @@ -229,15 +229,66 @@ The `browser` block has been tested to work on the following devices: | Device Type | Status | | ------------- | ------------- | -| Raspberry Pi 3b+ | ✔ | | Raspberry Pi 3b+ (64-bit OS) | ✔ | -| balena Fin | ✔ | | Raspberry Pi 4 | ✔ | +| Raspberry Pi 5 | ✔ | | Intel NUC | ✔ | | Generic AMD64 | ✔ | +| Generic AARCH64 | ✔ (software video decode) | + +> **Note:** 32-bit Raspberry Pi OS and the balena Fin (`fincm3`) are no longer targeted. Use the 64-bit (`aarch64`) OS on Raspberry Pi. --- +## Hardware acceleration + +Hardware acceleration is controlled by a single master switch with one optional override: + +- **`ENABLE_GPU=1`** turns on GPU rendering **and** best-effort hardware video decode. For most + kiosks (including video playback) this is the only variable you need. +- **`DISABLE_VIDEO_DECODE=1`** opts out of decode while keeping GPU rendering — for devices where the + decode path misbehaves. + +The prefix tells you the default: `ENABLE_*` is off until you set it; `DISABLE_*` is on until you set +it. + +> **Upgrade note:** `ENABLE_GPU=1` continues to give you hardware video decode, as it always has — +> nothing to change for existing video kiosks. +> +> Hardware **video encode** (e.g. for WebRTC capture) is not currently exposed; it's a candidate for a +> future enhancement. + +What to expect per target (with `ENABLE_GPU=1`): + +- **Raspberry Pi 4 / Pi 400 / Pi 3 (64-bit)** — H.264 hardware decode via the Pi-patched Chromium + (`bcm2835-codec`). Verify in `chrome://media-internals`: the decoder shows as `V4L2VideoDecoder` + with `isHardwareAccelerated: true`. +- **Raspberry Pi 5** — the video block is HEVC-only and the distro Chromium ships without proprietary + codecs, so H.264 falls back to **software decode**. GPU rendering still works. +- **Generic x86_64 (Intel/AMD)** — VA-API decode via `mesa-va-drivers` (already bundled). +- **Generic AARCH64** — software video decode (no guaranteed kernel decoder). + +### Extending the block (advanced VA-API drivers) + +The block bundles `mesa-va-drivers`, which covers AMD and Mesa-based Intel decode. Some stacks need +extra, often non-free, drivers that we deliberately do **not** bundle: + +- **Intel (iHD):** `intel-media-va-driver` (Debian `non-free`). +- **NVIDIA:** `nvidia-vaapi-driver` plus the `VaapiOnNvidiaGPUs` Chromium feature (experimental, + unsupported upstream). + +Add these in a derived image, e.g.: + +```dockerfile +FROM bh.cr//browser-block +# Requires the non-free component enabled in apt sources +RUN apt-get update && apt-get install -y --no-install-recommends intel-media-va-driver vainfo \ + && rm -rf /var/lib/apt/lists/* +``` + +Then verify the driver loads with `vainfo` inside the container, and check +`GET /diagnostics/media` / `chrome://gpu` for a hardware decoder. + ## Troubleshooting This section provides some guidance for common issues encountered: diff --git a/src/server.js b/src/server.js index aef7d00..50e5731 100644 --- a/src/server.js +++ b/src/server.js @@ -1,236 +1,384 @@ #!/bin/env node +/** + * balena Browser Block - Wayland Sidecar Architecture + * This service acts as a thin Wayland client, launching Chromium via chrome-launcher + * and connecting to a separately-deployed display block via a shared Unix socket. + * It exposes an Express API on port 5011 for dynamic configuration at runtime. + */ + const express = require('express'); const bodyParser = require('body-parser'); const chromeLauncher = require('chrome-launcher'); -const bent = require('bent') +const CDP = require('chrome-remote-interface'); +const bent = require('bent'); const { setIntervalAsync, clearIntervalAsync -} = require('set-interval-async/dynamic') -const { spawn } = require('child_process'); -const { readFile, unlink } = require('fs').promises; +} = require('set-interval-async/dynamic'); const path = require('path'); const os = require('os'); -// Bring in the static environment variables +// Static environment variable fallback configurations const API_PORT = parseInt(process.env.API_PORT) || 5011; -const WINDOW_SIZE = process.env.WINDOW_SIZE || "800,600"; -const WINDOW_POSITION = process.env.WINDOW_POSITION || "0,0"; const PERSISTENT_DATA = process.env.PERSISTENT || '0'; const REMOTE_DEBUG_PORT = process.env.REMOTE_DEBUG_PORT || 35173; const FLAGS = process.env.FLAGS || null; const EXTRA_FLAGS = process.env.EXTRA_FLAGS || null; -const HTTPS_REGEX = /^https?:\/\//i //regex for HTTP/S prefix +const HTTPS_REGEX = /^https?:\/\//i; const AUTO_REFRESH = process.env.AUTO_REFRESH || 0; const FORCE_VULKAN = process.env.FORCE_VULKAN || "-1"; +// Diagnostics endpoints expose internal Chromium/GPU state; off by default. +const ENABLE_DIAGNOSTICS = process.env.ENABLE_DIAGNOSTICS || '0'; -// Environment variables which can be overriden from the API +// Dynamic configuration variables that can be altered live via the HTTP API let kioskMode = process.env.KIOSK || '0'; +// Hardware acceleration switches. ENABLE_GPU is the master switch for GPU rendering +// (rasterization/compositing/WebGL); when it is on, best-effort hardware video decode +// rides along automatically (this preserves the historical ENABLE_GPU=1 contract). +// DISABLE_VIDEO_DECODE opts OUT of decode while keeping GPU rendering on. +// (Hardware video encode is a possible future enhancement; not currently exposed.) let enableGpu = process.env.ENABLE_GPU || '0'; +let disableVideoDecode = process.env.DISABLE_VIDEO_DECODE || '0'; let DEFAULT_FLAGS = []; let currentUrl = ''; let flags = []; - -// Refresh timer object let timer = {}; -// Returns the URL to display, adhering to the hieracrchy: -// 1) the configured LAUNCH_URL -// 2) a discovered HTTP service on the device -// 3) the default static HTML +// Universal Wayland and kiosk-oriented behavior flags applied on every platform, +// regardless of acceleration toggles. +const BALENA_BASE_FLAGS = [ + '--autoplay-policy=no-user-gesture-required', // Allow autonomous media playback + '--noerrdialogs', // Suppress error dialogs in production + '--disable-session-crashed-bubble', // Prevent crash recovery UI from breaking kiosk immersion + '--check-for-update-interval=31536000', // Disable update checks + '--disable-dev-shm-usage', // Prevent shared memory exhaustion in Docker + '--touch-events=enabled', // Force capacitive touch layer recognition for DSI displays + '--ozone-platform=wayland' // Render through the Wayland compositor (display block), never X11/XWayland +]; + +/** + * Per-platform GPU overrides. Each descriptor only carries the pieces that differ + * between hardware targets; the common base and the workload toggles in + * composeGpuFlags() decide what actually gets emitted. + * + * - glBackend: ANGLE/GL backend selection for the platform's driver stack. + * - decodeFeatures: Chromium feature tokens to add when hardware video decode is + * requested. Empty where decode is owned by the platform's patched + * Chromium (Raspberry Pi) or unavailable (generic aarch64). + */ +const PLATFORM_PROFILES = { + // Generic x86_64 (Intel / AMD). Hardware decode via Mesa VA-API; the LinuxGL + // features expect the ANGLE-on-GL path. + x86: { + glBackend: ['--use-gl=angle', '--use-angle=gl'], + decodeFeatures: ['AcceleratedVideoDecodeLinuxGL', 'AcceleratedVideoDecodeLinuxZeroCopyGL'] + }, + // Raspberry Pi 5 (VideoCore VII, v3dv). HW video decode is HEVC-only and unavailable + // in the distro Chromium, so H.264 falls back to software regardless of flags. + rpi5: { + glBackend: ['--use-angle=gles'], + decodeFeatures: [] + }, + // Raspberry Pi 3-64 / 4 / 400 (bcm2835-codec). Decode is handled by the Raspberry Pi + // patched Chromium (success shows as MojoVideoDecoder), so we add no decode features + // and must NOT force the upstream --use-v4l2-codec path. + rpiCodec: { + glBackend: ['--use-angle=gles'], + decodeFeatures: [] + }, + // Generic AARCH64 boards lacking guaranteed decoder support. Software video decode. + genericArm64: { + glBackend: ['--use-gl=egl'], + decodeFeatures: [] + } +}; + +/** + * Maps a balena device type/arch onto a PLATFORM_PROFILES key. + */ +function resolvePlatform(deviceType, deviceArch) { + if (deviceType === 'raspberrypi5') { + return 'rpi5'; + } + if (deviceType.startsWith('raspberry')) { + return 'rpiCodec'; + } + if (deviceArch === 'amd64') { + return 'x86'; + } + // aarch64 and any other unrecognized board + return 'genericArm64'; +} + +/** + * Resolves the target URL for Chromium to display based on a strict hierarchy: + * - Explicitly defined LAUNCH_URL environment variable. + * - Discovered local HTTP/S services (e.g., Grafana, Node-RED running on adjacent containers). + * - Fallback to a static local HTML splash page. + */ async function getUrlToDisplayAsync() { let launchUrl = process.env.LAUNCH_URL || null; - if (null !== launchUrl) - { - console.log(`Using LAUNCH_URL: ${launchUrl}`) - - // Prepend http:// if the LAUNCH_URL doesn't have it. - // This is needed for the --app flag to be used for kiosk mode - if (!HTTPS_REGEX.test(launchUrl)) { - launchUrl = `http://${launchUrl}`; - } - - return launchUrl; + + if (null !== launchUrl) { + console.log(`Using LAUNCH_URL: ${launchUrl}`); + // Prepend protocol if missing; required for Chromium's --app flag to parse the URL correctly + if (!HTTPS_REGEX.test(launchUrl)) { + launchUrl = `http://${launchUrl}`; } + return launchUrl; + } - console.log("LAUNCH_URL environment variable not set.") - console.log("Looking for local HTTP/S services.") - - // make a HTTP/S request for each supported port to the localhost - // add the URL to the array if HTTP200 is returned - let ports = [80,443,8080]; - let returnURL = null; - let urls = [] - for await (const port of ports) { - const protocol = 443 === port ? `https` : `http`; - const url = `${protocol}://localhost:${port}`; - try { - const request = bent(url); - const response = await request(); - console.log(`Trying local port ${port}`) - if (200 == response.statusCode) - { - console.log("HTTP/S service found at: " + url) - urls.push(url) - } - } - catch(e) - { - //Nothing to do here, failure is expected when nothing - //is listening on a port - console.log(`No service found on port ${port}`); + console.log("LAUNCH_URL environment variable not set. Looking for local HTTP/S services."); + + let ports = [80, 443, 8080]; + let returnURL = null; + let urls = []; + + // Probe common local ports to automatically surface adjacent container web interfaces + for await (const port of ports) { + const protocol = 443 === port ? `https` : `http`; + const url = `${protocol}://localhost:${port}`; + try { + const request = bent(url); + const response = await request(); + console.log(`Trying local port ${port}`); + if (200 == response.statusCode) { + console.log("HTTP/S service found at: " + url); + urls.push(url); } + } catch(e) { + console.log(`No service found on port ${port}`); } + } + + if (urls.length > 0) { + returnURL = urls[0]; + } else { + console.log("Displaying default HTML page"); + returnURL = "file:///home/chromium/index.html"; + } + + return returnURL; +} - if(urls.length > 0) - { - // return the first URL that returned 200 - returnURL = urls[0]; +/** + * Builds the full Chromium argument list from the common base, the resolved platform + * profile, and the hardware-acceleration switches. ENABLE_GPU is the master switch: + * when on, GPU rendering plus best-effort hardware video decode are applied. Decode can + * be opted out with DISABLE_VIDEO_DECODE. + */ +function composeGpuFlags() { + let composed = DEFAULT_FLAGS.concat(BALENA_BASE_FLAGS); + let enabledFeatures = []; + + if (process.env.ENABLE_VIRTUAL_KEYBOARD === '1') { + enabledFeatures.push('VirtualKeyboard'); + } + + // Decode rides along with GPU rendering by default (see below), so "I want decode" is + // simply ENABLE_GPU=1. + const gpuEnabled = enableGpu === '1'; + + if (!gpuEnabled) { + console.log("GPU rendering disabled. Engaging software rasterization."); + // CPU-driven software rasterization pipeline compatible with Wayland/Weston allocation + composed.push('--use-gl=swiftshader'); + } else { + console.log("GPU rendering enabled."); + // Baseline Wayland GPU rendering parameters + composed.push( + '--ignore-gpu-blocklist', // Override default driver blocklists for embedded Mesa drivers + '--enable-gpu-rasterization' // Offload UI and canvas rendering to the GPU + ); + + const deviceType = process.env.BALENA_DEVICE_TYPE || ''; + const deviceArch = process.env.BALENA_DEVICE_ARCH || ''; + const profile = PLATFORM_PROFILES[resolvePlatform(deviceType, deviceArch)]; + + composed = composed.concat(profile.glBackend); + + // Best-effort hardware video decode is enabled by default whenever the GPU is on. + // Operators can opt out with DISABLE_VIDEO_DECODE=1 on devices where the decode + // path misbehaves (rendering stays hardware-accelerated). + if (disableVideoDecode === '1') { + // Force software video decode while keeping GPU rendering on + console.log("Hardware video decode disabled by DISABLE_VIDEO_DECODE."); + composed.push('--disable-accelerated-video-decode'); + } else { + // On Raspberry Pi this list is empty: decode is owned by the patched Chromium + // (verify via V4L2VideoDecoder), so we deliberately do not force a decoder path. + enabledFeatures = enabledFeatures.concat(profile.decodeFeatures); } - // Otherwise send the default HTML - else - { - console.log("Displaying default HTML page"); - returnURL = "file:///home/chromium/index.html"; + + // Vulkan is only auto-enabled on the Raspberry Pi 5; FORCE_VULKAN can override. + // NOTE: '--ozone-platform=wayland' has been observed to be incompatible with Vulkan + // on Pi 4 — validate on Pi 5 hardware. + if (deviceType === 'raspberrypi5' + ? (FORCE_VULKAN === '1' || FORCE_VULKAN !== '0') + : (FORCE_VULKAN === '1')) { + enabledFeatures.push('Vulkan'); } + } - return returnURL; + // Only emit --enable-features when we actually have features to enable + if (enabledFeatures.length > 0) { + composed.push(`--enable-features=${enabledFeatures.join(',')}`); } - -// Launch the browser with the URL specified + + return composed; +} + +/** + * Composes hardware-specific flags and spawns Chromium using wayland. + * Manages the destruction of previous instances before launching a new session. + */ let launchChromium = async function(url) { - await chromeLauncher.killAll(); + await chromeLauncher.killAll(); + + flags = []; + if (null !== FLAGS) { + // Override completely if the user provides an explicit global FLAGS variable + flags = FLAGS.split(' '); + } else { + flags = composeGpuFlags(); + } - flags = []; - // If the user has set the flags, use them - if (null !== FLAGS) - { - flags = FLAGS.split(' '); - } - else - { - // User the default flags from chrome-launcher, plus our own. - flags = DEFAULT_FLAGS; - let balenaFlags = [ - '--window-size=' + WINDOW_SIZE, - '--window-position=' + WINDOW_POSITION, - '--autoplay-policy=no-user-gesture-required', - '--noerrdialogs', - '--disable-session-crashed-bubble', - '--check-for-update-interval=31536000', - '--disable-dev-shm-usage', // TODO: work out if we can enable this for devices with >1Gb of memory - ]; - - // Merge the chromium default and balena default flags - flags = flags.concat(balenaFlags); - - // either disable the gpu or set some flags to enable it - if (enableGpu != '1') - { - console.log("Disabling GPU"); - flags.push('--disable-gpu'); - } - else - { - console.log("Enabling GPU"); - let gpuFlags = [ - '--enable-zero-copy', - '--num-raster-threads=4', - '--ignore-gpu-blocklist', - '--enable-gpu-rasterization', - ]; - - // Enable vulkan - // This only seems to make a difference on the Raspberry Pi 5, - // it enables HW accelerated video decoding at the cost of some OpenGL performance - // * If FORCE_VULKAN is 0, never enable vulkan - // * If FORCE_VULKAN is 1, always enable vulkan - // * If FORCE_VULKAN is anything else or undefined, enable vulkan if the device is a RPi5 - if (FORCE_VULKAN === "1" || (process.env.BALENA_DEVICE_TYPE === "raspberrypi5" && FORCE_VULKAN !== "0")) - { - gpuFlags.push('--enable-features=Vulkan'); - } - - flags = flags.concat(gpuFlags); - } - } + // Append any additive custom flags provided by the operator + if (EXTRA_FLAGS) { + flags = flags.concat(EXTRA_FLAGS.split(' ')); + } - if (EXTRA_FLAGS) { - flags = flags.concat(EXTRA_FLAGS.split(' ')); - } + let startingUrl = url; + if ('1' === kioskMode) { + console.log("Enabling KIOSK mode"); + // The --app flag strips the URL bar and browser frame UI + startingUrl = `--app=${url}`; + } else { + console.log("Disabling KIOSK mode"); + } - let startingUrl = url; - if ('1' === kioskMode) - { - console.log("Enabling KIOSK mode"); - startingUrl = `--app= ${url}`; - } - else - { - console.log("Disabling KIOSK mode"); + console.log(`Starting Chromium with flags: ${flags.join(' ')}`); + console.log(`Displaying URL: ${startingUrl}`); + + // Launching the client. Note that Chromium's internal sandbox is retained for security. + const chrome = await chromeLauncher.launch({ + startingUrl: startingUrl, + ignoreDefaultFlags: true, + chromeFlags: flags, + port: REMOTE_DEBUG_PORT, + connectionPollInterval: 1000, + maxConnectionRetries: 120, + userDataDir: '1' === PERSISTENT_DATA ? '/data/chromium' : undefined + }); + + console.log(`Chromium remote debugging tools running on port: ${chrome.port}`); + currentUrl = url; +} + +/** + * Executes a page reload via the Chrome DevTools Protocol. + * Falls back to a hard process relaunch if the debugger socket is unavailable. + */ +async function refreshPageCDP() { + let client; + try { + client = await CDP({ port: REMOTE_DEBUG_PORT }); + const { Page } = client; + await Page.enable(); + await Page.reload(); + console.log('CDP page refresh executed.'); + } catch (err) { + console.log('CDP connection failed during refresh, falling back to full relaunch.', err.toString()); + await launchChromium(currentUrl); + } finally { + if (client) { + await client.close(); } + } +} - console.log(`Starting Chromium with flags: ${flags}`); - console.log(`Displaying URL: ${startingUrl}`); - - const chrome = await chromeLauncher.launch({ - startingUrl: startingUrl, - ignoreDefaultFlags: true, - chromeFlags: flags, - port: REMOTE_DEBUG_PORT, - connectionPollInterval: 1000, - maxConnectionRetries: 120, - userDataDir: '1' === PERSISTENT_DATA ? '/data/chromium' : undefined - }); - - console.log(`Chromium remote debugging tools running on port: ${chrome.port}`); +/** + * Executes a seamless URL navigation via the Chrome DevTools Protocol. + * Prevents unnecessary tearing and process destruction when only the target URL changes. + */ +async function navigatePageCDP(url) { + let client; + try { + client = await CDP({ port: REMOTE_DEBUG_PORT }); + const { Page } = client; + await Page.enable(); + await Page.navigate({ url: url }); currentUrl = url; + console.log(`CDP navigation to: ${url}`); + } catch (err) { + console.log('CDP connection failed during navigation, falling back to full relaunch.', err.toString()); + await launchChromium(url); + } finally { + if (client) { + await client.close(); + } + } } -// Get's the chrome-launcher default flags, minus the extensions and audio muting flags. +/** + * Retrieves chrome-launcher's internal default flags, filtering out + * developer extensions and audio muting to ensure standard kiosk functionality. + */ async function SetDefaultFlags() { - DEFAULT_FLAGS = await chromeLauncher.Launcher.defaultFlags().filter(flag => '--disable-extensions' !== flag && '--mute-audio' !== flag); + DEFAULT_FLAGS = await chromeLauncher.Launcher.defaultFlags().filter( + flag => '--disable-extensions' !== flag && '--mute-audio' !== flag + ); } +/** + * Initializes the asynchronous interval timer for automated page refreshes. + */ async function setTimer(interval) { console.log("Auto refresh interval: ", interval); timer = setIntervalAsync( async () => { try { - await launchChromium(currentUrl); + await refreshPageCDP(); } catch (err) { console.log("Timer error: ", err); process.exit(1); } }, interval - ) - + ); } +/** + * Halts the currently active automated refresh timer loop. + */ async function clearTimer(){ await clearIntervalAsync(timer); } +/** + * Primary execution entrypoint loop initialization. + */ async function main(){ await SetDefaultFlags(); let url = await getUrlToDisplayAsync(); await launchChromium(url); - if (AUTO_REFRESH > 0) - { + if (AUTO_REFRESH > 0) { await setTimer(AUTO_REFRESH * 1000); } } - main().catch(err => { console.log("Main error: ", err); process.exit(1); }); -// Start the API +// ============================================================================ +// Express HTTP API Routes +// Exposes endpoints for managing the Chromium Wayland client state dynamically +// ============================================================================ + const app = express(); const errorHandler = (err, req, res, next) => { @@ -241,9 +389,7 @@ const errorHandler = (err, req, res, next) => { }; app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ - extended: true -})); +app.use(bodyParser.urlencoded({ extended: true })); app.use(function(req, res, next) { res.header("Access-Control-Allow-Origin", "*"); res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); @@ -252,147 +398,134 @@ app.use(function(req, res, next) { }); app.use(errorHandler); -// ping endpoint +// Readiness probe for supervisor health checks app.get('/ping', (req, res) => { - - return res.status(200).send('ok'); + return res.status(200).send('ok'); }); -// url set endpoint -app.post('/url', (req, res) => { +// Update target URL and adjust kiosk/gpu modifiers +app.post('/url', async (req, res) => { if (!req.body.url) { return res.status(400).send('Bad request: missing URL in the body element'); } let url = req.body.url; - - // prepend http prefix if necessary for kiosk mode to work if (!HTTPS_REGEX.test(url)) { url = 'http://' + url; } - if (req.body.kiosk) { + let requiresRelaunch = false; + + if (req.body.kiosk && req.body.kiosk !== kioskMode) { kioskMode = req.body.kiosk; + requiresRelaunch = true; } - if (req.body.gpu) { + if (req.body.gpu && req.body.gpu !== enableGpu) { enableGpu = req.body.gpu; + requiresRelaunch = true; } - - launchChromium(url); + + if (requiresRelaunch) { + await launchChromium(url); + } else { + await navigatePageCDP(url); + } + return res.status(200).send('ok'); }); -// url get endpoint +// Retrieve the currently rendered URL app.get('/url', (req, res) => { - return res.status(200).send(currentUrl); }); -// refresh endpoint -app.post('/refresh', (req, res) => { - - launchChromium(currentUrl); +// Force an immediate reload of the active page +app.post('/refresh', async (req, res) => { + await refreshPageCDP(); return res.status(200).send('ok'); }); -// gpu set endpoint -app.post('/gpu/:gpu', (req, res) => { - if (!req.params.gpu) { - return res.status(400).send('Bad Request'); - } - - if('1' !== req.params.gpu && '0' !== req.params.gpu) - { - return res.status(400).send('Bad Request'); - } - +// Toggle hardware acceleration capabilities and regenerate runtime flags +app.post('/gpu/:gpu', async (req, res) => { + if (req.params.gpu !== '1' && req.params.gpu !== '0') return res.status(400).send('Invalid parameter'); enableGpu = req.params.gpu; - launchChromium(currentUrl); + await launchChromium(currentUrl); return res.status(200).send('ok'); }); -// gpu get endpoint + +// Read the state of active hardware acceleration app.get('/gpu', (req, res) => { - return res.status(200).send(enableGpu.toString()); }); -// kiosk set endpoint -app.post('/kiosk/:kiosk', (req, res) => { +// Toggle application UI masking limits and refresh runtime +app.post('/kiosk/:kiosk', async (req, res) => { if (!req.params.kiosk) { return res.status(400).send('Bad Request'); } - kioskMode = req.params.kiosk; - launchChromium(currentUrl); + await launchChromium(currentUrl); return res.status(200).send('ok'); }); +// Configure or disable the automated asynchronous refresh window interval app.post('/autorefresh/:interval', async(req, res) => { if (!req.params.interval) { return res.status(400).send('Bad Request'); } - if(req.params.interval < 1) - { + if (req.params.interval < 1) { await clearTimer(); - } - else - { - await setTimer((req.params.interval * 1000)) + } else { + await setTimer((req.params.interval * 1000)); } return res.status(200).send('ok'); }); -// flags endpoint +// Expose full runtime arguments sent to the browser interface for remote inspection app.get('/flags', (req, res) => { - return res.status(200).send(flags.toString()); }); -// kiosk get endpoint +// Read the current kiosk mode enforcement value app.get('/kiosk', (req, res) => { - return res.status(200).send(kioskMode.toString()); }); -// version get endpoint +// Read local version environment parameters app.get('/version', (req, res) => { - let version = process.env.VERSION || "Version not set"; return res.status(200).send(version.toString()); }); -app.get('/screenshot', async(req, res) => { - const fileName = process.hrtime.bigint() + '.png'; - const filePath = path.join(os.tmpdir(), fileName); +// Generates an on-screen capture via Chromium DevTools Protocol (replaces legacy scrot) +app.get('/screenshot', async (req, res) => { + let client; try { - const child = spawn('scrot', [filePath]); + client = await CDP({ port: REMOTE_DEBUG_PORT }); + const { Page } = client; - const statusCode = await new Promise( (res, rej) => { child.on('close', res); } ); - if (statusCode != 0) { - return res.status(500).send("Screenshot command exited with non-zero return code."); - } + await Page.enable(); + const { data } = await Page.captureScreenshot({ format: 'png' }); - const fileContents = await readFile(filePath); + const imageBuffer = Buffer.from(data, 'base64'); + res.set('Content-Type', 'image/png'); - return res.status(200).send(fileContents); - } catch(e) { - console.log(e.toString()); - return res.status(500).send("Error occurred in screenshot code."); + return res.status(200).send(imageBuffer); + } catch (err) { + console.log("Error occurred when taking screenshot: ", err.toString()); + return res.status(500).send("Error generating native Chromium screenshot."); } finally { - try { - await unlink(filePath); - } catch (e) { - console.log(e) + if (client) { + await client.close(); } } }); -// scan endpoint - causes the device to rescan for local HTTP services +// Triggers an immediate recalculation and discovery probe loop for neighboring HTTP containers app.post('/scan', (req, res) => { - main().catch(err => { console.log("Scan error: ", err); process.exit(1); @@ -404,6 +537,143 @@ app.listen(API_PORT, () => { console.log('Browser API running on port: ' + API_PORT); }); +// ============================================================================ +// Diagnostic Endpoints +// Extracts internal Chromium state directly via Chrome DevTools Protocol. +// Gated behind ENABLE_DIAGNOSTICS (default off) since they expose system info. +// ============================================================================ + +// Refuse diagnostics requests unless explicitly enabled. +function diagnosticsGuard(req, res, next) { + if (ENABLE_DIAGNOSTICS !== '1') { + return res.status(404).send('Diagnostics are disabled. Set ENABLE_DIAGNOSTICS=1 to enable them.'); + } + next(); +} + +// Reports the running Chromium build/version and the block version +app.get('/diagnostics/version', diagnosticsGuard, async (req, res) => { + try { + const info = await CDP.Version({ port: REMOTE_DEBUG_PORT }); + return res.status(200).json({ + browser: info['Browser'], // e.g. "Chrome/148.0.7778.167" + protocolVersion: info['Protocol-Version'], + v8Version: info['V8-Version'], + webkitVersion: info['WebKit-Version'], + userAgent: info['User-Agent'], + blockVersion: process.env.VERSION || null + }); + } catch (err) { + console.log("Error retrieving Chromium version: ", err.toString()); + return res.status(500).send("Failed to retrieve Chromium version."); + } +}); + +// Extracts full GPU diagnostic data silently without creating window surfaces +app.get('/diagnostics/gpu', diagnosticsGuard, async (req, res) => { + let client; + try { + // 1. Interrogate the debugging metadata endpoint to retrieve the root browser socket + const browserVersion = await CDP.Version({ port: REMOTE_DEBUG_PORT }); + + // 2. Connect directly to the master browser session WebSocket URL + client = await CDP({ target: browserVersion.webSocketDebuggerUrl }); + + // 3. Execute the native SystemInfo protocol command + if (client.SystemInfo && typeof client.SystemInfo.getInfo === 'function') { + const infoPayload = await client.SystemInfo.getInfo(); + + // infoPayload.gpu contains standard devices, featureStatus, and driverBugWorkarounds + return res.status(200).json(infoPayload.gpu); + } else { + return res.status(500).send("SystemInfo protocol domain not exposed by this Chromium binary."); + } + } catch (err) { + console.log("Error during silent SystemInfo extraction: ", err.toString()); + return res.status(500).send("Failed to extract GPU diagnostic payload silently."); + } finally { + // 4. Ensure the control socket is gracefully detached + if (client) { + await client.close(); + } + } +}); + +// Extracts active media player states subtly using the CDP Media domain +app.get('/diagnostics/media', diagnosticsGuard, async (req, res) => { + let client; + try { + // 1. Fetch the target registry to isolate the primary user-visible page + const targets = await CDP.List({ port: REMOTE_DEBUG_PORT }); + const pageTarget = targets.find(t => t.type === 'page'); + + if (!pageTarget) { + return res.status(404).send("No active kiosk or page target found to inspect."); + } + + // 2. Intercept the existing tab socket (zero compositor/window disruption) + client = await CDP({ target: pageTarget }); + const { Media } = client; + + const activePlayers = {}; + + // 3. Trap properties as Chromium flushes them down the WebSocket connection + Media.playerPropertiesChanged((params) => { + const { playerId, properties } = params; + if (!activePlayers[playerId]) { + activePlayers[playerId] = {}; + } + + properties.forEach(prop => { + activePlayers[playerId][prop.name] = prop.value; + }); + }); + + // 4. Force state synchronization from Chromium's internal media logger + await Media.enable(); + + // 5. Provide a brief period for the socket events to settle + await new Promise(resolve => setTimeout(resolve, 400)); + + // 6. Map and normalize the player parameters to verify acceleration status + const diagnosticReport = Object.keys(activePlayers).map(id => { + const playerData = activePlayers[id]; + const decoderName = playerData.video_decoder || playerData.kVideoDecoderName || "Unknown"; + + // Determine if hardware-accelerated based on runtime driver name or metadata + const isHardware = playerData.is_platform_video_decoder === "true" || + playerData.is_platform_video_decoder === true || + /v4l2|vaapi|mojo|d3d11/i.test(decoderName); + + return { + playerId: id, + videoCodec: playerData.video_codec_name || "Unknown", + decoderName: decoderName, + isHardwareAccelerated: isHardware, + resolution: `${playerData.video_width || 0}x${playerData.video_height || 0}`, + rawProperties: playerData + }; + }); + + return res.status(200).json({ + activePlaybackCount: diagnosticReport.length, + players: diagnosticReport + }); + + } catch (err) { + console.log("Error executing silent media diagnostics: ", err.toString()); + return res.status(500).send("Failed to extract active media decoder states subtly."); + } finally { + // 7. Ensure control socket detachment + if (client) { + await client.close(); + } + } +}); + + + +// Graceful cleanup handling on termination signal catch blocks process.on('SIGINT', () => { process.exit(); -}); +}); \ No newline at end of file diff --git a/src/start.sh b/src/start.sh index 219c470..da3a1e6 100644 --- a/src/start.sh +++ b/src/start.sh @@ -1,62 +1,41 @@ #!/usr/bin/env bash -# this allows chromium sandbox to run, see https://github.com/balena-os/meta-balena/issues/2319 -sysctl -w user.max_user_namespaces=10000 +set -e -# Run balena base image entrypoint script -/usr/src/app/entry.sh echo "Running balena base image entrypoint..." +# Enable user namespaces for Chromium's internal sandbox architecture +sysctl -w user.max_user_namespaces=10000 || true export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/host/run/dbus/system_bus_socket -sed -i -e 's/console/anybody/g' /etc/X11/Xwrapper.config -echo "needs_root_rights=yes" >> /etc/X11/Xwrapper.config -dpkg-reconfigure xserver-xorg-legacy - echo "balenaLabs browser version: $( /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor - -# check if display number envar was set -if [[ -z "$DISPLAY_NUM" ]] - then - export DISPLAY_NUM=0 +if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then + echo 'performance' > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor || true fi -# set whether to show a cursor or not -if [[ -n $SHOW_CURSOR ]] && [[ "$SHOW_CURSOR" -eq "1" ]] - then - export CURSOR='' - echo "Enabling cursor" - else - export CURSOR='-- -nocursor' - echo "Disabling cursor" -fi +# Map Wayland Socket Parameters +export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-"/run/user/0"} +export WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-"wayland-0"} +WAYLAND_SOCKET="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" -# If the vcgencmd is supported (i.e. RPi device) - check enough GPU memory is allocated -if command -v vcgencmd &> /dev/null -then - echo "Checking GPU memory" - if [ "$(vcgencmd get_mem gpu | grep -o '[0-9]\+')" -lt 128 ] - then - echo -e "\033[91mWARNING: GPU MEMORY TOO LOW" - fi -fi +echo "Targeting Wayland compositor socket: ${WAYLAND_SOCKET}" -# Inject X11 config on the RPi 5 as the defaults do not work -# We do this in the startup script and only for the RPi 5 because -# we build the images per-architecture and we do not want to break -# other aarch64-based device types -if [ "${BALENA_DEVICE_TYPE}" = "raspberrypi5" ] -then - echo "Raspberry Pi 5 detected, injecting X.org config" - cp -a "/usr/src/build/rpi/99-vc4.conf" "/etc/X11/xorg.conf.d/" -fi +# Block until the Display Sidecar container mounts the socket +echo "Waiting for display server to expose the Wayland socket..." +until [ -S "${WAYLAND_SOCKET}" ]; do + sleep 1 +done +echo "Wayland socket detected! Establishing connection permissions." + +# Apply permissive permissions so the unprivileged 'chromium' user can read/write to the socket +chmod 777 "${XDG_RUNTIME_DIR}" || true +chmod 666 "${WAYLAND_SOCKET}" || true -# set up the user data area +# Initialize user-data storage context mkdir -p /data/chromium -chown -R chromium:chromium /data +chown -R chromium:chromium /data || true rm -f /data/chromium/SingletonLock # we can't maintain the environment with su, because we are logging in to a new session @@ -66,7 +45,27 @@ environment=$(env | grep -v -w '_' | awk -F= '{ st = index($0,"=");print substr( # remove the last comma environment="${environment::-1}" -# launch Chromium and whitelist the enVars so that they pass through to the su session -su -w "$environment" -c "export DISPLAY=:$DISPLAY_NUM && startx /usr/src/app/startx.sh $CURSOR" - chromium -sleep infinity +# Grant the unprivileged 'chromium' user access to the GPU, video-decode and +# sound device nodes. These nodes (/dev/dri/card*, /dev/dri/render*, /dev/video*, +# /dev/snd/*) are group-owned by the host's 'video'/'render'/'audio' GIDs with +# mode 0660, so uid 1000 cannot open them by default. Without this, hardware GL +# falls back to the one world-readable render node, V4L2 hardware video decode +# fails silently (Chromium drops to the software FFmpegVideoDecoder), and ALSA +# audio output (e.g. HDMI) is silent. We map each node's owning GID into the +# container and add 'chromium' to the matching group before su. +for dev in /dev/dri/card* /dev/dri/render* /dev/video* /dev/snd/*; do + [ -e "$dev" ] || continue + node_gid=$(stat -c '%g' "$dev") + node_grp=$(getent group "$node_gid" | cut -d: -f1) + if [ -z "$node_grp" ]; then + node_grp="hwaccel_${node_gid}" + groupadd -g "$node_gid" "$node_grp" || true + fi + usermod -aG "$node_grp" chromium || true + echo "Granted chromium access to ${dev} (gid ${node_gid}, group ${node_grp})" +done + +# Launch the Node Management Service as the non-root 'chromium' user +echo "Starting Node.js server session..." +exec su -w "$environment" chromium -c "node /usr/src/app/server.js" \ No newline at end of file From 804aa7c80b79700a738afec3c1cba096d936ec05 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Mon, 15 Jun 2026 19:33:03 +0400 Subject: [PATCH 02/18] docs: Rework readme for v3 and add a v2 to v3 migration guide Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/migrating-from-v2.md | 91 ++++++++++++++++++++++++ readme.md | 143 ++++++++++++++++++++------------------ 2 files changed, 166 insertions(+), 68 deletions(-) create mode 100644 docs/migrating-from-v2.md diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md new file mode 100644 index 0000000..40c9408 --- /dev/null +++ b/docs/migrating-from-v2.md @@ -0,0 +1,91 @@ +# Migrating from v2 to v3 + +v3 is a significant change to how the `browser` block runs. This guide covers what changed and what +you need to update. If you are starting fresh, you can ignore this document and follow the +[readme](../readme.md). + +## 1. Wayland instead of X11 — the browser now needs a companion display block + +In v2 the block ran its own X11 server and drew directly to the display. In v3 the block is a thin +**Wayland client**: it no longer starts a display server itself. You now run a separate +**display (compositor) block** alongside it, and the two share the Wayland socket through a volume. + +At minimum your compose file must: + +- add the display block as a service and `depends_on` it from `browser`; +- share a volume mounted at `/run` between the two so the browser can reach the Wayland socket; +- set `XDG_RUNTIME_DIR` and `WAYLAND_DISPLAY` on the browser service. + +```yaml +volumes: + display-socket: + +services: + display: + image: bh.cr/balenalabs/display- # companion compositor block; see its README for the image name + privileged: true + volumes: + - display-socket:/run + labels: + io.balena.features.dbus: '1' + + browser: + image: bh.cr/balenalabs/browser- + privileged: true + depends_on: + - display + environment: + XDG_RUNTIME_DIR: /run/user/0 + WAYLAND_DISPLAY: wayland-0 + devices: + - /dev/dri:/dev/dri + ports: + - '5011:5011' + volumes: + - display-socket:/run +``` + +## 2. New image namespace: per device type, not per architecture + +v2 published one image per architecture (`browser-aarch64`, `browser-amd64`, `browser-arm32`). v3 +publishes one image **per device type**: + +| v2 image | v3 image | +| --- | --- | +| `browser-aarch64` (Raspberry Pi 3) | `browser-raspberrypi3-64` | +| `browser-aarch64` (Raspberry Pi 4 / 400) | `browser-raspberrypi4-64` | +| `browser-aarch64` (Raspberry Pi 5) | `browser-raspberrypi5` | +| `browser-aarch64` (other 64-bit Arm) | `browser-generic-aarch64` | +| `browser-amd64` | `browser-generic-amd64` | + +The reason is that the browser is a client whose build differs per target — most notably the +Raspberry Pi images use a Chromium built from the Raspberry Pi sources to get hardware video decode. +Update your `docker-compose.yml` to reference the image for your device type. + +> 32-bit Raspberry Pi OS, the `arm32` images and the balena Fin (`fincm3`) are no longer built. Use +> the 64-bit (`aarch64`) OS on Raspberry Pi. + +## 3. Hardware acceleration toggles + +`ENABLE_GPU=1` still turns on GPU rendering **and** best-effort hardware video decode, so existing +video kiosks need no change. New in v3: + +- **`DISABLE_VIDEO_DECODE=1`** — opt out of hardware video decode while keeping GPU rendering, for + devices where the decode path misbehaves. +- Hardware **video encode** is not supported (see + [#168](https://github.com/balena-io-experimental/browser/issues/168)). + +See [Hardware acceleration](../readme.md#hardware-acceleration) for what to expect per device. + +## 4. Custom `FLAGS` / `EXTRA_FLAGS` + +If you overrode `FLAGS` or `EXTRA_FLAGS` in v2, review them. v3 renders through Wayland +(`--ozone-platform=wayland`), so X11-specific switches no longer apply, and the GPU/decode flags are +now chosen per device type by the block. Prefer the `ENABLE_GPU` / `DISABLE_VIDEO_DECODE` toggles over +hand-rolled flags where possible. + +## 5. Diagnostics + +The `/diagnostics/*` endpoints (Chromium version, GPU state, media-decoder state) are new in v3 and +are **off by default**. Set `ENABLE_DIAGNOSTICS=1` to enable them. See +[Diagnostics](../readme.md#diagnostics). diff --git a/readme.md b/readme.md index 4794b50..3be206b 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,11 @@ # balena-labs-projects/browser Provides a hardware accelerated web browser to present internal and external URLs on a connected display. -The `browser` block is a docker image that runs a [Chromium](https://www.chromium.org/Home) browser via X11, optimized for balenaOS. -The block provides an API for dynamic configuration, and also exposes the Chromium Remote Debug port. +The `browser` block is a docker image that runs a [Chromium](https://www.chromium.org/Home) browser as a [Wayland](https://wayland.freedesktop.org/) client, optimized for balenaOS. +It renders through a companion **display** (compositor) block, and provides an API for dynamic configuration. + +> **Upgrading from v2?** v3 moves from X11 to Wayland and changes the image namespace. See the +> [v2 → v3 migration guide](docs/migrating-from-v2.md). --- ## Features @@ -18,59 +21,56 @@ The block provides an API for dynamic configuration, and also exposes the Chromi ## Usage +The `browser` block renders through a companion **display** (compositor) block. Run both services, +share a volume mounted at `/run` so the browser can reach the Wayland socket, and reference the +`browser` image for your **device type** (one of `raspberrypi3-64`, `raspberrypi4-64`, +`raspberrypi5`, `generic-aarch64`, `generic-amd64`). + #### docker-compose file -To use this image, create a container in your `docker-compose.yml` file as shown below: +To use this image, create your `docker-compose.yml` file as shown below: ```yaml -version: '2' +version: '2.4' volumes: + display-socket: # Shared Wayland runtime directory settings: # Only required if using PERSISTENT flag (see below) services: - browser: - image: bh.cr/balenalabs/browser- # where is one of aarch64, arm32 or amd64 - privileged: true # required for UDEV to find plugged in peripherals such as a USB mouse - ports: - - '5011' # management API (optional) - - '35173' # Chromium debugging port (optional) + display: + image: bh.cr/balenalabs/display- # companion compositor block; see its README for the image name + privileged: true volumes: - - 'settings:/data' # Only required if using PERSISTENT flag (see below) -``` - -To pin to a specific [version](CHANGELOG.md) of this block use: + - display-socket:/run + labels: + io.balena.features.dbus: '1' -```yaml -services: browser: - image: bh.cr/balenalabs/browser-/ + image: bh.cr/balenalabs/browser- # e.g. raspberrypi4-64, raspberrypi5, generic-amd64 privileged: true # required for UDEV to find plugged in peripherals such as a USB mouse + depends_on: + - display + environment: + XDG_RUNTIME_DIR: /run/user/0 + WAYLAND_DISPLAY: wayland-0 + devices: + - /dev/dri:/dev/dri ports: - - '5011' # management API (optional) - - '35173' # Chromium debugging port (optional) + - '5011:5011' # management API volumes: + - display-socket:/run - 'settings:/data' # Only required if using PERSISTENT flag (see below) ``` -See [here](https://github.com/balena-io/open-balena-registry-proxy#usage) for more details about how to use blocks hosted in balenaCloud. - ---- - -## Customization -### Extend image configuration - -By default the `browser` block uses the first local display (i.e. `DISPLAY=:0`) which would typically be a connected monitor, TV or a Pi Display. However for custom configurations you can overload the `CMD` directive, as such: +To pin to a specific [version](CHANGELOG.md) of this block, append the version to the image, e.g. +`bh.cr/balenalabs/browser-/`. -*dockerfile.template* -```Dockerfile -FROM bh.cr/balenalabs/browser-%%BALENA_ARCH%% +See [here](https://github.com/balena-io/open-balena-registry-proxy#usage) for more details about how to use blocks hosted in balenaCloud. -CMD ["export DISPLAY=:1"] -``` --- -### Environment variables +## Environment variables The following environment variables allow configuration of the `browser` block: @@ -95,6 +95,7 @@ The following environment variables allow configuration of the `browser` block: |`API_PORT`|port number|5011|Specifies the port number the API runs on| |`REMOTE_DEBUG_PORT`|port number|35173|Specifies the port number the chrome remote debugger runs on| |`AUTO_REFRESH`|interval|0 (disabled)|Specifies the number of seconds before the page automatically refreshes| +|`ENABLE_DIAGNOSTICS`|`0`, `1`|`0`|Enables the `/diagnostics/*` API endpoints, which expose Chromium version, GPU and media-decoder state. Off by default.
`0` = off, `1` = on| --- @@ -109,7 +110,7 @@ volumes: services: browser: restart: always - image: bh.cr/balenalabs/browser- + image: bh.cr/balenalabs/browser- privileged: true volumes: - 'settings:/data' @@ -129,7 +130,7 @@ In this example we add the `audio` block and route the `browser` audio to the Ra ```yaml services: browser: - image: bh.cr/balenalabs/browser- + image: bh.cr/balenalabs/browser- audio: image: bh.cr/balenalabs/audio- privileged: true @@ -222,21 +223,48 @@ Returns the version of Chromium that `browser` is running #### **GET** /screenshot Captures the current page through the Chromium DevTools Protocol and returns it as a PNG image. +### Diagnostics + +The following endpoints expose internal Chromium state for troubleshooting hardware acceleration. +They are **disabled by default**; set `ENABLE_DIAGNOSTICS=1` to enable them. When disabled they +return `404`. + +#### **GET** /diagnostics/version +Returns the running Chromium build/version (and the block version) as JSON. + +#### **GET** /diagnostics/gpu +Returns Chromium's GPU feature status, drivers and active backend as JSON (the same data as +`chrome://gpu`). + +#### **GET** /diagnostics/media +Returns the decoder used by any active media player, including whether it is hardware-accelerated — +useful for confirming hardware video decode (e.g. `V4L2VideoDecoder`). + --- ## Supported devices -The `browser` block has been tested to work on the following devices: -| Device Type | Status | -| ------------- | ------------- | -| Raspberry Pi 3b+ (64-bit OS) | ✔ | -| Raspberry Pi 4 | ✔ | -| Raspberry Pi 5 | ✔ | -| Intel NUC | ✔ | -| Generic AMD64 | ✔ | -| Generic AARCH64 | ✔ (software video decode) | +The block builds for two architectures (`aarch64`, `amd64`) and bundles the Mesa +GPU/VA-API drivers, so it will *run* on a wide range of hardware. We distinguish two levels of +support: + +**Tested** — exercised on real hardware, including hardware video decode where applicable: + +| Device Type | Notes | +| --- | --- | +| Raspberry Pi 3 (64-bit OS) | H.264 hardware decode | +| Raspberry Pi 4 / Pi 400 | H.264 hardware decode | +| Raspberry Pi 5 | GPU rendering; H.264 falls back to software decode | +| Intel NUC | VA-API hardware decode (Mesa) | +| Generic AMD64 | VA-API hardware decode (Mesa) | +| Generic AARCH64 | GPU rendering; software video decode | -> **Note:** 32-bit Raspberry Pi OS and the balena Fin (`fincm3`) are no longer targeted. Use the 64-bit (`aarch64`) OS on Raspberry Pi. +**Technically supported** — other devices of the same architecture should boot and render, but we +haven't validated them and hardware video decode is not guaranteed (it depends on the device's +kernel drivers). Use the generic `aarch64`/`amd64` images. + +> **Note:** 32-bit Raspberry Pi OS and the balena Fin (`fincm3`) are no longer targeted. Use the +> 64-bit (`aarch64`) OS on Raspberry Pi. --- @@ -255,8 +283,8 @@ it. > **Upgrade note:** `ENABLE_GPU=1` continues to give you hardware video decode, as it always has — > nothing to change for existing video kiosks. > -> Hardware **video encode** (e.g. for WebRTC capture) is not currently exposed; it's a candidate for a -> future enhancement. +> Hardware **video encode** is currently **not supported** (e.g. WebRTC capture/streaming may not +> work — see [#168](https://github.com/balena-io-experimental/browser/issues/168)). What to expect per target (with `ENABLE_GPU=1`): @@ -268,27 +296,6 @@ What to expect per target (with `ENABLE_GPU=1`): - **Generic x86_64 (Intel/AMD)** — VA-API decode via `mesa-va-drivers` (already bundled). - **Generic AARCH64** — software video decode (no guaranteed kernel decoder). -### Extending the block (advanced VA-API drivers) - -The block bundles `mesa-va-drivers`, which covers AMD and Mesa-based Intel decode. Some stacks need -extra, often non-free, drivers that we deliberately do **not** bundle: - -- **Intel (iHD):** `intel-media-va-driver` (Debian `non-free`). -- **NVIDIA:** `nvidia-vaapi-driver` plus the `VaapiOnNvidiaGPUs` Chromium feature (experimental, - unsupported upstream). - -Add these in a derived image, e.g.: - -```dockerfile -FROM bh.cr//browser-block -# Requires the non-free component enabled in apt sources -RUN apt-get update && apt-get install -y --no-install-recommends intel-media-va-driver vainfo \ - && rm -rf /var/lib/apt/lists/* -``` - -Then verify the driver loads with `vainfo` inside the container, and check -`GET /diagnostics/media` / `chrome://gpu` for a hardware decoder. - ## Troubleshooting This section provides some guidance for common issues encountered: From 6f03818bc7ec34cbc183c1b0fd31aec670d3d3e7 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Mon, 15 Jun 2026 19:33:19 +0400 Subject: [PATCH 03/18] Update supported device types for v3 Drop fincm3 and 32-bit raspberrypi3, add generic-amd64 and generic-aarch64, make raspberrypi4-64 the default device type, and publish a raspberrypi3-64 block. Change-type: major Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- .github/workflows/flowzone.yml | 2 +- balena.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/flowzone.yml b/.github/workflows/flowzone.yml index 8551c6e..9768fa4 100644 --- a/.github/workflows/flowzone.yml +++ b/.github/workflows/flowzone.yml @@ -25,4 +25,4 @@ jobs: secrets: inherit with: - balena_slugs: 'balenasolutions/browser-generic-aarch64,balenasolutions/browser-generic-amd64,balenasolutions/browser-raspberrypi4-64,balenasolutions/browser-raspberrypi5' + balena_slugs: 'balenasolutions/browser-generic-aarch64,balenasolutions/browser-generic-amd64,balenasolutions/browser-raspberrypi3-64,balenasolutions/browser-raspberrypi4-64,balenasolutions/browser-raspberrypi5' diff --git a/balena.yml b/balena.yml index d49a7a0..a868217 100644 --- a/balena.yml +++ b/balena.yml @@ -52,4 +52,4 @@ data: - raspberrypi5 - intel-nuc - generic-amd64 - - generic-aarch64 \ No newline at end of file + - generic-aarch64 From aea78cba5e7972d0387c093f3a221593dbed34bb Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Tue, 16 Jun 2026 07:22:10 +0400 Subject: [PATCH 04/18] Add opt-in remote debugging via a TCP relay Chromium binds its DevTools endpoint to localhost only and ignores --remote-debugging-address outside headless mode, so mapping the port never made it reachable from another host. With ENABLE_REMOTE_DEBUG=1 a small TCP relay forwards REMOTE_DEBUG_PORT to Chromium. The relay adds no authentication or encryption. Change-type: minor Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/migrating-from-v2.md | 7 ++++ readme.md | 27 ++++++++++++++-- src/server.js | 68 +++++++++++++++++++++++++++++++-------- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index 40c9408..e6f70b2 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -89,3 +89,10 @@ hand-rolled flags where possible. The `/diagnostics/*` endpoints (Chromium version, GPU state, media-decoder state) are new in v3 and are **off by default**. Set `ENABLE_DIAGNOSTICS=1` to enable them. See [Diagnostics](../readme.md#diagnostics). + +## 6. Remote debugging + +In v2, mapping the remote debugging port did not actually make it reachable from another host — +Chromium binds that interface to localhost only. In v3 it is an explicit opt-in: set +`ENABLE_REMOTE_DEBUG=1` and map `REMOTE_DEBUG_PORT`. See +[Remote debugging](../readme.md#remote-debugging) for the security caveats. diff --git a/readme.md b/readme.md index 3be206b..c5000b2 100644 --- a/readme.md +++ b/readme.md @@ -16,7 +16,7 @@ It renders through a companion **display** (compositor) block, and provides an A - Remotely configurable launch URL - Automatically displays local HTTP (port 80 or 8080) or HTTPS (443) service endpoints. - API for remote configuration and management -- Chromium remote debugging port +- Optional remote debugging from another host --- ## Usage @@ -93,7 +93,8 @@ The following environment variables allow configuration of the `browser` block: |`WINDOW_SIZE`|`x,y`|Detected screen resolution|Sets the browser window size, such as `800,600`.
**Note:** Reverse the dimensions if you also rotate the display to `left` or `right` | |`WINDOW_POSITION`|`x,y`|`0,0`|Specifies the browser window position on the screen| |`API_PORT`|port number|5011|Specifies the port number the API runs on| -|`REMOTE_DEBUG_PORT`|port number|35173|Specifies the port number the chrome remote debugger runs on| +|`ENABLE_REMOTE_DEBUG`|`0`, `1`|`0`|Exposes Chromium's remote debugging interface on `REMOTE_DEBUG_PORT` so it can be reached from another host (see [Remote debugging](#remote-debugging)). **No authentication or encryption.**
`0` = off, `1` = on| +|`REMOTE_DEBUG_PORT`|port number|35173|Port the remote debugging relay listens on when `ENABLE_REMOTE_DEBUG=1`. Has no effect otherwise| |`AUTO_REFRESH`|interval|0 (disabled)|Specifies the number of seconds before the page automatically refreshes| |`ENABLE_DIAGNOSTICS`|`0`, `1`|`0`|Enables the `/diagnostics/*` API endpoints, which expose Chromium version, GPU and media-decoder state. Off by default.
`0` = off, `1` = on| @@ -242,6 +243,28 @@ useful for confirming hardware video decode (e.g. `V4L2VideoDecoder`). --- +## Remote debugging + +Chromium's DevTools endpoint binds to localhost only and ignores `--remote-debugging-address` +outside headless mode, so mapping the port alone does not make it reachable from another machine. +Set `ENABLE_REMOTE_DEBUG=1` to run a small TCP relay that forwards `REMOTE_DEBUG_PORT` (default +`35173`) to Chromium, and map that port in your compose file: + +```yaml + ports: + - '5011:5011' + - '35173:35173' +``` + +Then add the device as a target in `chrome://inspect/#devices` on another machine, connecting by IP +address (`:35173`). + +> ⚠️ The remote debugging interface has **no authentication or encryption** — anyone who can reach +> the port gets full control of the browser. Only enable it on a trusted/private network, or leave +> the port unmapped and reach it through an SSH tunnel instead. + +--- + ## Supported devices The block builds for two architectures (`aarch64`, `amd64`) and bundles the Mesa diff --git a/src/server.js b/src/server.js index 50e5731..a640d59 100644 --- a/src/server.js +++ b/src/server.js @@ -18,10 +18,18 @@ const { } = require('set-interval-async/dynamic'); const path = require('path'); const os = require('os'); +const net = require('net'); // Static environment variable fallback configurations const API_PORT = parseInt(process.env.API_PORT) || 5011; const PERSISTENT_DATA = process.env.PERSISTENT || '0'; +// Chromium's DevTools endpoint always binds to localhost only (Chromium ignores +// --remote-debugging-address outside headless mode), so the block talks to it on this internal port. +const CHROMIUM_DEBUG_PORT = 9222; +// When ENABLE_REMOTE_DEBUG=1 a built-in TCP relay exposes the DevTools port on REMOTE_DEBUG_PORT so +// it can be reached from another host. This interface has NO authentication or encryption — only use +// it on a trusted/private network or behind an SSH tunnel. +const ENABLE_REMOTE_DEBUG = process.env.ENABLE_REMOTE_DEBUG || '0'; const REMOTE_DEBUG_PORT = process.env.REMOTE_DEBUG_PORT || 35173; const FLAGS = process.env.FLAGS || null; const EXTRA_FLAGS = process.env.EXTRA_FLAGS || null; @@ -54,7 +62,6 @@ const BALENA_BASE_FLAGS = [ '--disable-session-crashed-bubble', // Prevent crash recovery UI from breaking kiosk immersion '--check-for-update-interval=31536000', // Disable update checks '--disable-dev-shm-usage', // Prevent shared memory exhaustion in Docker - '--touch-events=enabled', // Force capacitive touch layer recognition for DSI displays '--ozone-platform=wayland' // Render through the Wayland compositor (display block), never X11/XWayland ]; @@ -82,7 +89,7 @@ const PLATFORM_PROFILES = { decodeFeatures: [] }, // Raspberry Pi 3-64 / 4 / 400 (bcm2835-codec). Decode is handled by the Raspberry Pi - // patched Chromium (success shows as MojoVideoDecoder), so we add no decode features + // patched Chromium so we add no decode features // and must NOT force the upstream --use-v4l2-codec path. rpiCodec: { glBackend: ['--use-angle=gles'], @@ -173,10 +180,6 @@ function composeGpuFlags() { let composed = DEFAULT_FLAGS.concat(BALENA_BASE_FLAGS); let enabledFeatures = []; - if (process.env.ENABLE_VIRTUAL_KEYBOARD === '1') { - enabledFeatures.push('VirtualKeyboard'); - } - // Decode rides along with GPU rendering by default (see below), so "I want decode" is // simply ENABLE_GPU=1. const gpuEnabled = enableGpu === '1'; @@ -267,7 +270,7 @@ let launchChromium = async function(url) { startingUrl: startingUrl, ignoreDefaultFlags: true, chromeFlags: flags, - port: REMOTE_DEBUG_PORT, + port: CHROMIUM_DEBUG_PORT, connectionPollInterval: 1000, maxConnectionRetries: 120, userDataDir: '1' === PERSISTENT_DATA ? '/data/chromium' : undefined @@ -284,7 +287,7 @@ let launchChromium = async function(url) { async function refreshPageCDP() { let client; try { - client = await CDP({ port: REMOTE_DEBUG_PORT }); + client = await CDP({ port: CHROMIUM_DEBUG_PORT }); const { Page } = client; await Page.enable(); await Page.reload(); @@ -306,7 +309,7 @@ async function refreshPageCDP() { async function navigatePageCDP(url) { let client; try { - client = await CDP({ port: REMOTE_DEBUG_PORT }); + client = await CDP({ port: CHROMIUM_DEBUG_PORT }); const { Page } = client; await Page.enable(); await Page.navigate({ url: url }); @@ -504,7 +507,7 @@ app.get('/version', (req, res) => { app.get('/screenshot', async (req, res) => { let client; try { - client = await CDP({ port: REMOTE_DEBUG_PORT }); + client = await CDP({ port: CHROMIUM_DEBUG_PORT }); const { Page } = client; await Page.enable(); @@ -537,6 +540,45 @@ app.listen(API_PORT, () => { console.log('Browser API running on port: ' + API_PORT); }); +// Holds the TCP relay server when remote debugging is exposed. +let remoteDebugRelay = null; + +/** + * Optionally exposes Chromium's localhost-only DevTools port to other hosts. + * Disabled unless ENABLE_REMOTE_DEBUG=1. A raw TCP relay forwards 0.0.0.0:REMOTE_DEBUG_PORT to + * Chromium on 127.0.0.1:CHROMIUM_DEBUG_PORT. Piping bytes transparently carries both the DevTools + * HTTP endpoints and the WebSocket session. It does NOT add authentication or encryption. + */ +function startRemoteDebugRelay() { + if (ENABLE_REMOTE_DEBUG !== '1' || remoteDebugRelay) { + return; + } + + remoteDebugRelay = net.createServer((client) => { + const upstream = net.connect(CHROMIUM_DEBUG_PORT, '127.0.0.1'); + client.pipe(upstream); + upstream.pipe(client); + const close = () => { client.destroy(); upstream.destroy(); }; + client.on('error', close); + upstream.on('error', close); + }); + + remoteDebugRelay.on('error', (err) => { + console.log(`Remote debug relay failed: ${err.message}`); + remoteDebugRelay = null; + }); + + remoteDebugRelay.listen(REMOTE_DEBUG_PORT, '0.0.0.0', () => { + console.log( + `Exposing Chromium remote debugging on port ${REMOTE_DEBUG_PORT}. ` + + `This interface has no authentication or encryption — only use it on a trusted ` + + `network or via an SSH tunnel.` + ); + }); +} + +startRemoteDebugRelay(); + // ============================================================================ // Diagnostic Endpoints // Extracts internal Chromium state directly via Chrome DevTools Protocol. @@ -554,7 +596,7 @@ function diagnosticsGuard(req, res, next) { // Reports the running Chromium build/version and the block version app.get('/diagnostics/version', diagnosticsGuard, async (req, res) => { try { - const info = await CDP.Version({ port: REMOTE_DEBUG_PORT }); + const info = await CDP.Version({ port: CHROMIUM_DEBUG_PORT }); return res.status(200).json({ browser: info['Browser'], // e.g. "Chrome/148.0.7778.167" protocolVersion: info['Protocol-Version'], @@ -574,7 +616,7 @@ app.get('/diagnostics/gpu', diagnosticsGuard, async (req, res) => { let client; try { // 1. Interrogate the debugging metadata endpoint to retrieve the root browser socket - const browserVersion = await CDP.Version({ port: REMOTE_DEBUG_PORT }); + const browserVersion = await CDP.Version({ port: CHROMIUM_DEBUG_PORT }); // 2. Connect directly to the master browser session WebSocket URL client = await CDP({ target: browserVersion.webSocketDebuggerUrl }); @@ -604,7 +646,7 @@ app.get('/diagnostics/media', diagnosticsGuard, async (req, res) => { let client; try { // 1. Fetch the target registry to isolate the primary user-visible page - const targets = await CDP.List({ port: REMOTE_DEBUG_PORT }); + const targets = await CDP.List({ port: CHROMIUM_DEBUG_PORT }); const pageTarget = targets.find(t => t.type === 'page'); if (!pageTarget) { From 464ae5798fb79b814c3876fe06ad41d53ac078a6 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Tue, 16 Jun 2026 14:09:15 +0400 Subject: [PATCH 05/18] Remove FORCE_VULKAN Chromium's Vulkan backend does not work with the Ozone Wayland platform. The options for bringing it back after v3 are recorded in docs/todo-vulkan-wayland.md. Change-type: major Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/todo-vulkan-wayland.md | 48 +++++++++++++++++++++++++++++++++++++ readme.md | 1 - src/server.js | 10 -------- 3 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 docs/todo-vulkan-wayland.md diff --git a/docs/todo-vulkan-wayland.md b/docs/todo-vulkan-wayland.md new file mode 100644 index 0000000..63a7f7b --- /dev/null +++ b/docs/todo-vulkan-wayland.md @@ -0,0 +1,48 @@ +# TODO: Vulkan on Wayland (deferred until after v3) + +Vulkan support was removed from the block before the v3 release. This note records why, +so we don't re-investigate from scratch when we pick it back up. + +## What we found + +Chromium's Vulkan backend is **incompatible with native Ozone Wayland**. With +`--ozone-platform=wayland` (which v3 always uses), the GPU process refuses Vulkan and logs: + +``` +ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:252] +'--ozone-platform=wayland' is not compatible with Vulkan. +Consider switching to '--ozone-platform=x11' or disabling Vulkan +``` + +This is enforced in Chromium, not a driver, package, or permission problem: + +- Confirmed on Raspberry Pi 5 with the Pi-patched builds (`chromium …+rpt1`, + `mesa-vulkan-drivers 25.0.7-2+rpt4`) — same packages as Raspberry Pi OS desktop. +- When the block auto-enabled Vulkan, the GPU process crashed in a loop at startup + (`gpu_process_host: GPU process exited unexpectedly: exit_code=8704`, 3x) before + falling back to GLES. The crash reason was only visible with `--disable-gpu-sandbox`, + which let the GPU process log the incompatibility message above. + +## Why Raspberry Pi OS likely shows Vulkan enabled + +Not confirmed, but the most likely explanation: on the RPi OS desktop Chromium probably +runs under **X11 / XWayland** (`--ozone-platform=x11`) rather than native Wayland, where +Vulkan is supported. The desktop compositor (labwc) using Vulkan would be separate from +Chromium's own renderer backend. Worth verifying the actual Chromium launch args / ozone +platform on RPi OS when we revisit this. + +## Options for re-enabling later + +1. Run Chromium under X11 / XWayland (`--ozone-platform=x11`). Brings Vulkan/WebGPU back + but walks back the v3 X11 -> Wayland migration and needs the display block to provide + XWayland. Large change for little gain on a video/kiosk browser. +2. Wait for upstream Chromium to support Vulkan under Ozone Wayland. + +Under the current Wayland-only design, rendering runs on GLES (healthy: `gpu_compositing`, +`rasterization`, `webgl` all enabled). The only real loss is WebGPU, which needs Vulkan. + +## Notes + +- `mesa-vulkan-drivers` is still installed in `build/install_chromium` so the driver is + present when we revisit this. +- The `FORCE_VULKAN` env var and the Vulkan feature flag were removed from `src/server.js`. diff --git a/readme.md b/readme.md index c5000b2..4e32423 100644 --- a/readme.md +++ b/readme.md @@ -89,7 +89,6 @@ The following environment variables allow configuration of the `browser` block: |`TOUCHSCREEN`|`string`|N\A|Name of Touch Input to rotate| |`ENABLE_GPU`|`0`, `1`|0|Master hardware-acceleration switch. Enables GPU **rendering** (rasterization, compositing, WebGL/canvas) and, by default, best-effort hardware **video decode**. On Raspberry Pi, decode is handled by the Pi-patched Chromium (verify via `MojoVideoDecoder`/`V4L2VideoDecoder` in `chrome://media-internals`); on x86 it enables the Mesa VA-API path.
`0` = off, `1` = on| |`DISABLE_VIDEO_DECODE`|`0`, `1`|0|Opt **out** of hardware video decode while keeping GPU rendering on. Use on devices where the decode path misbehaves. No effect unless `ENABLE_GPU=1`.
`0` = decode stays on, `1` = decode off| -|`FORCE_VULKAN`|`0`, `1`|undefined|In combination with `ENABLE_GPU`, forces vulkan to either be enabled or disabled in chromium. If undefined or an invalid value, vulkan is only automatically enabled on the Raspberry Pi 5| |`WINDOW_SIZE`|`x,y`|Detected screen resolution|Sets the browser window size, such as `800,600`.
**Note:** Reverse the dimensions if you also rotate the display to `left` or `right` | |`WINDOW_POSITION`|`x,y`|`0,0`|Specifies the browser window position on the screen| |`API_PORT`|port number|5011|Specifies the port number the API runs on| diff --git a/src/server.js b/src/server.js index a640d59..472759e 100644 --- a/src/server.js +++ b/src/server.js @@ -35,7 +35,6 @@ const FLAGS = process.env.FLAGS || null; const EXTRA_FLAGS = process.env.EXTRA_FLAGS || null; const HTTPS_REGEX = /^https?:\/\//i; const AUTO_REFRESH = process.env.AUTO_REFRESH || 0; -const FORCE_VULKAN = process.env.FORCE_VULKAN || "-1"; // Diagnostics endpoints expose internal Chromium/GPU state; off by default. const ENABLE_DIAGNOSTICS = process.env.ENABLE_DIAGNOSTICS || '0'; @@ -214,15 +213,6 @@ function composeGpuFlags() { // (verify via V4L2VideoDecoder), so we deliberately do not force a decoder path. enabledFeatures = enabledFeatures.concat(profile.decodeFeatures); } - - // Vulkan is only auto-enabled on the Raspberry Pi 5; FORCE_VULKAN can override. - // NOTE: '--ozone-platform=wayland' has been observed to be incompatible with Vulkan - // on Pi 4 — validate on Pi 5 hardware. - if (deviceType === 'raspberrypi5' - ? (FORCE_VULKAN === '1' || FORCE_VULKAN !== '0') - : (FORCE_VULKAN === '1')) { - enabledFeatures.push('Vulkan'); - } } // Only emit --enable-features when we actually have features to enable From 1552073642d0ed1fcb5bcad085e7a851756693e0 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Wed, 17 Jun 2026 09:29:04 +0400 Subject: [PATCH 06/18] Add a diagnostics report endpoint /diagnostics/report returns a single text file with device and host info, runtime config, Chromium GPU and media state, and recent block and Chromium logs, for attaching to bug reports. Diagnostics move into src/diagnostics.js. Change-type: minor Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- readme.md | 12 +- src/diagnostics.js | 405 +++++++++++++++++++++++++++++++++++++++++++++ src/server.js | 154 +++-------------- 3 files changed, 436 insertions(+), 135 deletions(-) create mode 100644 src/diagnostics.js diff --git a/readme.md b/readme.md index 4e32423..4a72c61 100644 --- a/readme.md +++ b/readme.md @@ -227,7 +227,17 @@ Captures the current page through the Chromium DevTools Protocol and returns it The following endpoints expose internal Chromium state for troubleshooting hardware acceleration. They are **disabled by default**; set `ENABLE_DIAGNOSTICS=1` to enable them. When disabled they -return `404`. +return `404`. While enabled, Chromium also logs to `/tmp/chrome_debug.log` in the container so its +GPU/decoder/audio errors can be included in the report below. + +#### **GET** /diagnostics/report +Returns a single, human-readable `.txt` bundling device/host info, runtime config and flags, Chromium +/ GPU / media state, and recent block + Chromium logs. This is the easiest thing to attach to a bug +report. Save it with: + +```bash +curl -OJ http://:5011/diagnostics/report +``` #### **GET** /diagnostics/version Returns the running Chromium build/version (and the block version) as JSON. diff --git a/src/diagnostics.js b/src/diagnostics.js new file mode 100644 index 0000000..df3d6fc --- /dev/null +++ b/src/diagnostics.js @@ -0,0 +1,405 @@ +/** + * Diagnostics for the balena Browser Block. + * + * Everything here is gated behind ENABLE_DIAGNOSTICS (default off) because it + * exposes internal Chromium/GPU/system state. When enabled it: + * - captures recent block + Chromium logs, + * - exposes the existing /diagnostics/{version,gpu,media} JSON endpoints, + * - adds /diagnostics/report: a single, human-readable .txt bundling + * device/host info, runtime config, Chromium/GPU/media state and recent logs + * (useful for testers now and end-user bug reports later). + */ + +const fs = require('fs'); +const os = require('os'); +const util = require('util'); +const CDP = require('chrome-remote-interface'); + +const ENABLE_DIAGNOSTICS = process.env.ENABLE_DIAGNOSTICS || '0'; +const enabled = ENABLE_DIAGNOSTICS === '1'; + +// Chromium writes its own log here when diagnostics are on, so the report can +// include GPU-process crashes / decoder / ALSA errors that never reach the +// block's console.log. Same file used during manual debugging. +const CHROMIUM_LOG_PATH = '/tmp/chrome_debug.log'; + +// Bounded in-memory ring buffer of recent block log lines. +const LOG_BUFFER_MAX = 500; +const logBuffer = []; +const originalConsoleLog = console.log; + +// Wrap console.log once (rather than touching every call site) so the report can +// include the block's own output. Installed only when diagnostics are enabled, so +// there is zero overhead in the default configuration. +function startLogCapture() { + console.log = (...args) => { + logBuffer.push(`${new Date().toISOString()} ${util.format(...args)}`); + if (logBuffer.length > LOG_BUFFER_MAX) { + logBuffer.shift(); + } + originalConsoleLog(...args); + }; +} + +if (enabled) { + startLogCapture(); +} + +/** + * Flags that tell Chromium to write its own log to CHROMIUM_LOG_PATH. Empty when + * diagnostics are disabled so the default launch is untouched. + */ +function chromiumLogFlags() { + return enabled ? ['--enable-logging', `--log-file=${CHROMIUM_LOG_PATH}`] : []; +} + +// ---------------------------------------------------------------------------- +// Collectors (one CDP connection each; throw on failure so callers can record it) +// ---------------------------------------------------------------------------- + +async function getChromiumVersion(port) { + const info = await CDP.Version({ port }); + return { + browser: info['Browser'], // e.g. "Chrome/148.0.7778.167" + protocolVersion: info['Protocol-Version'], + v8Version: info['V8-Version'], + webkitVersion: info['WebKit-Version'], + userAgent: info['User-Agent'], + blockVersion: process.env.VERSION || null + }; +} + +async function getGpuInfo(port) { + let client; + try { + // Connect to the master browser session to run the SystemInfo command, + // which returns the same data as chrome://gpu without opening a window. + const browserVersion = await CDP.Version({ port }); + client = await CDP({ target: browserVersion.webSocketDebuggerUrl }); + + if (client.SystemInfo && typeof client.SystemInfo.getInfo === 'function') { + const infoPayload = await client.SystemInfo.getInfo(); + return infoPayload.gpu; // devices, featureStatus, driverBugWorkarounds, ... + } + throw new Error('SystemInfo protocol domain not exposed by this Chromium binary.'); + } finally { + if (client) { + await client.close(); + } + } +} + +async function getMediaState(port) { + let client; + try { + // Inspect the primary user-visible page without disrupting the compositor. + const targets = await CDP.List({ port }); + const pageTarget = targets.find(t => t.type === 'page'); + + if (!pageTarget) { + return { activePlaybackCount: 0, players: [] }; + } + + client = await CDP({ target: pageTarget }); + const { Media } = client; + + const activePlayers = {}; + + // Trap properties as Chromium flushes them down the WebSocket connection. + Media.playerPropertiesChanged((params) => { + const { playerId, properties } = params; + if (!activePlayers[playerId]) { + activePlayers[playerId] = {}; + } + properties.forEach(prop => { + activePlayers[playerId][prop.name] = prop.value; + }); + }); + + await Media.enable(); + // Brief settle window for the socket events to arrive. + await new Promise(resolve => setTimeout(resolve, 400)); + + const players = Object.keys(activePlayers).map(id => { + const playerData = activePlayers[id]; + const decoderName = playerData.video_decoder || playerData.kVideoDecoderName || 'Unknown'; + + // Hardware-accelerated based on the runtime driver name or metadata. + const isHardware = playerData.is_platform_video_decoder === 'true' || + playerData.is_platform_video_decoder === true || + /v4l2|vaapi|mojo|d3d11/i.test(decoderName); + + return { + playerId: id, + videoCodec: playerData.video_codec_name || 'Unknown', + decoderName, + isHardwareAccelerated: isHardware, + resolution: `${playerData.video_width || 0}x${playerData.video_height || 0}`, + rawProperties: playerData + }; + }); + + return { activePlaybackCount: players.length, players }; + } finally { + if (client) { + await client.close(); + } + } +} + +/** + * Device and host facts that don't need Chromium. Only a whitelist of balena + * env vars is read — process.env is never dumped, so a shared report can't leak + * secrets. + */ +function getDeviceInfo() { + const cpus = os.cpus(); + return { + deviceType: process.env.BALENA_DEVICE_TYPE || null, + deviceArch: process.env.BALENA_DEVICE_ARCH || null, + deviceUuid: process.env.BALENA_DEVICE_UUID || null, + appName: process.env.BALENA_APP_NAME || null, + osVersion: process.env.BALENA_HOST_OS_VERSION || null, + supervisorVersion: process.env.BALENA_SUPERVISOR_VERSION || null, + hostname: os.hostname(), + kernel: os.release(), + arch: os.arch(), + cpuModel: (cpus[0] || {}).model || null, + cpuCount: cpus.length, + totalMemBytes: os.totalmem(), + freeMemBytes: os.freemem(), + loadavg: os.loadavg(), + uptimeSeconds: Math.round(os.uptime()) + }; +} + +/** + * Last `lines` lines of the Chromium log, or a note if it isn't there yet. + */ +function tailChromiumLog(lines = 200) { + try { + const content = fs.readFileSync(CHROMIUM_LOG_PATH, 'utf8'); + return content.trim().split('\n').slice(-lines).join('\n'); + } catch (err) { + return `(no Chromium log at ${CHROMIUM_LOG_PATH}: ${err.code || err.message})`; + } +} + +// ---------------------------------------------------------------------------- +// Report formatting (plain text, sectioned for quick human reading) +// ---------------------------------------------------------------------------- + +function bytesToMb(bytes) { + return typeof bytes === 'number' ? `${Math.round(bytes / 1024 / 1024)} MB` : 'unknown'; +} + +function section(title, body) { + return `\n--- ${title} ---\n${body}\n`; +} + +function formatDevice(d) { + return [ + `device type : ${d.deviceType || 'unknown'}`, + `device arch : ${d.deviceArch || 'unknown'}`, + `device uuid : ${d.deviceUuid || 'unknown'}`, + `app / fleet : ${d.appName || 'unknown'}`, + `balenaOS : ${d.osVersion || 'unknown'}`, + `supervisor : ${d.supervisorVersion || 'unknown'}`, + `hostname : ${d.hostname}`, + `kernel : ${d.kernel}`, + `cpu : ${d.cpuModel || 'unknown'} x${d.cpuCount}`, + `memory : ${bytesToMb(d.freeMemBytes)} free / ${bytesToMb(d.totalMemBytes)} total`, + `load average : ${d.loadavg.map(n => n.toFixed(2)).join(' ')}`, + `uptime : ${d.uptimeSeconds}s` + ].join('\n'); +} + +function formatRuntime(r) { + return [ + `ENABLE_GPU : ${r.enableGpu}`, + `DISABLE_VIDEO_DECODE : ${r.disableVideoDecode}`, + `kiosk : ${r.kioskMode}`, + `current URL : ${r.currentUrl || '(none)'}`, + '', + 'flags:', + ...(r.flags || []).map(f => ` ${f}`) + ].join('\n'); +} + +function formatChromium(c) { + if (c.error) { + return `error: ${c.error}`; + } + return [ + `browser : ${c.browser}`, + `v8 : ${c.v8Version}`, + `webkit : ${c.webkitVersion}`, + `user-agent : ${c.userAgent}`, + `block : ${c.blockVersion || 'unknown'}` + ].join('\n'); +} + +function formatGpu(g) { + if (g.error) { + return `error: ${g.error}`; + } + const parts = []; + + if (g.featureStatus && typeof g.featureStatus === 'object') { + parts.push('feature status:'); + for (const [name, status] of Object.entries(g.featureStatus)) { + parts.push(` ${name}: ${status}`); + } + } + + if (Array.isArray(g.devices) && g.devices.length) { + parts.push('\ndevices:'); + g.devices.forEach(dev => { + const desc = dev.deviceString || `${dev.vendorId}:${dev.deviceId}`; + parts.push(` ${desc}`); + }); + } + + // Keep the full payload too so nothing useful is lost. + parts.push('\nfull payload:'); + parts.push(JSON.stringify(g, null, 2)); + return parts.join('\n'); +} + +function formatMedia(m) { + if (m.error) { + return `error: ${m.error}`; + } + if (!m.players || m.players.length === 0) { + return 'no active media players (nothing playing at report time).'; + } + return m.players.map(p => [ + `player ${p.playerId}:`, + ` codec : ${p.videoCodec}`, + ` decoder : ${p.decoderName}`, + ` hardware : ${p.isHardwareAccelerated}`, + ` resolution : ${p.resolution}` + ].join('\n')).join('\n\n'); +} + +function formatReport(data) { + const header = + '===== balena browser-block diagnostics =====\n' + + `generated : ${data.generatedAt}\n` + + `block : ${data.runtime.blockVersion || process.env.VERSION || 'unknown'}`; + + return [ + header, + section('DEVICE & HOST', formatDevice(data.device)), + section('RUNTIME CONFIG', formatRuntime(data.runtime)), + section('CHROMIUM', formatChromium(data.chromium)), + section('GPU (chrome://gpu)', formatGpu(data.gpu)), + section('MEDIA PLAYERS', formatMedia(data.media)), + section('RECENT BLOCK LOG', data.log.block.length ? data.log.block.join('\n') : '(empty)'), + section('RECENT CHROMIUM LOG', data.log.chromium) + ].join('\n'); +} + +// Build a filename like browser-block-raspberrypi5-6.5.7-diagnostics-2026-06-16T1042Z.txt +function reportFilename(device, generatedAt) { + const clean = (v, fallback) => String(v || fallback).replace(/[^A-Za-z0-9._-]/g, '_'); + const deviceType = clean(device.deviceType, 'unknown'); + const balenaOs = clean(device.osVersion, 'unknown'); + const stamp = clean(generatedAt, 'unknown'); + return `browser-block-${deviceType}-${balenaOs}-diagnostics-${stamp}.txt`; +} + +// ---------------------------------------------------------------------------- +// Route registration +// ---------------------------------------------------------------------------- + +/** + * Registers the diagnostics endpoints on the given Express app. + * + * @param {object} app Express app. + * @param {object} deps + * @param {number} deps.debugPort Chromium DevTools port. + * @param {Function} deps.getRuntimeConfig Returns the live runtime config + * ({ enableGpu, disableVideoDecode, kioskMode, currentUrl, flags }). + */ +function register(app, { debugPort, getRuntimeConfig }) { + // Refuse diagnostics requests unless explicitly enabled. + function diagnosticsGuard(req, res, next) { + if (!enabled) { + return res.status(404).send('Diagnostics are disabled. Set ENABLE_DIAGNOSTICS=1 to enable them.'); + } + next(); + } + + // Running Chromium build/version and the block version. + app.get('/diagnostics/version', diagnosticsGuard, async (req, res) => { + try { + return res.status(200).json(await getChromiumVersion(debugPort)); + } catch (err) { + console.log('Error retrieving Chromium version: ', err.toString()); + return res.status(500).send('Failed to retrieve Chromium version.'); + } + }); + + // Full GPU diagnostic data (same as chrome://gpu). + app.get('/diagnostics/gpu', diagnosticsGuard, async (req, res) => { + try { + return res.status(200).json(await getGpuInfo(debugPort)); + } catch (err) { + console.log('Error during SystemInfo extraction: ', err.toString()); + return res.status(500).send('Failed to extract GPU diagnostic payload.'); + } + }); + + // Active media player states (decoder + hardware acceleration). + app.get('/diagnostics/media', diagnosticsGuard, async (req, res) => { + try { + return res.status(200).json(await getMediaState(debugPort)); + } catch (err) { + console.log('Error executing media diagnostics: ', err.toString()); + return res.status(500).send('Failed to extract active media decoder states.'); + } + }); + + // Single downloadable, human-readable report bundling everything above. + app.get('/diagnostics/report', diagnosticsGuard, async (req, res) => { + const generatedAt = new Date().toISOString().replace(/:/g, '').replace(/\.\d+Z$/, 'Z'); + const device = getDeviceInfo(); + const runtime = getRuntimeConfig(); + + const report = { + generatedAt: new Date().toISOString(), + device, + runtime, + chromium: null, + gpu: null, + media: null, + log: { block: logBuffer.slice(), chromium: tailChromiumLog() } + }; + + // Collect each CDP-backed section independently so one failure still yields + // a complete file (with an error note in the failing section). + const collectors = [ + ['chromium', () => getChromiumVersion(debugPort)], + ['gpu', () => getGpuInfo(debugPort)], + ['media', () => getMediaState(debugPort)] + ]; + for (const [key, fn] of collectors) { + try { + report[key] = await fn(); + } catch (err) { + report[key] = { error: err.toString() }; + } + } + + res.type('text/plain'); + res.set('Content-Disposition', `attachment; filename="${reportFilename(device, generatedAt)}"`); + return res.status(200).send(formatReport(report)); + }); +} + +module.exports = { + enabled, + chromiumLogFlags, + register +}; diff --git a/src/server.js b/src/server.js index 472759e..eb7a922 100644 --- a/src/server.js +++ b/src/server.js @@ -19,6 +19,9 @@ const { const path = require('path'); const os = require('os'); const net = require('net'); +// Diagnostics live in their own module; requiring it here installs the log +// capture (only when ENABLE_DIAGNOSTICS=1) before main() produces any output. +const diagnostics = require('./diagnostics'); // Static environment variable fallback configurations const API_PORT = parseInt(process.env.API_PORT) || 5011; @@ -35,8 +38,6 @@ const FLAGS = process.env.FLAGS || null; const EXTRA_FLAGS = process.env.EXTRA_FLAGS || null; const HTTPS_REGEX = /^https?:\/\//i; const AUTO_REFRESH = process.env.AUTO_REFRESH || 0; -// Diagnostics endpoints expose internal Chromium/GPU state; off by default. -const ENABLE_DIAGNOSTICS = process.env.ENABLE_DIAGNOSTICS || '0'; // Dynamic configuration variables that can be altered live via the HTTP API let kioskMode = process.env.KIOSK || '0'; @@ -243,6 +244,10 @@ let launchChromium = async function(url) { flags = flags.concat(EXTRA_FLAGS.split(' ')); } + // When diagnostics are enabled, tell Chromium to write its own log so the + // report can include GPU/decoder/audio errors. No-op otherwise. + flags = flags.concat(diagnostics.chromiumLogFlags()); + let startingUrl = url; if ('1' === kioskMode) { console.log("Enabling KIOSK mode"); @@ -569,138 +574,19 @@ function startRemoteDebugRelay() { startRemoteDebugRelay(); -// ============================================================================ -// Diagnostic Endpoints -// Extracts internal Chromium state directly via Chrome DevTools Protocol. -// Gated behind ENABLE_DIAGNOSTICS (default off) since they expose system info. -// ============================================================================ - -// Refuse diagnostics requests unless explicitly enabled. -function diagnosticsGuard(req, res, next) { - if (ENABLE_DIAGNOSTICS !== '1') { - return res.status(404).send('Diagnostics are disabled. Set ENABLE_DIAGNOSTICS=1 to enable them.'); - } - next(); -} - -// Reports the running Chromium build/version and the block version -app.get('/diagnostics/version', diagnosticsGuard, async (req, res) => { - try { - const info = await CDP.Version({ port: CHROMIUM_DEBUG_PORT }); - return res.status(200).json({ - browser: info['Browser'], // e.g. "Chrome/148.0.7778.167" - protocolVersion: info['Protocol-Version'], - v8Version: info['V8-Version'], - webkitVersion: info['WebKit-Version'], - userAgent: info['User-Agent'], - blockVersion: process.env.VERSION || null - }); - } catch (err) { - console.log("Error retrieving Chromium version: ", err.toString()); - return res.status(500).send("Failed to retrieve Chromium version."); - } -}); - -// Extracts full GPU diagnostic data silently without creating window surfaces -app.get('/diagnostics/gpu', diagnosticsGuard, async (req, res) => { - let client; - try { - // 1. Interrogate the debugging metadata endpoint to retrieve the root browser socket - const browserVersion = await CDP.Version({ port: CHROMIUM_DEBUG_PORT }); - - // 2. Connect directly to the master browser session WebSocket URL - client = await CDP({ target: browserVersion.webSocketDebuggerUrl }); - - // 3. Execute the native SystemInfo protocol command - if (client.SystemInfo && typeof client.SystemInfo.getInfo === 'function') { - const infoPayload = await client.SystemInfo.getInfo(); - - // infoPayload.gpu contains standard devices, featureStatus, and driverBugWorkarounds - return res.status(200).json(infoPayload.gpu); - } else { - return res.status(500).send("SystemInfo protocol domain not exposed by this Chromium binary."); - } - } catch (err) { - console.log("Error during silent SystemInfo extraction: ", err.toString()); - return res.status(500).send("Failed to extract GPU diagnostic payload silently."); - } finally { - // 4. Ensure the control socket is gracefully detached - if (client) { - await client.close(); - } - } -}); - -// Extracts active media player states subtly using the CDP Media domain -app.get('/diagnostics/media', diagnosticsGuard, async (req, res) => { - let client; - try { - // 1. Fetch the target registry to isolate the primary user-visible page - const targets = await CDP.List({ port: CHROMIUM_DEBUG_PORT }); - const pageTarget = targets.find(t => t.type === 'page'); - - if (!pageTarget) { - return res.status(404).send("No active kiosk or page target found to inspect."); - } - - // 2. Intercept the existing tab socket (zero compositor/window disruption) - client = await CDP({ target: pageTarget }); - const { Media } = client; - - const activePlayers = {}; - - // 3. Trap properties as Chromium flushes them down the WebSocket connection - Media.playerPropertiesChanged((params) => { - const { playerId, properties } = params; - if (!activePlayers[playerId]) { - activePlayers[playerId] = {}; - } - - properties.forEach(prop => { - activePlayers[playerId][prop.name] = prop.value; - }); - }); - - // 4. Force state synchronization from Chromium's internal media logger - await Media.enable(); - - // 5. Provide a brief period for the socket events to settle - await new Promise(resolve => setTimeout(resolve, 400)); - - // 6. Map and normalize the player parameters to verify acceleration status - const diagnosticReport = Object.keys(activePlayers).map(id => { - const playerData = activePlayers[id]; - const decoderName = playerData.video_decoder || playerData.kVideoDecoderName || "Unknown"; - - // Determine if hardware-accelerated based on runtime driver name or metadata - const isHardware = playerData.is_platform_video_decoder === "true" || - playerData.is_platform_video_decoder === true || - /v4l2|vaapi|mojo|d3d11/i.test(decoderName); - - return { - playerId: id, - videoCodec: playerData.video_codec_name || "Unknown", - decoderName: decoderName, - isHardwareAccelerated: isHardware, - resolution: `${playerData.video_width || 0}x${playerData.video_height || 0}`, - rawProperties: playerData - }; - }); - - return res.status(200).json({ - activePlaybackCount: diagnosticReport.length, - players: diagnosticReport - }); - - } catch (err) { - console.log("Error executing silent media diagnostics: ", err.toString()); - return res.status(500).send("Failed to extract active media decoder states subtly."); - } finally { - // 7. Ensure control socket detachment - if (client) { - await client.close(); - } - } +// Diagnostics endpoints (/diagnostics/version, /gpu, /media, /report) live in +// ./diagnostics and are gated behind ENABLE_DIAGNOSTICS. getRuntimeConfig is +// read at request time so the report reflects the live state. +diagnostics.register(app, { + debugPort: CHROMIUM_DEBUG_PORT, + getRuntimeConfig: () => ({ + enableGpu, + disableVideoDecode, + kioskMode, + currentUrl, + flags, + blockVersion: process.env.VERSION || null + }) }); From ef6c3999fe04fe50a69d1f080ebb0e32b84bbd69 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Thu, 18 Jun 2026 15:35:22 +0400 Subject: [PATCH 07/18] Remove remaining X11 code Delete startx.sh and the unused X.org config for the Raspberry Pi 5. ROTATE_DISPLAY, ROTATE_DELAY, TOUCHSCREEN, WINDOW_SIZE, WINDOW_POSITION, SHOW_CURSOR and DISPLAY_NUM no longer exist; rotation and resolution are now configured on the display block. The migration guide maps the old variables to the new ones. Change-type: major Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- build/rpi/99-vc4.conf | 6 --- docs/migrating-from-v2.md | 59 +++++++++++++++++++++++++++ readme.md | 14 +++---- src/startx.sh | 86 --------------------------------------- 4 files changed, 66 insertions(+), 99 deletions(-) delete mode 100644 build/rpi/99-vc4.conf delete mode 100644 src/startx.sh diff --git a/build/rpi/99-vc4.conf b/build/rpi/99-vc4.conf deleted file mode 100644 index ed3e255..0000000 --- a/build/rpi/99-vc4.conf +++ /dev/null @@ -1,6 +0,0 @@ -Section "OutputClass" - Identifier "vc4" - MatchDriver "vc4" - Driver "modesetting" - Option "PrimaryGPU" "true" -EndSection diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index e6f70b2..5451599 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -96,3 +96,62 @@ In v2, mapping the remote debugging port did not actually make it reachable from Chromium binds that interface to localhost only. In v3 it is an explicit opt-in: set `ENABLE_REMOTE_DEBUG=1` and map `REMOTE_DEBUG_PORT`. See [Remote debugging](../readme.md#remote-debugging) for the security caveats. + +## 7. Screen rotation & display geometry moved to the display block + +In v2 the browser owned the screen — it ran its own X server and rotated the display with `xrandr`, +rotated touch input with `xinput`, and sized its own window. In v3 the **compositor (the display +block) owns the output**, so these settings move there. The following `browser` variables **no longer +exist**: + +`ROTATE_DISPLAY`, `ROTATE_DELAY`, `TOUCHSCREEN`, `WINDOW_SIZE`, `WINDOW_POSITION`, `SHOW_CURSOR`, +`DISPLAY_NUM`. + +Configure the equivalents on the **`display`** service instead: + +| v2 (browser) | v3 (display block) | +| --- | --- | +| `ROTATE_DISPLAY=left` | `DISPLAY_ROTATION=270` (degrees **clockwise**: `left`→`270`, `right`→`90`, `inverted`→`180`) | +| `ROTATE_DELAY` | no longer needed — the compositor applies the transform at startup | +| `WINDOW_SIZE=1920,1080` | `DISPLAY_RESOLUTION=1920x1080` (in kiosk mode the client always fills the output) | +| `WINDOW_POSITION` | not applicable — the kiosk client is full-screen | +| `TOUCHSCREEN` | not needed — touch input follows the output transform automatically | +| `SHOW_CURSOR` | controlled by the compositor | + +See the display block's README for the full list (`DISPLAY_ROTATION`, `DISPLAY_RESOLUTION`, +`DISPLAY_SCALE`). Note that `DISPLAY_ROTATION` uses **degrees clockwise** (`0`/`90`/`180`/`270`) +rather than v2's `left`/`right`/`inverted`. + +Before (v2 — rotation on the browser): + +```yaml +services: + browser: + image: bh.cr/balenalabs/browser-aarch64 + environment: + ROTATE_DISPLAY: left + WINDOW_SIZE: '1920,1080' +``` + +After (v3 — rotation on the display block): + +```yaml +services: + display: + image: bh.cr/balenalabs/display- + privileged: true + volumes: + - display-socket:/run + labels: + io.balena.features.dbus: '1' + environment: + DISPLAY_ROTATION: 270 # v2 "left" == 270 degrees clockwise + DISPLAY_RESOLUTION: 1920x1080 + + browser: + image: bh.cr/balenalabs/browser- + # ... no rotation/geometry variables here anymore +``` + +> Multi-display setups are not yet supported by the display block; these variables apply to the +> first connected display. diff --git a/readme.md b/readme.md index 4a72c61..285446a 100644 --- a/readme.md +++ b/readme.md @@ -77,26 +77,26 @@ The following environment variables allow configuration of the `browser` block: | Environment variable | Options | Default | Description | | --- | --- | --- | --- | |`LAUNCH_URL`|`http` or `https` URL|N\A|Web page to display| -|`DISPLAY_NUM`|`n`|0|Display number to use| |`LOCAL_HTTP_DELAY`|Number (seconds)|0|Number of seconds to wait for a local HTTP service to start before trying to detect it| |`KIOSK`|`0`, `1`|`0`|Run in kiosk mode with no menus or status bars.
`0` = off, `1` = on| -|`SHOW_CURSOR`|`0`, `1`|`0`|Enables/disables the cursor when in kiosk mode
`0` = off, `1` = on| |`FLAGS`|[many!](https://peter.sh/experiments/chromium-command-line-switches/)|N/A|**Replaces** the flags chromium is started with. Enter a space (\' \') separated list of flags (e.g. `--noerrdialogs --disable-session-crashed-bubble`)
**Use with caution!**| |`EXTRA_FLAGS`|[many!](https://peter.sh/experiments/chromium-command-line-switches/)|N/A|Adds **additional** flags chromium is started with. Enter a space (\' \') separated list of flags (e.g. `--audio-buffer-size=2048 --audio-output-channels=8`)| |`PERSISTENT`|`0`, `1`|`0`|Enables/disables user profile data being stored on the device. **Note: you'll need to create a settings volume. See example above**
`0` = off, `1` = on| -|`ROTATE_DISPLAY`|`normal`, `left`, `right`, `inverted`|`normal`|Rotates the display| -|`ROTATE_DELAY`|`n`|`3`|Add an artificial delay (in seconds) before applying display rotation| -|`TOUCHSCREEN`|`string`|N\A|Name of Touch Input to rotate| |`ENABLE_GPU`|`0`, `1`|0|Master hardware-acceleration switch. Enables GPU **rendering** (rasterization, compositing, WebGL/canvas) and, by default, best-effort hardware **video decode**. On Raspberry Pi, decode is handled by the Pi-patched Chromium (verify via `MojoVideoDecoder`/`V4L2VideoDecoder` in `chrome://media-internals`); on x86 it enables the Mesa VA-API path.
`0` = off, `1` = on| |`DISABLE_VIDEO_DECODE`|`0`, `1`|0|Opt **out** of hardware video decode while keeping GPU rendering on. Use on devices where the decode path misbehaves. No effect unless `ENABLE_GPU=1`.
`0` = decode stays on, `1` = decode off| -|`WINDOW_SIZE`|`x,y`|Detected screen resolution|Sets the browser window size, such as `800,600`.
**Note:** Reverse the dimensions if you also rotate the display to `left` or `right` | -|`WINDOW_POSITION`|`x,y`|`0,0`|Specifies the browser window position on the screen| |`API_PORT`|port number|5011|Specifies the port number the API runs on| |`ENABLE_REMOTE_DEBUG`|`0`, `1`|`0`|Exposes Chromium's remote debugging interface on `REMOTE_DEBUG_PORT` so it can be reached from another host (see [Remote debugging](#remote-debugging)). **No authentication or encryption.**
`0` = off, `1` = on| |`REMOTE_DEBUG_PORT`|port number|35173|Port the remote debugging relay listens on when `ENABLE_REMOTE_DEBUG=1`. Has no effect otherwise| |`AUTO_REFRESH`|interval|0 (disabled)|Specifies the number of seconds before the page automatically refreshes| |`ENABLE_DIAGNOSTICS`|`0`, `1`|`0`|Enables the `/diagnostics/*` API endpoints, which expose Chromium version, GPU and media-decoder state. Off by default.
`0` = off, `1` = on| +> **Display geometry (rotation, resolution, scale) is configured on the `display` block, +> not here.** In v3 the browser is a Wayland client and the compositor owns the screen, so the v2 +> `ROTATE_DISPLAY`, `ROTATE_DELAY`, `TOUCHSCREEN`, `WINDOW_SIZE`, `WINDOW_POSITION`, `SHOW_CURSOR` and +> `DISPLAY_NUM` variables no longer apply. Set `DISPLAY_ROTATION` / `DISPLAY_RESOLUTION` / +> `DISPLAY_SCALE` on the `display` service instead — see its README and +> [Migrating from v2](docs/migrating-from-v2.md#7-screen-rotation--display-geometry-moved-to-the-display-block). + --- ## Choosing what to display diff --git a/src/startx.sh b/src/startx.sh deleted file mode 100644 index 81897e7..0000000 --- a/src/startx.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash - -echo "--- List Input Devices ---" -xinput list -echo "----- End of List --------" - -function reverse_window_coordinates () { - local INPUT=$1 - - IFS=', ' read -a coords <<< $INPUT - if [ ${#coords[@]} -eq 2 ]; then - echo "${coords[1]},${coords[0]}" - else - echo "Screen coordinates not set correctly, so cannot reverse them." - fi -} - -function rotate_touch () { - echo "Rotating Input ($1): $2" - local TRANSFORM='Coordinate Transformation Matrix' - - case "$2" in - normal) xinput set-prop "$1" "$TRANSFORM" 1 0 0 0 1 0 0 0 1;; - inverted) xinput set-prop "$1" "$TRANSFORM" -1 0 1 0 -1 1 0 0 1;; - left) xinput set-prop "$1" "$TRANSFORM" 0 -1 1 1 0 0 0 0 1;; - right) xinput set-prop "$1" "$TRANSFORM" 0 1 0 -1 0 1 0 0 1;; - esac -} - -if [[ -z "$WINDOW_SIZE" ]]; then - # detect the window size from the framebuffer file - echo "Detecting window size from framebuffer" - export WINDOW_SIZE=$( cat /sys/class/graphics/fb0/virtual_size ) - echo "Window size detected as $WINDOW_SIZE" -else - echo "Window size set by environment variable to $WINDOW_SIZE" -fi - -# rotate screen if env variable is set [normal, inverted, left or right] -if [[ ! -z "$ROTATE_DISPLAY" ]]; then - ROTATE_DELAY="${ROTATE_DELAY:-3}" - - sleep "$ROTATE_DELAY" && xrandr -o $ROTATE_DISPLAY - - #If the display is rotated to left or right, we need to reverse the size and position coords - if [[ "$ROTATE_DISPLAY" == "left" ]] || [[ "$ROTATE_DISPLAY" == "right" ]]; then - - echo "Display rotated to portait. Reversing screen coordinates" - - #window size - REVERSED_SIZE="$(reverse_window_coordinates $WINDOW_SIZE)" - WINDOW_SIZE=$REVERSED_SIZE - echo "Reversed window size: $WINDOW_SIZE" - - #window position, if set - if [[ "$WINDOW_POSITION" -ne "0,0" ]] - then - REVERSED_POSITION="$(reverse_window_coordinates $WINDOW_POSITION)" - export WINDOW_POSITION=$REVERSED_POSITION - echo "Reversed window position: $WINDOW_POSITION" - fi - fi - - echo "Rotate Touch Inputs" - if [[ ! -z "$TOUCHSCREEN" ]]; then - rotate_touch "$TOUCHSCREEN" "$ROTATE_DISPLAY" - else - devices=$( xinput --list | fgrep Touch | sed -E 's/^.*id=([0-9]+).*$/\1/' ) - for device in ${devices} ;do - rotate_touch $device $ROTATE_DISPLAY - done - fi -fi - -# these two lines remove the "restore pages" popup on chromium. -sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' /data/chromium/'Local State' > /dev/null 2>&1 || true -sed -i 's/"exited_cleanly":false/"exited_cleanly":true/; s/"exit_type":"[^"]\+"/"exit_type":"Normal"/' /data/chromium/Default/Preferences > /dev/null 2>&1 || true - -# Set chromium version into an EnVar for later -export VERSION=`chromium-browser --version` -echo "Installed browser version: $VERSION" - -# stop the screen blanking -xset s off -dpms - -node /usr/src/app/server.js \ No newline at end of file From 39b5bd3048cd2d5b9dae77f41617e467fc63295c Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Fri, 19 Jun 2026 17:15:45 +0400 Subject: [PATCH 08/18] Play audio directly via ALSA instead of the audio block The image no longer installs the PulseAudio ALSA bridge or sets PULSE_SERVER. HDMI and the 3.5mm jack work with no extra container. The migration guide shows how to add the audio block back in a derived image. Change-type: major Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/migrating-from-v2.md | 49 +++++++++++++++++++++++++++++++++++++++ readme.md | 49 +++++++++++++++++++++++++-------------- src/start.sh | 2 -- 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index 5451599..086574d 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -155,3 +155,52 @@ services: > Multi-display setups are not yet supported by the display block; these variables apply to the > first connected display. + +## 8. Audio no longer pre-wired to the audio block + +In v2 the browser image baked in the [`audio` block](https://github.com/balena-labs-projects/audio) +integration: the Dockerfile installed the PulseAudio ALSA bridge and set +`PULSE_SERVER=tcp:audio:4317`, so dropping the audio block into your compose file "just worked". + +In v3 this is **removed**. The browser now plays audio **directly via ALSA** to the device's sound +hardware, with no extra container required — for most setups (HDMI sound, the 3.5mm jack) audio works +out of the box with no configuration. + +### Retaining the audio block + +If you relied on the audio block, you can opt back in, but you must now wire it up yourself by +extending the browser image. Add the ALSA→PulseAudio bridge and point it at the audio server (this is +exactly what the v2 image baked in): + +```Dockerfile +FROM bh.cr/balenalabs/browser- +# Route ALSA output to the audio block's PulseAudio server +RUN curl -skL https://raw.githubusercontent.com/balena-labs-projects/audio/master/scripts/alsa-bridge/debian-setup.sh | sh +ENV PULSE_SERVER=tcp:audio:4317 +``` + +Then add the `audio` service alongside the (now locally built) browser service. In this example audio +is routed to the Raspberry Pi headphone jack via the audio block's `AUDIO_OUTPUT` variable: + +```yaml +services: + browser: + build: . # builds the extended Dockerfile above + privileged: true + audio: + image: bh.cr/balenalabs/audio- + privileged: true + ports: + - 4317:4317 + environment: + AUDIO_OUTPUT: RPI_HEADPHONES +``` + +The `PULSE_SERVER` host must match the audio service name. It defaults to `audio`; if you rename the +service, update the env var accordingly (e.g. `ENV PULSE_SERVER=tcp:not-audio:4317`). See the audio +block's [environment variables](https://github.com/balena-labs-projects/audio#environment-variables) +for the full `AUDIO_OUTPUT` vocabulary (`RPI_HDMI0`, `RPI_HDMI1`, `DAC`, `USB`, `AUTO`, …). + +> The `audio` block is **unmaintained** (no updates or testing in ~4 years). The supported, tested +> path is the default ALSA-direct one; use the audio block at your own risk. It is also the only way +> to get Bluetooth audio output, which the default ALSA-direct path does not support. diff --git a/readme.md b/readme.md index 285446a..b479bc3 100644 --- a/readme.md +++ b/readme.md @@ -122,25 +122,40 @@ services: ``` --- -## Choosing audio output device -By default the `browser` block will output audio via HDMI. If you want to route audio through a different interface you can do it with the help of the [`audio` block]((https://github.com/balena-labs-projects/audio)). The `browser` block is pre-configured to use it if present so you only need to add it to your `docker-compose.yml` file and then use `AUDIO_OUTPUT` environment variable to select the desired output. Check out the `audio` block [documentation](https://github.com/balena-labs-projects/audio#environment-variables) to learn more about it. - -In this example we add the `audio` block and route the `browser` audio to the Raspberry Pi headphone jack: - -```yaml -services: - browser: - image: bh.cr/balenalabs/browser- - audio: - image: bh.cr/balenalabs/audio- - privileged: true - ports: - - 4317:4317 - environment: - AUDIO_OUTPUT: RPI_HEADPHONES +## Audio + +The `browser` block plays audio **directly via ALSA** to the device's sound +hardware — no extra container or configuration is required. The block +automatically grants its unprivileged `chromium` user access to the kernel sound +devices (`/dev/snd/*`); see [`src/start.sh`](src/start.sh). `privileged: true` +(already set in the example `docker-compose.yml`) is required so those devices are +visible to the container. + +In practice audio is emitted on the device's active output. For example, with an +HDMI screen connected the sound travels over HDMI, and the 3.5mm headphone jack +works when used (verified on a Raspberry Pi 4). The kernel/ALSA default decides +which output is used; the browser block does not currently expose a knob to +select a specific output. + +To force a specific output without any additional container, you can bake an +[`/etc/asound.conf`](https://www.alsa-project.org/wiki/Asoundrc) into a derived +image that pins ALSA's default device to the card you want — for example the +Raspberry Pi 4 headphone jack: + +```Dockerfile +FROM bh.cr/balenalabs/browser- +RUN printf 'pcm.!default {\n type plug\n slave.pcm "hw:Headphones"\n}\nctl.!default {\n type hw\n card Headphones\n}\n' > /etc/asound.conf ``` -**Note**: The `browser` block expects the `audio` block to be named as such. If you change it's service name you'll need to override the `PULSE_SERVER` environment variable value to match it in the `browser` dockerfile. For example add `ENV PULSE_SERVER=tcp:not-audio:4317`. +Use the card name as reported by `aplay -l` (e.g. `Headphones`, `vc4hdmi0`, +`vc4hdmi1`); names are more stable across reboots than numeric indices. + +For richer routing — selecting a specific sink at runtime, Bluetooth output, or +sharing audio across multiple containers — run a dedicated sound server such as +the [`audio` block](https://github.com/balena-labs-projects/audio), PipeWire, or +similar, and point the browser at it. This is no longer wired in by default; if +you are migrating from v2 and want to retain the audio block, see +[Migrating from v2 → Audio](docs/migrating-from-v2.md#8-audio-no-longer-pre-wired-to-the-audio-block). --- diff --git a/src/start.sh b/src/start.sh index da3a1e6..cd3107f 100644 --- a/src/start.sh +++ b/src/start.sh @@ -5,8 +5,6 @@ set -e # Enable user namespaces for Chromium's internal sandbox architecture sysctl -w user.max_user_namespaces=10000 || true -export DBUS_SYSTEM_BUS_ADDRESS=unix:path=/host/run/dbus/system_bus_socket - echo "balenaLabs browser version: $( Date: Sun, 21 Jun 2026 17:31:51 +0400 Subject: [PATCH 09/18] Restart Chromium when the display socket is recreated The display block deletes and recreates the Wayland socket on every restart, which left Chromium disconnected. start.sh now supervises the session and relaunches it when the socket's inode changes. Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- src/start.sh | 46 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/src/start.sh b/src/start.sh index cd3107f..dd15071 100644 --- a/src/start.sh +++ b/src/start.sh @@ -20,17 +20,6 @@ WAYLAND_SOCKET="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" echo "Targeting Wayland compositor socket: ${WAYLAND_SOCKET}" -# Block until the Display Sidecar container mounts the socket -echo "Waiting for display server to expose the Wayland socket..." -until [ -S "${WAYLAND_SOCKET}" ]; do - sleep 1 -done -echo "Wayland socket detected! Establishing connection permissions." - -# Apply permissive permissions so the unprivileged 'chromium' user can read/write to the socket -chmod 777 "${XDG_RUNTIME_DIR}" || true -chmod 666 "${WAYLAND_SOCKET}" || true - # Initialize user-data storage context mkdir -p /data/chromium chown -R chromium:chromium /data || true @@ -64,6 +53,35 @@ for dev in /dev/dri/card* /dev/dri/render* /dev/video* /dev/snd/*; do echo "Granted chromium access to ${dev} (gid ${node_gid}, group ${node_grp})" done -# Launch the Node Management Service as the non-root 'chromium' user -echo "Starting Node.js server session..." -exec su -w "$environment" chromium -c "node /usr/src/app/server.js" \ No newline at end of file +# Supervise the browser session and reconnect whenever the display block restarts. +# The display block deletes and recreates the Wayland socket on every restart; we run +# as root here, so we can re-apply the socket permissions and relaunch Chromium each +# time. We poll the socket's INODE (not just its existence) so a delete+recreate that +# happens between two polls is still detected (the new socket has a different inode). +while true; do + echo "Waiting for display server to expose the Wayland socket..." + until [ -S "${WAYLAND_SOCKET}" ]; do sleep 1; done + echo "Wayland socket detected. Applying connection permissions." + + # Apply permissive permissions so the unprivileged 'chromium' user can reach the (new) socket + chmod 777 "${XDG_RUNTIME_DIR}" || true + chmod 666 "${WAYLAND_SOCKET}" || true + connected_socket_inode=$(stat -c %i "${WAYLAND_SOCKET}") + + # Launch the Node Management Service as the non-root 'chromium' user. + # setsid gives it its own process group so we can reap node + Chromium together. + echo "Starting Node.js server session..." + setsid su -w "$environment" chromium -c "node /usr/src/app/server.js" & + browser_session_pid=$! + + # Run until the session exits OR the socket is replaced/removed (display restarted). + # A deleted socket makes stat fail -> empty string -> inode mismatch -> loop exits. + while kill -0 "${browser_session_pid}" 2>/dev/null \ + && [ "$(stat -c %i "${WAYLAND_SOCKET}" 2>/dev/null)" = "${connected_socket_inode}" ]; do + sleep 1 + done + + echo "Display socket changed or session ended; restarting browser session." + kill -- -"${browser_session_pid}" 2>/dev/null || true # reap node + Chromium (process group) + wait 2>/dev/null || true +done \ No newline at end of file From bf745dea18e276d7b9afb121d29c078fa6fb4df3 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Sun, 21 Jun 2026 19:20:20 +0400 Subject: [PATCH 10/18] Enable Intel VA-API hardware video decode on generic-amd64 mesa-va-drivers only provides VA-API for AMD/gallium, so on Intel devices libva exposed zero decode profiles and Chromium fell back to software for every codec. Install Intel's own VA driver (intel-media-va-driver for Gen8+, i965-va-driver for older parts) on amd64; libva auto-selects the right one per GPU. Also bundle vainfo and surface its output in the diagnostics report (plus a /diagnostics/vainfo endpoint) so VA profiles can be inspected directly. Verified on i3-7100U (Kaby Lake): H.264 and HEVC now decode via VaapiVideoDecoder (hardware: true). Change-type: minor Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- build/install_chromium | 15 ++++++++++++++- readme.md | 8 +++++++- src/diagnostics.js | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/build/install_chromium b/build/install_chromium index 7d3bfe5..2d08770 100755 --- a/build/install_chromium +++ b/build/install_chromium @@ -28,9 +28,22 @@ fi # which is not provided by the upstream package ln -sf /usr/bin/chromium /usr/bin/chromium-browser +# Intel GPUs need Intel's own VA-API driver for hardware video decode: mesa-va-drivers +# only provides VA-API for AMD/gallium, so on Intel libva exposes zero decode profiles +# and Chromium falls back to software for every codec. These packages are x86-only +# (Intel hardware), so we only add them on amd64; AMD x86 keeps using mesa-va-drivers. +# - intel-media-va-driver (iHD): Gen8+ (covers e.g. Kaby Lake, Raptor Lake) +# - i965-va-driver: pre-Gen8 Intel +# Both live in Debian main and handle H.264/HEVC decode (no non-free codecs needed). +# vainfo (libva-utils) is bundled so the diagnostics report can enumerate VA profiles. +ARCH_PACKAGES="" +if [ "$(dpkg --print-architecture)" = "amd64" ]; then + ARCH_PACKAGES="intel-media-va-driver i965-va-driver vainfo" +fi + # Install hardware acceleration libraries and base system libraries -# Notice the inclusion of $PLATFORM_SPECIFIC_PACKAGES at the end of the package list apt-get update && apt-get install -y --no-install-recommends \ + ${ARCH_PACKAGES} \ chromium \ chromium-common \ ca-certificates \ diff --git a/readme.md b/readme.md index b479bc3..b0db665 100644 --- a/readme.md +++ b/readme.md @@ -265,6 +265,10 @@ Returns Chromium's GPU feature status, drivers and active backend as JSON (the s Returns the decoder used by any active media player, including whether it is hardware-accelerated — useful for confirming hardware video decode (e.g. `V4L2VideoDecoder`). +#### **GET** /diagnostics/vainfo +Returns the raw `vainfo` output as plain text, listing the VA-API profiles the installed driver +exposes. `vainfo` is only bundled on `generic-amd64`; other images report that it is not installed. + --- ## Remote debugging @@ -340,7 +344,9 @@ What to expect per target (with `ENABLE_GPU=1`): with `isHardwareAccelerated: true`. - **Raspberry Pi 5** — the video block is HEVC-only and the distro Chromium ships without proprietary codecs, so H.264 falls back to **software decode**. GPU rendering still works. -- **Generic x86_64 (Intel/AMD)** — VA-API decode via `mesa-va-drivers` (already bundled). +- **Generic x86_64 (Intel/AMD)** — VA-API hardware decode (e.g. H.264 shows as `VaapiVideoDecoder` + with `hardware: true` in `chrome://media-internals`). AMD uses `mesa-va-drivers`; Intel uses its + own driver (`intel-media-va-driver`/iHD for Gen8+, `i965-va-driver` for older parts). - **Generic AARCH64** — software video decode (no guaranteed kernel decoder). ## Troubleshooting diff --git a/src/diagnostics.js b/src/diagnostics.js index df3d6fc..56746cb 100644 --- a/src/diagnostics.js +++ b/src/diagnostics.js @@ -4,15 +4,16 @@ * Everything here is gated behind ENABLE_DIAGNOSTICS (default off) because it * exposes internal Chromium/GPU/system state. When enabled it: * - captures recent block + Chromium logs, - * - exposes the existing /diagnostics/{version,gpu,media} JSON endpoints, + * - exposes the /diagnostics/{version,gpu,media,vainfo} endpoints, * - adds /diagnostics/report: a single, human-readable .txt bundling - * device/host info, runtime config, Chromium/GPU/media state and recent logs - * (useful for testers now and end-user bug reports later). + * device/host info, runtime config, Chromium/GPU/media/VA-API state and + * recent logs (useful for testers now and end-user bug reports later). */ const fs = require('fs'); const os = require('os'); const util = require('util'); +const { execFile } = require('child_process'); const CDP = require('chrome-remote-interface'); const ENABLE_DIAGNOSTICS = process.env.ENABLE_DIAGNOSTICS || '0'; @@ -147,6 +148,27 @@ async function getMediaState(port) { } } +/** + * Raw `vainfo` output: the VA-API profiles the installed driver exposes (e.g. + * VAProfileH264* decode entries on Intel). Independent of whether a video is + * playing, so it confirms driver/codec support directly. vainfo is only + * installed on amd64 (Intel/AMD); elsewhere it ENOENTs and we say so. Never + * rejects — returns the raw text (or a note) so the report always has a section. + */ +function getVaInfo() { + return new Promise((resolve) => { + execFile('vainfo', { timeout: 5000 }, (err, stdout, stderr) => { + if (err && err.code === 'ENOENT') { + return resolve('(vainfo not installed on this platform)'); + } + // vainfo can exit non-zero yet still print useful diagnostics, so keep + // whatever it wrote rather than discarding it on error. + const output = `${stdout || ''}${stderr || ''}`.trim(); + resolve(output || `(vainfo produced no output${err ? `: ${err.message}` : ''})`); + }); + }); +} + /** * Device and host facts that don't need Chromium. Only a whitelist of balena * env vars is read — process.env is never dumped, so a shared report can't leak @@ -294,6 +316,7 @@ function formatReport(data) { section('RUNTIME CONFIG', formatRuntime(data.runtime)), section('CHROMIUM', formatChromium(data.chromium)), section('GPU (chrome://gpu)', formatGpu(data.gpu)), + section('VA-API (vainfo)', data.vainfo || '(not collected)'), section('MEDIA PLAYERS', formatMedia(data.media)), section('RECENT BLOCK LOG', data.log.block.length ? data.log.block.join('\n') : '(empty)'), section('RECENT CHROMIUM LOG', data.log.chromium) @@ -361,6 +384,12 @@ function register(app, { debugPort, getRuntimeConfig }) { } }); + // Raw `vainfo` output: VA-API profiles the installed driver exposes. + app.get('/diagnostics/vainfo', diagnosticsGuard, async (req, res) => { + res.type('text/plain'); + return res.status(200).send(await getVaInfo()); + }); + // Single downloadable, human-readable report bundling everything above. app.get('/diagnostics/report', diagnosticsGuard, async (req, res) => { const generatedAt = new Date().toISOString().replace(/:/g, '').replace(/\.\d+Z$/, 'Z'); @@ -374,6 +403,7 @@ function register(app, { debugPort, getRuntimeConfig }) { chromium: null, gpu: null, media: null, + vainfo: await getVaInfo(), log: { block: logBuffer.slice(), chromium: tailChromiumLog() } }; From f913d054748ce92253c44a71eefeb682743ac41a Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Sun, 21 Jun 2026 19:27:48 +0400 Subject: [PATCH 11/18] docs: Add design note on forcing H.264 for YouTube Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/todo-h264-force-youtube.md | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/todo-h264-force-youtube.md diff --git a/docs/todo-h264-force-youtube.md b/docs/todo-h264-force-youtube.md new file mode 100644 index 0000000..0f64496 --- /dev/null +++ b/docs/todo-h264-force-youtube.md @@ -0,0 +1,80 @@ +# TODO: Allow forcing H.264 for hardware decode on sites that provide multiple codecs per video (e.g. YouTube) + +Hardware video decode now works on Intel `generic-amd64` (iHD/i965 VA-API drivers added in +`build/install_chromium`). But many sites — YouTube most notably — prefer codecs that *aren't* +hardware-decodable on most of our targets, so playback still falls back to software even though the +device has a perfectly good hardware decoder for H.264. This note records the problem and the options +so we don't re-investigate from scratch. + +## The problem + +Codec hardware-decode availability across our targets, worst to best: + +| Codec | Hardware decode availability | +|-------|------------------------------| +| **H.264** | Near-universal — every Intel iGPU, the Pi's `bcm2835-codec`, virtually all SoCs. | +| **VP9** | Intel Gen9+ (e.g. Kaby Lake NUC shows `VAProfileVP9Profile0/2`), but **absent** on the Pi, generic arm64, and older x86. | +| **AV1** | Rare — Intel Gen11+ (Tiger Lake/Raptor Lake) only; most ARM SoCs lack it entirely. | + +YouTube's codec preference is the **inverse**: **AV1 > VP9 > H.264**. So on a typical device YouTube +negotiates AV1 (or VP9, where the device can't hardware-decode it) and Chromium decodes in software +(`Dav1dVideoDecoder` for AV1, `libvpx` for VP9) — burning CPU — while the idle hardware decoder goes +unused. + +This is a codec-**selection** problem, not a capability problem. On the Kaby Lake i3 NUC, Chromium +hardware-decodes VP9 and HEVC and H.264 directly (verified: a direct VP9 `.webm` reports +`decoderName: VaapiVideoDecoder`, `kIsPlatformVideoDecoder: true`, `vp9 profile0 1920x1080`). Yet +YouTube still serves AV1 and decodes it in software. The likely reason: `chrome://gpu`'s +`videoDecoding` array is **empty** even though `VaapiVideoDecoder` clearly works, so the page-facing +`navigator.mediaCapabilities.decodingInfo` reports VP9/AV1 as **not `powerEfficient`**. YouTube +weights selection on `smooth` (can the device keep up) plus its AV1 > VP9 > H.264 preference — not on +power efficiency — and an i3 software-plays AV1 "smoothly," so it picks AV1. The decoder's real +ability and the capability *advertised to the page* are disconnected; that gap is the problem. + +Use `/diagnostics/report` to see it: the `vainfo` VA profile list (what the device *can* do in +hardware) vs. the MEDIA PLAYERS decoder name (what it *actually* used). + +Forcing H.264 flips this for the broadest device coverage. But note we don't always need H.264 +*specifically*: on a device with hardware VP9 (like the Kaby Lake i3 above), stripping **only AV1** +lets YouTube fall back to VP9, which already decodes in hardware there. H.264-forcing is only required +on devices **without** hardware VP9 (the Pi, generic arm64, older x86). This argues for a per-device +strip list rather than a blanket "H.264 only." Trade-off if we do force H.264: YouTube caps it at +1080p (no 4K H.264) and it's slightly less bandwidth-efficient — generally acceptable for a +video/kiosk browser, and far better than dropped frames from software AV1. + +## Options + +### 1. h264ify (or enhanced-h264ify) browser extension +A maintained extension that hides VP9/AV1 from YouTube so it serves H.264. + +- **Pros:** ready-made, well-tested, YouTube-specific edge cases already handled. +- **Cons:** third-party dependency we'd have to trust/pin/update; needs extension loading into our + kiosk Chromium (`--load-extension=` or via the existing `/etc/chromium/policies` mechanism), which + we don't currently do; YouTube-only. + +### 2. Custom in-page override (do it ourselves) +Inject a small content script that reports VP9/AV1 as unsupported so the page picks H.264. Hook the +codec-negotiation entry points: + +- `MediaSource.isTypeSupported` / `ManagedMediaSource.isTypeSupported` +- `HTMLMediaElement.prototype.canPlayType` +- `navigator.mediaCapabilities.decodingInfo` (return `supported:false` / `powerEfficient:false` for + `vp9`/`av01` codec strings) + +- **Pros:** no third-party code; can be made **generic** (any site, not just YouTube); tiny and + auditable; we control the rollout (could gate behind an env var, e.g. `FORCE_H264=1`). +- **Cons:** we maintain it as sites evolve; must be careful not to claim a codec is unsupported when + it's the *only* one the site offers (could break playback); per-device gating is ideal since + forcing H.264 on a device that *does* have hardware VP9/AV1 (e.g. Gen13 Intel) is a downgrade. + +## Recommendation (to validate later) + +Lean toward **option 2** behind an opt-in env var, injected only where it helps. Decide whether to +apply it unconditionally or per-device using the VA profile list (only strip codecs the device can't +decode in hardware). Verify the result the same way as the VA-API fix: `/diagnostics/report` should +then show `VaapiVideoDecoder` / `hardware: true` on a YouTube tab instead of `Dav1dVideoDecoder`. + +## Notes +- The VA-API hardware-decode groundwork (drivers + `vainfo` in the diagnostics report) already landed; + this is purely about *which codec the page chooses*, not whether the hardware can decode it. +- Per-device codec truth is in `/diagnostics/report` under `VA-API (vainfo)`. From 036f235bbe42f0a43660a317317e22e54c7bf07f Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Thu, 1 Oct 2026 11:23:15 +0400 Subject: [PATCH 12/18] Point docs and block metadata at balenasolutions Use bh.cr/balenasolutions for v3 image references, update the balena.yml usage snippet for the display block, point the repository, logo and package.json URLs at balenasolutions/browser, and drop the balenalabs name from the default landing page. Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- balena.yml | 50 +++++++++++++++++++++++---------------- docs/migrating-from-v2.md | 14 +++++------ package.json | 6 ++--- public-html/index.html | 6 ++--- readme.md | 14 +++++------ 5 files changed, 49 insertions(+), 41 deletions(-) diff --git a/balena.yml b/balena.yml index a868217..6ace093 100644 --- a/balena.yml +++ b/balena.yml @@ -4,45 +4,53 @@ description: >- connected display post-provisioning: >- - [![balena](https://github.com/balena-labs-projects/browser/actions/workflows/balena.yml/badge.svg)](https://github.com/balena-labs-projects/browser/actions/workflows/balena.yml) - ## Usage instructions - Include this snippet in your docker-compose.yml file under 'services': - + The browser block renders through a companion display (compositor) block. Include this + snippet in your docker-compose.yml file under 'services', and declare the `display-socket` + (and, if using PERSISTENT, `settings`) volumes: + ``` + display: + image: bh.cr/balenasolutions/display- # see the display block README for the image name + privileged: true + volumes: + - display-socket:/run + labels: + io.balena.features.dbus: '1' + browser: - image: bh.cr/balenalabs/browser- # where is one of aarch64, arm32 or amd64 + image: bh.cr/balenasolutions/browser- # one of raspberrypi3-64, raspberrypi4-64, raspberrypi5, generic-aarch64, generic-amd64 privileged: true # required for UDEV to find plugged in peripherals such as a USB mouse - network_mode: host + depends_on: + - display + environment: + XDG_RUNTIME_DIR: /run/user/0 + WAYLAND_DISPLAY: wayland-0 + devices: + - /dev/dri:/dev/dri + ports: + - '5011:5011' # management API volumes: - - 'settings:/data' # Only required if using PERSISTENT flag (see below) + - display-socket:/run + - 'settings:/data' # Only required if using PERSISTENT flag ``` - To pin to a specific [version](CHANGELOG.md) of this block use: + To pin to a specific version of this block, append it to the image, e.g. + `bh.cr/balenasolutions/browser-/`. - ``` - browser: - image: bh.cr/balenalabs/browser-/ - privileged: true # required for UDEV to find plugged in peripherals such as a USB mouse - network_mode: host - ports: - - '5011' # management API (optional) - - '35173' # Chromium debugging port (optional) - volumes: - - 'settings:/data' # Only required if using PERSISTENT flag (see below) - ``` + Upgrading from v2? See docs/migrating-from-v2.md in the repository. version: 2.12.2 type: sw.block assets: repository: type: blob.asset data: - url: 'https://github.com/balena-labs-projects/browser' + url: 'https://github.com/balenasolutions/browser' logo: type: blob.asset data: - url: 'https://raw.githubusercontent.com/balena-labs-projects/browser/master/logo.png' + url: 'https://raw.githubusercontent.com/balenasolutions/browser/master/logo.png' data: defaultDeviceType: raspberrypi4-64 supportedDeviceTypes: diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index 086574d..5de5fc4 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -22,7 +22,7 @@ volumes: services: display: - image: bh.cr/balenalabs/display- # companion compositor block; see its README for the image name + image: bh.cr/balenasolutions/display- # companion compositor block; see its README for the image name privileged: true volumes: - display-socket:/run @@ -30,7 +30,7 @@ services: io.balena.features.dbus: '1' browser: - image: bh.cr/balenalabs/browser- + image: bh.cr/balenasolutions/browser- privileged: true depends_on: - display @@ -47,8 +47,8 @@ services: ## 2. New image namespace: per device type, not per architecture -v2 published one image per architecture (`browser-aarch64`, `browser-amd64`, `browser-arm32`). v3 -publishes one image **per device type**: +v2 published one image per architecture (`browser-aarch64`, `browser-amd64`, `browser-arm32`) under +`bh.cr/balenalabs`. v3 publishes one image **per device type** under `bh.cr/balenasolutions`: | v2 image | v3 image | | --- | --- | @@ -138,7 +138,7 @@ After (v3 — rotation on the display block): ```yaml services: display: - image: bh.cr/balenalabs/display- + image: bh.cr/balenasolutions/display- privileged: true volumes: - display-socket:/run @@ -149,7 +149,7 @@ services: DISPLAY_RESOLUTION: 1920x1080 browser: - image: bh.cr/balenalabs/browser- + image: bh.cr/balenasolutions/browser- # ... no rotation/geometry variables here anymore ``` @@ -173,7 +173,7 @@ extending the browser image. Add the ALSA→PulseAudio bridge and point it at th exactly what the v2 image baked in): ```Dockerfile -FROM bh.cr/balenalabs/browser- +FROM bh.cr/balenasolutions/browser- # Route ALSA output to the audio block's PulseAudio server RUN curl -skL https://raw.githubusercontent.com/balena-labs-projects/audio/master/scripts/alsa-bridge/debian-setup.sh | sh ENV PULSE_SERVER=tcp:audio:4317 diff --git a/package.json b/package.json index b90c8bf..c677247 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/balena-labs-projects/browser.git" + "url": "git+https://github.com/balenasolutions/browser.git" }, "private": true, "keywords": [ @@ -22,9 +22,9 @@ "esversion": 8 }, "bugs": { - "url": "https://github.com/balena-labs-projects/browser/issues" + "url": "https://github.com/balenasolutions/browser/issues" }, - "homepage": "https://github.com/balena-labs-projects/browser#readme", + "homepage": "https://github.com/balenasolutions/browser#readme", "dependencies": { "bent": "7.3.10", "body-parser": "^1.19.0", diff --git a/public-html/index.html b/public-html/index.html index 2e7aeef..76079c1 100644 --- a/public-html/index.html +++ b/public-html/index.html @@ -8,7 +8,7 @@ - balenalabs-browser + browser @@ -32,7 +32,7 @@
-

balenalabs-browser

+

browser

A balena block for browsing HTML

@@ -48,7 +48,7 @@

Choosing what to display

  • You can set a Device Variable called LAUNCH_URL
  • You can use the API on port 5011 to set the URL (see project readme for details)
  • -
    ** The balenalabs-browser service will need network_mode: host set.
    +
    ** The browser service will need network_mode: host set.
    diff --git a/readme.md b/readme.md index b0db665..0352cbc 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ -# balena-labs-projects/browser +# browser Provides a hardware accelerated web browser to present internal and external URLs on a connected display. -The `browser` block is a docker image that runs a [Chromium](https://www.chromium.org/Home) browser as a [Wayland](https://wayland.freedesktop.org/) client, optimized for balenaOS. +The `browser` [block](https://docs.balena.io/learn/develop/blocks) is a docker image that runs a [Chromium](https://www.chromium.org/Home) browser as a [Wayland](https://wayland.freedesktop.org/) client, optimized for balenaOS. It renders through a companion **display** (compositor) block, and provides an API for dynamic configuration. > **Upgrading from v2?** v3 moves from X11 to Wayland and changes the image namespace. See the @@ -39,7 +39,7 @@ volumes: services: display: - image: bh.cr/balenalabs/display- # companion compositor block; see its README for the image name + image: bh.cr/balenasolutions/display- # companion compositor block; see its README for the image name privileged: true volumes: - display-socket:/run @@ -47,7 +47,7 @@ services: io.balena.features.dbus: '1' browser: - image: bh.cr/balenalabs/browser- # e.g. raspberrypi4-64, raspberrypi5, generic-amd64 + image: bh.cr/balenasolutions/browser- # e.g. raspberrypi4-64, raspberrypi5, generic-amd64 privileged: true # required for UDEV to find plugged in peripherals such as a USB mouse depends_on: - display @@ -64,7 +64,7 @@ services: ``` To pin to a specific [version](CHANGELOG.md) of this block, append the version to the image, e.g. -`bh.cr/balenalabs/browser-/`. +`bh.cr/balenasolutions/browser-/`. See [here](https://github.com/balena-io/open-balena-registry-proxy#usage) for more details about how to use blocks hosted in balenaCloud. @@ -110,7 +110,7 @@ volumes: services: browser: restart: always - image: bh.cr/balenalabs/browser- + image: bh.cr/balenasolutions/browser- privileged: true volumes: - 'settings:/data' @@ -143,7 +143,7 @@ image that pins ALSA's default device to the card you want — for example the Raspberry Pi 4 headphone jack: ```Dockerfile -FROM bh.cr/balenalabs/browser- +FROM bh.cr/balenasolutions/browser- RUN printf 'pcm.!default {\n type plug\n slave.pcm "hw:Headphones"\n}\nctl.!default {\n type hw\n card Headphones\n}\n' > /etc/asound.conf ``` From 18e9544b9e8b300e1d24af52cf5b2f828562f98d Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Thu, 1 Oct 2026 18:25:36 +0400 Subject: [PATCH 13/18] docs: Add architecture diagram and link the display block Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- balena.yml | 2 +- docs/migrating-from-v2.md | 2 +- readme.md | 62 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/balena.yml b/balena.yml index 6ace093..e16610c 100644 --- a/balena.yml +++ b/balena.yml @@ -12,7 +12,7 @@ post-provisioning: >- ``` display: - image: bh.cr/balenasolutions/display- # see the display block README for the image name + image: bh.cr/balenasolutions/display- # is aarch64 or amd64; see https://github.com/balenasolutions/display privileged: true volumes: - display-socket:/run diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index 5de5fc4..240aa52 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -22,7 +22,7 @@ volumes: services: display: - image: bh.cr/balenasolutions/display- # companion compositor block; see its README for the image name + image: bh.cr/balenasolutions/display- # is aarch64 or amd64; see https://github.com/balenasolutions/display privileged: true volumes: - display-socket:/run diff --git a/readme.md b/readme.md index 0352cbc..8d6093c 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,7 @@ It renders through a companion **display** (compositor) block, and provides an A - Automatically displays local HTTP (port 80 or 8080) or HTTPS (443) service endpoints. - API for remote configuration and management - Optional remote debugging from another host + --- ## Usage @@ -39,7 +40,7 @@ volumes: services: display: - image: bh.cr/balenasolutions/display- # companion compositor block; see its README for the image name + image: bh.cr/balenasolutions/display- # is aarch64 or amd64; see https://github.com/balenasolutions/display privileged: true volumes: - display-socket:/run @@ -349,6 +350,65 @@ What to expect per target (with `ENABLE_GPU=1`): own driver (`intel-media-va-driver`/iHD for Gen8+, `i965-va-driver` for older parts). - **Generic AARCH64** — software video decode (no guaranteed kernel decoder). +--- + +## Architecture + +The `browser` block is a Wayland client. It does not drive the screen itself: the +[**display** block](https://github.com/balenasolutions/display) runs the Weston compositor, which owns the outputs and input devices. Weston creates the Wayland +socket on a volume mounted at `/run` in both containers, and Chromium connects to it. Chromium +still renders with the GPU and decodes video in hardware itself, then hands finished frames to +the compositor. + +```mermaid +flowchart TB + subgraph BrowserBlock ["browser block (Wayland client)"] + Start["start.sh
    grants device access,
    waits for the socket,
    restarts if it is recreated"] + Server["server.js (Node.js)
    management API :5011"] + Chromium["Chromium
    --ozone-platform=wayland"] + + Start -- "runs as chromium user" --> Server + Server -- "chrome-launcher" --> Chromium + end + + subgraph SharedVolume ["display-socket volume (/run)"] + Socket[("/run/user/0/
    wayland-0")] + end + + subgraph DisplayBlock ["display block (Wayland compositor)"] + Config["weston.ini generated from
    DISPLAY_* variables
    (or WESTON_INI_PATH)"] + Weston["Weston"] + + Config -- "configures" --> Weston + end + + subgraph Hardware ["device hardware"] + Sound["sound card
    /dev/snd"] + VDec["video decoder
    /dev/video* (Pi)"] + GPU["GPU / DRM
    /dev/dri"] + Input["input devices
    touch, mouse, keyboard"] + Outputs["physical displays"] + end + + Chromium -- "Wayland protocol" --> Socket + Socket -- "client connection" --> Weston + Chromium -- "ALSA audio" --> Sound + Chromium -- "V4L2 decode" --> VDec + Chromium -- "GPU rendering,
    VA-API decode (x86)" --> GPU + Weston -- "DRM/KMS" --> GPU + Weston -- "reads" --> Input + GPU -- "scans out to" --> Outputs + + classDef block fill:#ffffff,stroke:#333333,stroke-width:2px,color:#333333; + classDef hardware fill:#e1f5fe,stroke:#0288d1,stroke-width:2px,color:#333333; + classDef volume fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#333333; + + class BrowserBlock,DisplayBlock block; + class Hardware hardware; + class SharedVolume volume; +``` +--- + ## Troubleshooting This section provides some guidance for common issues encountered: From f8638c8e2e69024bbef3bb041a593f5f9acb623a Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Thu, 1 Oct 2026 18:44:07 +0400 Subject: [PATCH 14/18] Fix /version always returning 'Version not set' start.sh printed the VERSION file but never exported it, so server.js had nothing to serve on /version or in the diagnostics report. Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- src/start.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/start.sh b/src/start.sh index dd15071..dcc6c6d 100644 --- a/src/start.sh +++ b/src/start.sh @@ -5,7 +5,10 @@ set -e # Enable user namespaces for Chromium's internal sandbox architecture sysctl -w user.max_user_namespaces=10000 || true -echo "balenaLabs browser version: $( Date: Thu, 1 Oct 2026 18:44:12 +0400 Subject: [PATCH 15/18] docs: Restructure API reference and use GitHub alerts Add an endpoint overview, group endpoints, and correct /gpu and /kiosk to POST. Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- readme.md | 186 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 109 insertions(+), 77 deletions(-) diff --git a/readme.md b/readme.md index 8d6093c..7e04770 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,7 @@ Provides a hardware accelerated web browser to present internal and external URL The `browser` [block](https://docs.balena.io/learn/develop/blocks) is a docker image that runs a [Chromium](https://www.chromium.org/Home) browser as a [Wayland](https://wayland.freedesktop.org/) client, optimized for balenaOS. It renders through a companion **display** (compositor) block, and provides an API for dynamic configuration. +> [!IMPORTANT] > **Upgrading from v2?** v3 moves from X11 to Wayland and changes the image namespace. See the > [v2 → v3 migration guide](docs/migrating-from-v2.md). @@ -91,11 +92,12 @@ The following environment variables allow configuration of the `browser` block: |`AUTO_REFRESH`|interval|0 (disabled)|Specifies the number of seconds before the page automatically refreshes| |`ENABLE_DIAGNOSTICS`|`0`, `1`|`0`|Enables the `/diagnostics/*` API endpoints, which expose Chromium version, GPU and media-decoder state. Off by default.
    `0` = off, `1` = on| +> [!IMPORTANT] > **Display geometry (rotation, resolution, scale) is configured on the `display` block, > not here.** In v3 the browser is a Wayland client and the compositor owns the screen, so the v2 > `ROTATE_DISPLAY`, `ROTATE_DELAY`, `TOUCHSCREEN`, `WINDOW_SIZE`, `WINDOW_POSITION`, `SHOW_CURSOR` and > `DISPLAY_NUM` variables no longer apply. Set `DISPLAY_ROTATION` / `DISPLAY_RESOLUTION` / -> `DISPLAY_SCALE` on the `display` service instead — see its README and +> `DISPLAY_SCALE` on the `display` service instead — see [its README](https://github.com/balenasolutions/display#display-geometry--rotation) and > [Migrating from v2](docs/migrating-from-v2.md#7-screen-rotation--display-geometry-moved-to-the-display-block). --- @@ -161,114 +163,140 @@ you are migrating from v2 and want to retain the audio block, see --- ## API -The `browser` block exposes an HTTP API running on port 5011. The following endpoints are available: -#### **GET** /ping -Returns HTTP 200 if the `browser` block is ready +The `browser` block serves an HTTP API on port `5011` (set `API_PORT` to change it). The examples +call it from another machine at ``; on the device itself, use `localhost`. -#### **POST** /refresh -Refreshes the currently displayed page +| Method | Endpoint | Description | +| --- | --- | --- | +| `GET` | [`/ping`](#get-ping) | Health check | +| `GET` | [`/url`](#get-url) | Current URL | +| `POST` | [`/url`](#post-url) | Display a URL | +| `POST` | [`/refresh`](#post-refresh) | Reload the page | +| `POST` | [`/autorefresh/{interval}`](#post-autorefreshinterval) | Reload the page on a timer | +| `POST` | [`/scan`](#post-scan) | Look again for a local web service | +| `GET` | [`/kiosk`](#get-kiosk) | Kiosk mode state | +| `POST` | [`/kiosk/{value}`](#post-kioskvalue) | Turn kiosk mode on or off | +| `GET` | [`/gpu`](#get-gpu) | GPU acceleration state | +| `POST` | [`/gpu/{value}`](#post-gpuvalue) | Turn GPU acceleration on or off | +| `GET` | [`/flags`](#get-flags) | Chromium launch flags | +| `GET` | [`/version`](#get-version) | Block version | +| `GET` | [`/screenshot`](#get-screenshot) | PNG of the current page | +| `GET` | [`/diagnostics/*`](#diagnostics) | Troubleshooting data (off by default) | -#### **POST** /autorefresh/{interval} -Automatically refreshes the browser window +### Page -| Value | Description | -|--------------|-------------| -| 0 | disable | -| 1-60 | refresh every `interval` seconds | +#### `GET /ping` -#### **POST** /scan -Re-scans the device to find local HTTP or HTTPS services to display. This can be used by the HTTP/S service to notify the `browser` block that it is ready to be displayed, should there be a startup race. +Returns `200 ok` once the API is running. Use it as a readiness check. -note: *the* `LAUNCH_URL` *must not be set for local services to be detected.* +#### `GET /url` -#### **GET** /url -Returns the URL currently being displayed +Returns the URL currently on screen. -#### **POST** /url -Sets the URL to be displayed. The URL is set in the request body. Example: +#### `POST /url` + +Displays a new URL. Send the parameters as form data or JSON. + +| Parameter | Required | Description | +| --- | --- | --- | +| `url` | yes | Page to display. `http://` is added if the URL has no scheme. | +| `kiosk` | no | `1` turns kiosk mode on, `0` turns it off. | +| `gpu` | no | `1` turns GPU acceleration on, `0` turns it off. | + +Changing `kiosk` or `gpu` restarts Chromium; otherwise the page changes in place. A request +without `url` returns `400`. ```bash -curl -X POST --data "url=www.balena.io" http://localhost:5011/url +curl -X POST --data "url=www.balena.io" http://:5011/url +curl -X POST --data "url=www.balena.io&gpu=0&kiosk=1" http://:5011/url ``` -You can also pre-set the kiosk and GPU settings as part of a URL put request. Example: +#### `POST /refresh` + +Reloads the current page. + +#### `POST /autorefresh/{interval}` + +Reloads the page every `interval` seconds. `0` turns automatic refresh off. ```bash -curl --data "url=www.balena.io&gpu=0&kiosk=1" http://localhost:5011/url +curl -X POST http://:5011/autorefresh/30 ``` -#### **GET** /gpu -Returns the status of the GPU: +#### `POST /scan` -| Return Value | Description | -|--------------|-------------| -| 0 | disabled | -| 1 | enabled | +Looks again for a local HTTP or HTTPS service to display. A local service can call this once it +is ready, to avoid a startup race with the browser. -#### **PUT** /gpu/{value} -Enables or disables the GPU +> [!NOTE] +> Local services are only detected when `LAUNCH_URL` is not set. -| Value | Description | -|--------------|-------------| -| 0 | disable | -| 1 | enable | +### Display mode -#### **GET** /kiosk -Returns whether the device is running kiosk mode or not: +#### `GET /kiosk` -| Return Value | Description | -|--------------|-------------| -| 0 | disabled | -| 1 | enabled | +Returns `1` if kiosk mode is on, `0` if it is off. -#### **PUT** /kiosk/{value} -Enables or disables kiosk mode +#### `POST /kiosk/{value}` -| Value | Description | -|--------------|-------------| -| 0 | disable | -| 1 | enable | +`1` turns kiosk mode on, `0` turns it off. Chromium restarts to apply the change. -#### **GET** /flags -Returns the flags Chromium was started with +```bash +curl -X POST http://:5011/kiosk/1 +``` -#### **GET** /version -Returns the version of Chromium that `browser` is running +#### `GET /gpu` -#### **GET** /screenshot -Captures the current page through the Chromium DevTools Protocol and returns it as a PNG image. +Returns `1` if GPU acceleration is on, `0` if it is off. -### Diagnostics +#### `POST /gpu/{value}` + +`1` turns GPU acceleration on, `0` turns it off. Chromium restarts to apply the change. Any other +value returns `400`. + +```bash +curl -X POST http://:5011/gpu/1 +``` -The following endpoints expose internal Chromium state for troubleshooting hardware acceleration. -They are **disabled by default**; set `ENABLE_DIAGNOSTICS=1` to enable them. When disabled they -return `404`. While enabled, Chromium also logs to `/tmp/chrome_debug.log` in the container so its -GPU/decoder/audio errors can be included in the report below. +### Info -#### **GET** /diagnostics/report -Returns a single, human-readable `.txt` bundling device/host info, runtime config and flags, Chromium -/ GPU / media state, and recent block + Chromium logs. This is the easiest thing to attach to a bug -report. Save it with: +#### `GET /flags` + +Returns the command-line flags Chromium was started with. + +#### `GET /version` + +Returns the `browser` block version. + +#### `GET /screenshot` + +Returns a PNG of the current page, captured through the Chromium DevTools Protocol. ```bash -curl -OJ http://:5011/diagnostics/report +curl -o screenshot.png http://:5011/screenshot ``` -#### **GET** /diagnostics/version -Returns the running Chromium build/version (and the block version) as JSON. +### Diagnostics + +These endpoints expose internal Chromium state for troubleshooting hardware acceleration. They are +**off by default**: set `ENABLE_DIAGNOSTICS=1` to turn them on. While off, they return `404`. +While on, Chromium also logs to `/tmp/chrome_debug.log` in the container, so the report can +include its GPU, decoder and audio errors. -#### **GET** /diagnostics/gpu -Returns Chromium's GPU feature status, drivers and active backend as JSON (the same data as -`chrome://gpu`). +| Method | Endpoint | Returns | +| --- | --- | --- | +| `GET` | `/diagnostics/report` | A single text file with device and host info, config and flags, Chromium GPU and media state, and recent block and Chromium logs. Attach it to bug reports. | +| `GET` | `/diagnostics/version` | Chromium build and block version (JSON) | +| `GET` | `/diagnostics/gpu` | GPU feature status, drivers and active backend: the same data as `chrome://gpu` (JSON) | +| `GET` | `/diagnostics/media` | The decoder each active media player uses and whether it is hardware accelerated, such as `V4L2VideoDecoder` (JSON) | +| `GET` | `/diagnostics/vainfo` | Raw `vainfo` output listing the VA-API profiles the driver exposes (plain text). `vainfo` is only bundled on `generic-amd64`; other images report that it is not installed. | -#### **GET** /diagnostics/media -Returns the decoder used by any active media player, including whether it is hardware-accelerated — -useful for confirming hardware video decode (e.g. `V4L2VideoDecoder`). +To save the report: -#### **GET** /diagnostics/vainfo -Returns the raw `vainfo` output as plain text, listing the VA-API profiles the installed driver -exposes. `vainfo` is only bundled on `generic-amd64`; other images report that it is not installed. +```bash +curl -OJ http://:5011/diagnostics/report +``` --- @@ -288,7 +316,8 @@ Set `ENABLE_REMOTE_DEBUG=1` to run a small TCP relay that forwards `REMOTE_DEBUG Then add the device as a target in `chrome://inspect/#devices` on another machine, connecting by IP address (`:35173`). -> ⚠️ The remote debugging interface has **no authentication or encryption** — anyone who can reach +> [!CAUTION] +> The remote debugging interface has **no authentication or encryption** — anyone who can reach > the port gets full control of the browser. Only enable it on a trusted/private network, or leave > the port unmapped and reach it through an SSH tunnel instead. @@ -315,7 +344,8 @@ support: haven't validated them and hardware video decode is not guaranteed (it depends on the device's kernel drivers). Use the generic `aarch64`/`amd64` images. -> **Note:** 32-bit Raspberry Pi OS and the balena Fin (`fincm3`) are no longer targeted. Use the +> [!NOTE] +> 32-bit Raspberry Pi OS and the balena Fin (`fincm3`) are no longer targeted. Use the > 64-bit (`aarch64`) OS on Raspberry Pi. --- @@ -332,9 +362,11 @@ Hardware acceleration is controlled by a single master switch with one optional The prefix tells you the default: `ENABLE_*` is off until you set it; `DISABLE_*` is on until you set it. -> **Upgrade note:** `ENABLE_GPU=1` continues to give you hardware video decode, as it always has — +> [!NOTE] +> **Upgrading?** `ENABLE_GPU=1` continues to give you hardware video decode, as it always has — > nothing to change for existing video kiosks. -> + +> [!WARNING] > Hardware **video encode** is currently **not supported** (e.g. WebRTC capture/streaming may not > work — see [#168](https://github.com/balena-io-experimental/browser/issues/168)). From 814080950c5f6849f76b973c65e76eed04547ace Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Thu, 1 Oct 2026 21:56:20 +0400 Subject: [PATCH 16/18] docs: Use GitHub alerts in the migration guide Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/migrating-from-v2.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index 240aa52..415413b 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -62,6 +62,7 @@ The reason is that the browser is a client whose build differs per target — mo Raspberry Pi images use a Chromium built from the Raspberry Pi sources to get hardware video decode. Update your `docker-compose.yml` to reference the image for your device type. +> [!IMPORTANT] > 32-bit Raspberry Pi OS, the `arm32` images and the balena Fin (`fincm3`) are no longer built. Use > the 64-bit (`aarch64`) OS on Raspberry Pi. @@ -153,6 +154,7 @@ services: # ... no rotation/geometry variables here anymore ``` +> [!NOTE] > Multi-display setups are not yet supported by the display block; these variables apply to the > first connected display. @@ -201,6 +203,7 @@ service, update the env var accordingly (e.g. `ENV PULSE_SERVER=tcp:not-audio:43 block's [environment variables](https://github.com/balena-labs-projects/audio#environment-variables) for the full `AUDIO_OUTPUT` vocabulary (`RPI_HDMI0`, `RPI_HDMI1`, `DAC`, `USB`, `AUTO`, …). +> [!WARNING] > The `audio` block is **unmaintained** (no updates or testing in ~4 years). The supported, tested > path is the default ALSA-direct one; use the audio block at your own risk. It is also the only way > to get Bluetooth audio output, which the default ALSA-direct path does not support. From b72be3745d907e1a0918efc88f94e7ee8dfc67f0 Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Mon, 5 Oct 2026 06:34:59 +0400 Subject: [PATCH 17/18] docs: Restyle the architecture diagram Lay out the browser block horizontally and highlight it, mute the other groups, and give every node explicit colours so it reads in dark mode. Use consistent two-line group titles, label the V4L2 path as H.264 decode on Pi 3/4 only, and show the Wayland socket without its configurable path. Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- readme.md | 55 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/readme.md b/readme.md index 7e04770..11c6231 100644 --- a/readme.md +++ b/readme.md @@ -394,48 +394,59 @@ the compositor. ```mermaid flowchart TB - subgraph BrowserBlock ["browser block (Wayland client)"] - Start["start.sh
    grants device access,
    waits for the socket,
    restarts if it is recreated"] - Server["server.js (Node.js)
    management API :5011"] - Chromium["Chromium
    --ozone-platform=wayland"] - - Start -- "runs as chromium user" --> Server - Server -- "chrome-launcher" --> Chromium + subgraph BrowserBlock ["browser block
    Wayland client"] + subgraph Procs [" "] + direction LR + Start["start.sh
    grants device access,
    waits for the socket,
    restarts if it is recreated"] + Server["server.js (Node.js)
    management API :5011"] + Chromium["Chromium
    --ozone-platform=wayland"] + + Start -- "runs as
    chromium user" --> Server + Server -- "chrome-launcher" --> Chromium + end end - subgraph SharedVolume ["display-socket volume (/run)"] - Socket[("/run/user/0/
    wayland-0")] + subgraph SharedVolume ["display-socket volume
    mounted at /run"] + Socket[("Wayland socket")] end - subgraph DisplayBlock ["display block (Wayland compositor)"] + subgraph DisplayBlock ["display block
    Wayland compositor"] Config["weston.ini generated from
    DISPLAY_* variables
    (or WESTON_INI_PATH)"] - Weston["Weston"] + Weston["Weston"] Config -- "configures" --> Weston end - subgraph Hardware ["device hardware"] + subgraph Hardware ["device hardware"] Sound["sound card
    /dev/snd"] - VDec["video decoder
    /dev/video* (Pi)"] + VDec["video decoder
    /dev/video*"] GPU["GPU / DRM
    /dev/dri"] Input["input devices
    touch, mouse, keyboard"] Outputs["physical displays"] end - Chromium -- "Wayland protocol" --> Socket + BrowserBlock -- "Wayland protocol" --> Socket Socket -- "client connection" --> Weston - Chromium -- "ALSA audio" --> Sound - Chromium -- "V4L2 decode" --> VDec - Chromium -- "GPU rendering,
    VA-API decode (x86)" --> GPU + BrowserBlock -- "ALSA audio" --> Sound + BrowserBlock -- "H.264 decode
    (Pi 3/4)" --> VDec + BrowserBlock -- "GPU rendering,
    video decode (x86)" --> GPU Weston -- "DRM/KMS" --> GPU Weston -- "reads" --> Input GPU -- "scans out to" --> Outputs - classDef block fill:#ffffff,stroke:#333333,stroke-width:2px,color:#333333; - classDef hardware fill:#e1f5fe,stroke:#0288d1,stroke-width:2px,color:#333333; - classDef volume fill:#fff3e0,stroke:#f57c00,stroke-width:2px,color:#333333; - - class BrowserBlock,DisplayBlock block; + classDef browser fill:#e3f2fd,stroke:#1565c0,stroke-width:3px,color:#0d47a1; + classDef browserNode fill:#ffffff,stroke:#1565c0,stroke-width:2px,color:#0d47a1; + classDef ghost fill:none,stroke:none; + classDef otherBlock fill:#fafafa,stroke:#9e9e9e,stroke-width:1px,color:#424242; + classDef component fill:#ffffff,stroke:#9e9e9e,stroke-width:1px,color:#424242; + classDef hardware fill:#f5f5f5,stroke:#9e9e9e,stroke-width:1px,stroke-dasharray:4 3,color:#424242; + classDef volume fill:#fff8e1,stroke:#f9a825,stroke-width:1px,color:#424242; + + class BrowserBlock browser; + class Procs ghost; + class Start,Server,Chromium browserNode; + class Config,Weston,Socket,Sound,VDec,GPU,Input,Outputs component; + class DisplayBlock otherBlock; class Hardware hardware; class SharedVolume volume; ``` From abf26bc01a456e093e50ea12ee3cdd09407eddbf Mon Sep 17 00:00:00 2001 From: Rahul Thakoor Date: Wed, 7 Oct 2026 18:17:27 +0400 Subject: [PATCH 18/18] Keep the Wayland platform flag when FLAGS is set FLAGS replaces the whole flag set, which also dropped --ozone-platform=wayland. Chromium then fell back to X11, exited with "Missing X server or $DISPLAY", and the block failed with ECONNREFUSED on the debug port and restarted in a loop. Add the Wayland flag unless FLAGS sets its own --ozone-platform, and document that FLAGS replaces the kiosk defaults so additive flags belong in EXTRA_FLAGS. Change-type: patch Signed-off-by: rahul-thakoor <13220877+rahul-thakoor@users.noreply.github.com> --- docs/migrating-from-v2.md | 11 +++++++++++ readme.md | 2 +- src/server.js | 11 ++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/migrating-from-v2.md b/docs/migrating-from-v2.md index 415413b..b82fae0 100644 --- a/docs/migrating-from-v2.md +++ b/docs/migrating-from-v2.md @@ -85,6 +85,17 @@ If you overrode `FLAGS` or `EXTRA_FLAGS` in v2, review them. v3 renders through now chosen per device type by the block. Prefer the `ENABLE_GPU` / `DISABLE_VIDEO_DECODE` toggles over hand-rolled flags where possible. +> [!WARNING] +> `FLAGS` **replaces** the block's defaults, including the kiosk flags (`--noerrdialogs`, +> `--disable-session-crashed-bubble`, autoplay, `--disable-dev-shm-usage`). A v2 value such as +> `FLAGS=--hide-scrollbars` that was meant to add one switch now drops all of them. Move flags you +> want to add to `EXTRA_FLAGS`, and keep `FLAGS` only if you really mean to replace the full set. +> +> The block always adds `--ozone-platform=wayland` unless `FLAGS` sets its own `--ozone-platform`. +> There is no X server in v3. If you set `--ozone-platform=x11`, Chromium exits with +> `Missing X server or $DISPLAY`, and the block fails with `connect ECONNREFUSED 127.0.0.1:9222` +> and restarts in a loop. + ## 5. Diagnostics The `/diagnostics/*` endpoints (Chromium version, GPU state, media-decoder state) are new in v3 and diff --git a/readme.md b/readme.md index 11c6231..901f59d 100644 --- a/readme.md +++ b/readme.md @@ -81,7 +81,7 @@ The following environment variables allow configuration of the `browser` block: |`LAUNCH_URL`|`http` or `https` URL|N\A|Web page to display| |`LOCAL_HTTP_DELAY`|Number (seconds)|0|Number of seconds to wait for a local HTTP service to start before trying to detect it| |`KIOSK`|`0`, `1`|`0`|Run in kiosk mode with no menus or status bars.
    `0` = off, `1` = on| -|`FLAGS`|[many!](https://peter.sh/experiments/chromium-command-line-switches/)|N/A|**Replaces** the flags chromium is started with. Enter a space (\' \') separated list of flags (e.g. `--noerrdialogs --disable-session-crashed-bubble`)
    **Use with caution!**| +|`FLAGS`|[many!](https://peter.sh/experiments/chromium-command-line-switches/)|N/A|**Replaces** the flags chromium is started with, including the block's kiosk defaults (e.g. `--noerrdialogs`, `--disable-session-crashed-bubble`, `--autoplay-policy=no-user-gesture-required`). Enter a space (\' \') separated list of flags. `--ozone-platform=wayland` is always added unless you set your own `--ozone-platform`.
    **Use with caution!** To add flags, use `EXTRA_FLAGS` instead.| |`EXTRA_FLAGS`|[many!](https://peter.sh/experiments/chromium-command-line-switches/)|N/A|Adds **additional** flags chromium is started with. Enter a space (\' \') separated list of flags (e.g. `--audio-buffer-size=2048 --audio-output-channels=8`)| |`PERSISTENT`|`0`, `1`|`0`|Enables/disables user profile data being stored on the device. **Note: you'll need to create a settings volume. See example above**
    `0` = off, `1` = on| |`ENABLE_GPU`|`0`, `1`|0|Master hardware-acceleration switch. Enables GPU **rendering** (rasterization, compositing, WebGL/canvas) and, by default, best-effort hardware **video decode**. On Raspberry Pi, decode is handled by the Pi-patched Chromium (verify via `MojoVideoDecoder`/`V4L2VideoDecoder` in `chrome://media-internals`); on x86 it enables the Mesa VA-API path.
    `0` = off, `1` = on| diff --git a/src/server.js b/src/server.js index eb7a922..cfda425 100644 --- a/src/server.js +++ b/src/server.js @@ -54,6 +54,10 @@ let currentUrl = ''; let flags = []; let timer = {}; +// Render through the Wayland compositor (display block), never X11/XWayland. The image has no +// X server, so this is also kept when FLAGS replaces the rest of the defaults. +const OZONE_WAYLAND_FLAG = '--ozone-platform=wayland'; + // Universal Wayland and kiosk-oriented behavior flags applied on every platform, // regardless of acceleration toggles. const BALENA_BASE_FLAGS = [ @@ -62,7 +66,7 @@ const BALENA_BASE_FLAGS = [ '--disable-session-crashed-bubble', // Prevent crash recovery UI from breaking kiosk immersion '--check-for-update-interval=31536000', // Disable update checks '--disable-dev-shm-usage', // Prevent shared memory exhaustion in Docker - '--ozone-platform=wayland' // Render through the Wayland compositor (display block), never X11/XWayland + OZONE_WAYLAND_FLAG ]; /** @@ -235,6 +239,11 @@ let launchChromium = async function(url) { if (null !== FLAGS) { // Override completely if the user provides an explicit global FLAGS variable flags = FLAGS.split(' '); + // Without an ozone platform Chromium falls back to X11 and exits at startup + if (!flags.some(flag => flag.startsWith('--ozone-platform='))) { + console.log(`FLAGS does not set --ozone-platform; adding ${OZONE_WAYLAND_FLAG}. Use EXTRA_FLAGS to add flags without replacing the defaults.`); + flags.push(OZONE_WAYLAND_FLAG); + } } else { flags = composeGpuFlags(); }