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/.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..e16610c 100644 --- a/balena.yml +++ b/balena.yml @@ -4,53 +4,60 @@ 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- # is aarch64 or amd64; see https://github.com/balenasolutions/display + 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: raspberrypi3 + defaultDeviceType: raspberrypi4-64 supportedDeviceTypes: - raspberrypi4-64 - - fincm3 - - raspberrypi3 - raspberrypi3-64 - raspberrypi400-64 - raspberrypi5 - intel-nuc - - genericx86-64-ext + - generic-amd64 + - generic-aarch64 diff --git a/build/install_chromium b/build/install_chromium index 83459a1..2d08770 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,37 @@ 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 \ +# 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 +apt-get update && apt-get install -y --no-install-recommends \ + ${ARCH_PACKAGES} \ 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/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 new file mode 100644 index 0000000..b82fae0 --- /dev/null +++ b/docs/migrating-from-v2.md @@ -0,0 +1,220 @@ +# 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/balenasolutions/display- # is aarch64 or amd64; see https://github.com/balenasolutions/display + privileged: true + volumes: + - display-socket:/run + labels: + io.balena.features.dbus: '1' + + browser: + image: bh.cr/balenasolutions/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`) under +`bh.cr/balenalabs`. v3 publishes one image **per device type** under `bh.cr/balenasolutions`: + +| 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. + +> [!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. + +## 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. + +> [!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 +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. + +## 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/balenasolutions/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/balenasolutions/browser- + # ... 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. + +## 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/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 +``` + +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`, …). + +> [!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. 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)`. 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/package.json b/package.json index eb4d1ed..c677247 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/balenasolutions/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/balenasolutions/browser/issues" + }, + "homepage": "https://github.com/balenasolutions/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/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 4012c56..901f59d 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,12 @@ -# 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 via X11, optimized for balenaOS. -The block provides an API for dynamic configuration, and also exposes the Chromium Remote Debug port. +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). --- ## Features @@ -13,87 +17,88 @@ The block provides an API for dynamic configuration, and also exposes the Chromi - 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 +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/balenasolutions/display- # is aarch64 or amd64; see https://github.com/balenasolutions/display + privileged: true volumes: - - 'settings:/data' # Only required if using PERSISTENT flag (see below) -``` + - display-socket:/run + labels: + io.balena.features.dbus: '1' -To pin to a specific [version](CHANGELOG.md) of this block use: - -```yaml -services: browser: - image: bh.cr/balenalabs/browser-/ + 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 + 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/balenasolutions/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: | 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!**| +|`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| -|`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| -|`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| +|`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| |`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| + +> [!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](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). --- @@ -108,7 +113,7 @@ volumes: services: browser: restart: always - image: bh.cr/balenalabs/browser- + image: bh.cr/balenasolutions/browser- privileged: true volumes: - 'settings:/data' @@ -120,122 +125,331 @@ 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. +## Audio -In this example we add the `audio` block and route the `browser` audio to the Raspberry Pi headphone jack: +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. -```yaml -services: - browser: - image: bh.cr/balenalabs/browser- - audio: - image: bh.cr/balenalabs/audio- - privileged: true - ports: - - 4317:4317 - environment: - AUDIO_OUTPUT: RPI_HEADPHONES +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/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 ``` -**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). --- ## 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`. + +| 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) | + +### Page -#### **POST** /refresh -Refreshes the currently displayed page +#### `GET /ping` -#### **POST** /autorefresh/{interval} -Automatically refreshes the browser window +Returns `200 ok` once the API is running. Use it as a readiness check. -| Value | Description | -|--------------|-------------| -| 0 | disable | -| 1-60 | refresh every `interval` seconds | +#### `GET /url` -#### **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 the URL currently on screen. -note: *the* `LAUNCH_URL` *must not be set for local services to be detected.* +#### `POST /url` -#### **GET** /url -Returns the URL currently being displayed +Displays a new URL. Send the parameters as form data or JSON. -#### **POST** /url -Sets the URL to be displayed. The URL is set in the request body. Example: +| 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 -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. +Returns `1` if GPU acceleration is on, `0` if it is off. + +#### `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 +``` + +### Info + +#### `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 -o screenshot.png http://:5011/screenshot +``` + +### 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. + +| 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. | + +To save the report: + +```bash +curl -OJ http://:5011/diagnostics/report +``` + +--- + +## 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`). + +> [!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. --- ## Supported devices -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 | ✔ | -| Intel NUC | ✔ | -| Generic AMD64 | ✔ | +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 | + +**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. + +--- + +## 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. + +> [!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)). +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 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). + +--- + +## 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"] + 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
    mounted at /run"] + Socket[("Wayland socket")] + 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*"] + GPU["GPU / DRM
    /dev/dri"] + Input["input devices
    touch, mouse, keyboard"] + Outputs["physical displays"] + end + + BrowserBlock -- "Wayland protocol" --> Socket + Socket -- "client connection" --> Weston + 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 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; +``` --- ## Troubleshooting diff --git a/src/diagnostics.js b/src/diagnostics.js new file mode 100644 index 0000000..56746cb --- /dev/null +++ b/src/diagnostics.js @@ -0,0 +1,435 @@ +/** + * 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 /diagnostics/{version,gpu,media,vainfo} endpoints, + * - adds /diagnostics/report: a single, human-readable .txt bundling + * 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'; +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(); + } + } +} + +/** + * 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 + * 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('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) + ].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.'); + } + }); + + // 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'); + const device = getDeviceInfo(); + const runtime = getRuntimeConfig(); + + const report = { + generatedAt: new Date().toISOString(), + device, + runtime, + chromium: null, + gpu: null, + media: null, + vainfo: await getVaInfo(), + 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 aef7d00..cfda425 100644 --- a/src/server.js +++ b/src/server.js @@ -1,236 +1,391 @@ #!/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'); +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'); -// 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'; +// 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; -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"; -// 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 +// 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 = [ + '--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 + OZONE_WAYLAND_FLAG +]; + +/** + * 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 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) - { - // return the first URL that returned 200 - returnURL = urls[0]; - } - // Otherwise send the default HTML - else - { - console.log("Displaying default HTML page"); - returnURL = "file:///home/chromium/index.html"; + if (urls.length > 0) { + returnURL = urls[0]; + } else { + console.log("Displaying default HTML page"); + returnURL = "file:///home/chromium/index.html"; + } + + return returnURL; +} + +/** + * 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 = []; + + // 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); } + } - 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 -let launchChromium = async function(url) { - await chromeLauncher.killAll(); - 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); - } - } + return composed; +} - if (EXTRA_FLAGS) { - flags = flags.concat(EXTRA_FLAGS.split(' ')); +/** + * 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(); + + flags = []; + 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(); + } - let startingUrl = url; - if ('1' === kioskMode) - { - console.log("Enabling KIOSK mode"); - startingUrl = `--app= ${url}`; - } - else - { - console.log("Disabling KIOSK mode"); + // Append any additive custom flags provided by the operator + if (EXTRA_FLAGS) { + 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"); + // The --app flag strips the URL bar and browser frame UI + 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: CHROMIUM_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: CHROMIUM_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: CHROMIUM_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 +396,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 +405,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: CHROMIUM_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 +544,63 @@ 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(); + +// 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 + }) +}); + + + +// 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..dcc6c6d 100644 --- a/src/start.sh +++ b/src/start.sh @@ -1,62 +1,31 @@ #!/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 +# Export the block version so server.js can serve it on /version and in diagnostics. +# Must happen before the environment whitelist for su is built below. +export VERSION="$(> /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 - -# 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 +# 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}" -# 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 +echo "Targeting Wayland compositor socket: ${WAYLAND_SOCKET}" -# 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 +35,56 @@ 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 + +# 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 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