fix(claude_desktop): stop the GPU flags from disabling the GPU; make max_resolution work; drop the dead driver install (#2938)

The GPU acceleration shipped in 2026.08.03 was not inert — it was what turned
the GPU off. `--use-gl=angle --use-angle=gl-egl` forces Mesa's EGL X11 platform,
which offers no window-capable EGLConfig under this Xvfb. The GPU process logged
`gl_surface_egl.cc:262 No suitable EGL configs found`, abandoned GL, and was
relaunched with `--use-gl=disabled` while every renderer got
`--disable-gpu-compositing`.

Chromium already renders on the GPU here with no flags at all: LSIO's Xvfb runs
`-vfbdevice /dev/dri/renderD128`, so its GLX is backed by the real render node.
The premise that Xvfb offers only an indirect/software path was wrong for this
base image. Measured on a separate display, including at the production
15360x8640 screen — with the flags the GPU process loads libEGL_mesa and holds
1 fd on the render node; without them it loads the Mesa gallium megadriver over
DRI3, holds 8, and no renderer carries --disable-gpu-compositing.

claude-gpu-probe was not wrong about the hardware, it answered the wrong
question: it exercised ANGLE's default GLX path, which works, so it passed while
the flags it gated disabled the GPU. Removed with the gpu_acceleration option.

Also fixes two other changes from the same release that never did anything:

- max_resolution wrote MAX_RES into the s6 container_environment, but svc-xorg
  starts `#!/usr/bin/env bashio`, not with-contenv, and never reads it. Renaming
  the option to MAX_RES makes the add-on env layer inject it into every service
  run script, which is how DRINODE already reaches Xvfb. It ships with no
  default, so nothing changes until it is set: carrying over the old 1920x1080
  default would have silently shrunk every existing desktop, since that value
  had never taken effect. Schema bounds each axis to 100-9999.

- The amd64 driver install has never run in any release: guarded by `if [[ ]]`
  with no SHELL directive, so it runs under dash, which has no `[[` — condition
  false, RUN still exit 0. It is deleted rather than repaired, because it had
  nothing to add. Hardware GL already works without it, Mesa already comes from
  the LSIO base at a newer backports version, and intel-media-va-driver-non-free
  was installed live on the running add-on and changed nothing: VA-API failed
  identically to the free driver on both DRM nodes. That failure is below the
  add-on, in the host i915 stack.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alexandre
2026-08-04 19:38:09 +02:00
committed by GitHub
parent f7c00ca818
commit 7dfeb78c37
8 changed files with 92 additions and 359 deletions

View File

@@ -1,3 +1,50 @@
## 2026.08.04 (04-08-2026)
- Fix: reverted the GPU acceleration added in 2026.08.03. It did not just fail to help — it was
what disabled the GPU. `--use-gl=angle --use-angle=gl-egl` forces Mesa's EGL X11 platform,
which offers no window-capable EGLConfig under this Xvfb, so the GPU process logged
`gl_surface_egl.cc:262 No suitable EGL configs found`, abandoned GL, and was relaunched with
`--use-gl=disabled` while every renderer got `--disable-gpu-compositing`.
Chromium already renders on the GPU here with no flags at all, because LSIO's Xvfb runs with
`-vfbdevice /dev/dri/renderD128` and its GLX is therefore backed by the real render node —
the premise that Xvfb offers only a software path was wrong for this base image. Measured
three times, including at the production 15360x8640 screen: with the flags the GPU process
loads `libEGL_mesa` and holds 1 fd on the render node; without them it loads `libGLX_mesa`,
holds 8, and no renderer carries `--disable-gpu-compositing`.
Removes `claude-gpu-probe` and the `gpu_acceleration` option. The probe was not wrong about
the hardware, it was answering the wrong question: it exercised ANGLE's default GLX path,
which works, and so it passed while the flags it gated disabled the GPU.
- Fix: `max_resolution` never did anything; the option is renamed to `MAX_RES`. It wrote
`MAX_RES` into the s6 `container_environment`, but the base image's `svc-xorg` starts
`#!/usr/bin/env bashio` rather than `with-contenv` and never reads that directory, so Xvfb
kept starting at 15360x8640. Naming the option `MAX_RES` makes the add-on env layer inject
it directly into every service `run` script, which is how `DRINODE` already reaches Xvfb.
`MAX_RES` ships with no default, so nothing changes for anyone until it is set: the screen
stays at the base image's 15360x8640. The old `max_resolution` default of `1920x1080` is
deliberately not carried over — it had never taken effect, so shipping it now would have
silently shrunk every existing desktop, including on 4K displays. Home Assistant drops
options that are no longer in the schema (it logs a warning), so a saved `max_resolution`
is discarded rather than migrated. The schema bounds each axis to 100-9999, which keeps the
worst case (~400 MB of framebuffer) below the 15360x8640 default it replaces.
Removes `22-display_tuning.sh`.
- Removed the amd64 GPU driver install, which has never run in any release. The 2026.08.03
build corrected its gate variable but the guard is `if [[ ... ]]` and there is no `SHELL`
directive, so it runs under dash, which has no `[[` — the condition was false and the `RUN`
still exited 0. Verified in the shipped 2026.08.03 image: no `vainfo`, no
`intel-media-va-driver-non-free`, and no matching install in its apt history.
It was deleted rather than repaired, because it had nothing to add. Hardware OpenGL already
works without it: the GPU process loads the Mesa gallium megadriver over DRI3 and holds 8
fds on `/dev/dri/renderD128`, with no swrast/llvmpipe in the GL path. `libgl1-mesa-dri` and
`mesa-vulkan-drivers` already come from the LSIO base at a newer backports version (Mesa
25.0.7) than reinstalling them would give. And `intel-media-va-driver-non-free`, the one
genuinely new payload, was installed live on the running add-on and changed nothing: VA-API
failed identically to the free driver (`iHD_drv_video.so init failed`) on both
`/dev/dri/renderD128` and `card0`. That failure is below the add-on, in the host i915 stack.
Repairing the guard would have turned dead code into an untested apt transaction that swaps
a base-image package for no measured benefit.
- A `[[` in the Dockerfile's legacy `/etc/services.d` shim is corrected to `[` for the same
dash reason; that directory does not exist on this s6-overlay v3 base, so it is
behaviour-neutral here.
## 2026.08.03 (03-08-2026)
- Performance: Claude Desktop now uses the GPU instead of rendering on the CPU.
Under Xvfb, Chromium probed GLX, found only Xvfb's software path, and fell back to

View File

@@ -74,7 +74,7 @@ VOLUME [ "/sys/fs/cgroup" ]
# hadolint ignore=SC2015,DL4006,SC2013,SC2086
RUN \
usermod --home /data/data abc && \
if [[ -d /etc/services.d ]] && ls /etc/services.d/*/run 1> /dev/null 2>&1; then sed -i "1a set +e" /etc/services.d/*/run; fi
if [ -d /etc/services.d ] && ls /etc/services.d/*/run 1> /dev/null 2>&1; then sed -i "1a set +e" /etc/services.d/*/run; fi
ARG TEMPLATE_BASE_URL="https://raw.githubusercontent.com/alexbelgium/hassio-addons/master/.templates"
@@ -142,36 +142,29 @@ RUN install -d -m 0755 /etc/apt/keyrings && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# The Intel N150/Twin Lake iGPU uses the host's i915 kernel driver through the mapped
# /dev/dri nodes. Explicitly install the amd64 userspace stack needed for accelerated
# OpenGL rendering, VA-API video encoding, and Vulkan, then fail the build if any driver
# payload is missing. Keep aarch64 unchanged because these Intel packages are amd64-only.
# No bespoke GPU driver install. The LSIO base image already provides the userspace stack that
# hardware rendering actually uses, and it does so at a newer version than adding it here would:
# libgl1-mesa-dri and mesa-vulkan-drivers come from bookworm-backports Mesa 25.0.7, where a
# plain `apt-get install` of the same names is at best a no-op.
#
# Gate on BUILD_ARCH, not TARGETARCH. TARGETARCH is a BuildKit-provided platform ARG; the
# repo's builder (.github/workflows/onpush_builder.yaml) passes BUILD_ARCH explicitly and
# that is the contract this repo can rely on. This block previously used TARGETARCH and
# silently never ran: the shipped amd64 image has no `vainfo` and no
# `intel-media-va-driver-non-free`, and its apt history contains no matching install — so
# the "fail the build if a payload is missing" guarantee below had never once executed.
# There used to be an amd64 block here installing intel-media-va-driver-non-free, libgl1-mesa-dri,
# mesa-vulkan-drivers and vainfo. It never ran in any release: it was guarded by
# `if [[ ... ]]`, and with no SHELL directive RUN executes under /bin/sh — dash, which has no
# `[[`. dash printed `[[: not found`, the condition was false, and the RUN still exited 0, so
# every build silently skipped it and still passed. (An earlier fix corrected the gate variable
# from TARGETARCH to BUILD_ARCH but left the `[[`, so it stayed dead.) Confirmed in the shipped
# 2026.08.03 amd64 image: no vainfo, no intel-media-va-driver-non-free, no matching apt history.
#
# The Vulkan ICD payload is checked via the driver directory rather than a single
# distro-specific filename: the previous `test -f intel_icd.x86_64.json` named a file Debian
# does not ship (it installs `intel_icd.json`), so restoring the guard without this change
# would have turned dead code straight into a failing build.
RUN if [[ "${BUILD_ARCH}" == "amd64" ]]; then \
apt-get update && \
apt-get install -y --no-install-recommends \
intel-media-va-driver-non-free \
libgl1-mesa-dri \
mesa-vulkan-drivers \
vainfo && \
test -f /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so && \
test -f /usr/lib/x86_64-linux-gnu/dri/iris_dri.so && \
ls /usr/share/vulkan/icd.d/intel_icd*.json > /dev/null && \
command -v vainfo > /dev/null; \
fi && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# It was removed rather than repaired, because measurement showed it had nothing to add:
# - Hardware OpenGL already works without it. In the shipped image the Chromium GPU process
# loads the Mesa gallium megadriver over DRI3 and holds 8 fds on /dev/dri/renderD128, with
# no swrast/llvmpipe in the GL path.
# - The only genuinely new payload, intel-media-va-driver-non-free, does not fix anything
# here. Installed live alongside vainfo on the running add-on, VA-API failed identically to
# the free driver — `iHD_drv_video.so init failed` on both /dev/dri/renderD128 and card0,
# with both 23.1.1 builds. The failure is below the add-on, in the host i915/GuC stack.
# Repairing the guard would therefore have turned dead code into an untested apt transaction
# that swaps a base-image package for no measured benefit.
# Install the current upstream hadolint and actionlint releases for both supported
ARG HADOLINT_VERSION=v2.14.0

View File

@@ -97,8 +97,7 @@ Git synchronization hooks. A repository is indexed only when it is listed in
| `KEYBOARD` | | Optional Selkies keyboard layout. |
| `PASSWORD` | | Optional password for direct Selkies ports. |
| `DRINODE` | | Optional GPU device override for Selkies. |
| `gpu_acceleration` | `auto` | Whether Claude Desktop renders on the GPU. `auto` adds Chromium's ANGLE/EGL flags only when a probe confirms a hardware GL context is available, `on` forces them without probing, `off` keeps software rendering. Use `on` with care: it skips every safety check, so on a host that cannot actually drive those flags the desktop can come up black — set the option back to `auto` or `off` to recover. |
| `max_resolution` | `1920x1080` | Caps the virtual screen. Selkies still resizes dynamically below this; raise it only if you drive the desktop from a larger display. |
| `MAX_RES` | _(unset)_ | Optional cap on the virtual screen, as `WIDTHxHEIGHT` (100-9999 per axis). Unset means the base image default, 15360x8640 — Selkies resizes dynamically below whatever the cap is, so this only sets the ceiling. Named `MAX_RES` because that is the environment variable the base image's Xvfb service reads. Setting it lowers the area Xvfb and the Selkies capture loop track for damage; the framebuffer itself is lazily populated, so this is a CPU saving, not a memory one. |
| `DNS_server` | `8.8.8.8` | DNS server used by the standard DNS module. |
| `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. |
| `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. |

View File

@@ -56,7 +56,6 @@ options:
github_username: ""
enable_tools_health_report: true
expose_headroom_dashboard: false
gpu_acceleration: auto
headroom_auto_compress: true
headroom_wrap_claude_code: true
install_caveman: false
@@ -66,7 +65,6 @@ options:
install_headroom: true
install_rtk: true
install_tokensave: true
max_resolution: 1920x1080
mcp_servers_desktop:
- headroom
- tokensave
@@ -98,6 +96,7 @@ schema:
data_location: str?
DRINODE: list(/dev/dri/card0|/dev/dri/card1|/dev/dri/card2|/dev/dri/renderD128|/dev/dri/renderD129|)?
KEYBOARD: list(da-dk-qwerty|de-de-qwertz|en-gb-qwerty|en-us-qwerty|es-es-qwerty|fr-ch-qwertz|fr-fr-azerty|it-it-qwerty|ja-jp-qwerty|pt-br-qwerty|sv-se-qwerty|tr-tr-qwerty)?
MAX_RES: match(^[1-9][0-9]{2,3}x[1-9][0-9]{2,3}$)?
PASSWORD: str?
PGID: int
PUID: int
@@ -118,7 +117,6 @@ schema:
github_username: str?
enable_tools_health_report: bool
expose_headroom_dashboard: bool
gpu_acceleration: list(auto|on|off)?
headroom_auto_compress: bool?
headroom_wrap_claude_code: bool
install_caveman: bool
@@ -128,7 +126,6 @@ schema:
install_headroom: bool
install_rtk: bool
install_tokensave: bool
max_resolution: str?
mcp_servers_desktop:
- list(headroom|tokensave|homeassistant|codex)
mcp_servers_code:
@@ -139,5 +136,5 @@ schema:
slug: claude_desktop
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "2026.08.03"
version: "2026.08.04"
video: true

View File

@@ -21,48 +21,28 @@
# force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom
# MCP tools instead.
# GPU acceleration.
# GPU acceleration: deliberately no flags. Do not add ANGLE/EGL flags here.
#
# Under Xvfb, Chromium probes GLX, finds only Xvfb's indirect/software path, and falls back to
# rendering everything on the CPU (`--use-gl=disabled` on the GPU process, and
# `--disable-gpu-compositing` on the renderer). On a small Home Assistant host that is the
# add-on's single largest CPU consumer.
# Chromium already renders on the GPU on this base image, because LSIO's Xvfb is started with
# `-vfbdevice /dev/dri/renderD128` — so GLX here is backed by the real render node, not the
# indirect/software path. Left alone, Chromium picks Mesa's GLX (libGLX_mesa), initialises the
# GPU process, and composites on the GPU.
#
# Routing Chromium through ANGLE's OpenGL backend over EGL uses the real render node instead.
# The flags are only added when /usr/local/bin/claude-gpu-probe confirms that Claude Desktop's
# own bundled ANGLE can create a hardware GL context here, because forcing them on a host with
# no render node (or one where Mesa falls back to llvmpipe) trades a working software desktop
# for a black window or a GPU-process crash loop. Any probe failure leaves the command line
# untouched, i.e. exactly the pre-existing software-rendering behaviour.
# Passing `--use-gl=angle --use-angle=gl-egl` actively *broke* that. It forces Mesa's EGL X11
# platform, which offers no window-capable EGLConfig under this Xvfb, so the GPU process logged
# ui/gl/gl_surface_egl.cc:262 No suitable EGL configs found.
# gave up on GL entirely, and Chromium relaunched it with `--use-gl=disabled` while stamping
# `--disable-gpu-compositing` on every renderer — i.e. the flags caused the CPU rendering they
# were meant to remove. Measured three times, including at the production 15360x8640 screen:
# with the flags the GPU process loads libEGL_mesa and holds 1 fd on the render node; with no
# flags it loads libGLX_mesa and holds 8, and no renderer carries --disable-gpu-compositing.
#
# `--use-angle=gles-egl` is deliberately not used: Mesa rejects it with "Intel or NVIDIA
# OpenGL ES drivers are not supported".
# A standalone ANGLE probe is not evidence for any of this: Claude Desktop's bundled ANGLE
# happily creates a hardware context here via its default (GLX) path, which is why the probe
# that used to gate these flags passed while the flags themselves disabled the GPU.
#
# /run/claude-desktop-gpu-mode is written each boot by /etc/cont-init.d/85-openbox_autostart.sh
# from the `gpu_acceleration` add-on option (auto|on|off).
GPU_FLAGS=""
GPU_MODE="auto"
if [ -r /run/claude-desktop-gpu-mode ]; then
GPU_MODE="$(cat /run/claude-desktop-gpu-mode)"
fi
case "$GPU_MODE" in
off)
echo "claude-desktop: gpu_acceleration=off; using software rendering" >&2
;;
on)
# Escape hatch for hosts where the probe is wrong in either direction.
GPU_FLAGS="--ozone-platform=x11 --use-gl=angle --use-angle=gl-egl"
echo "claude-desktop: gpu_acceleration=on; forcing ANGLE/EGL without probing" >&2
;;
*)
# Bounded: this sits in the desktop's startup path, and a driver that wedges during
# EGL init must not leave the user staring at an empty screen. A timeout is treated
# exactly like a failed probe, i.e. software rendering.
if timeout 15 claude-gpu-probe; then
GPU_FLAGS="--ozone-platform=x11 --use-gl=angle --use-angle=gl-egl"
fi
;;
esac
# `--use-angle=gl` also works, but it only reproduces what Chromium already chooses by itself,
# so it is not passed either.
# Shared memory.
#
@@ -80,7 +60,6 @@ if [ -n "$SHM_KB" ] && [ "$SHM_KB" -ge 262144 ]; then
SHM_FLAGS=""
fi
# GPU_FLAGS and SHM_FLAGS must stay unquoted so they expand to separate arguments, or to
# nothing at all.
# SHM_FLAGS must stay unquoted so it expands to a separate argument, or to nothing at all.
# shellcheck disable=SC2086
exec claude-desktop --no-sandbox --password-store=basic $SHM_FLAGS $GPU_FLAGS
exec claude-desktop --no-sandbox --password-store=basic $SHM_FLAGS

View File

@@ -1,66 +0,0 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
# Cap the virtual screen the desktop is drawn on.
#
# The Selkies base image starts Xvfb at DEFAULT_RES=15360x8640 so that a client on any monitor
# can resize into it. Nothing here needs a 133-megapixel screen: it enlarges the area Xvfb and
# the Selkies capture loop track for damage on every frame, which the add-on pays for
# continuously — measurably so even with no browser connected at all.
#
# MAX_RES is the base image's own knob for this (svc-xorg prefers it over DEFAULT_RES) and it
# only sets the *maximum*; Selkies still resizes dynamically underneath it, so a smaller cap
# costs nothing until a client actually asks for something larger.
#
# This is a CPU and address-space saving, not a memory one: the framebuffer is a lazily
# populated SysV shared segment, so the unused portion of the oversized screen was never
# resident to begin with.
#
# Note SELKIES_MANUAL_WIDTH/HEIGHT is a different knob that *pins* the resolution and disables
# dynamic resizing. It is deliberately not used here.
MAX_RESOLUTION="$(bashio::config 'max_resolution' '1920x1080')"
if [ -z "$MAX_RESOLUTION" ]; then
bashio::log.info "max_resolution is empty; leaving the base image's default virtual screen size"
exit 0
fi
# Matched with bash's own =~ rather than grep: grep anchors per *line*, so a multi-line value
# such as "1920x1080\n640x480" satisfies ^...$ on its first line and would be passed through to
# Xvfb verbatim. Bash anchors the whole string, so an embedded newline is rejected.
if [[ ! "$MAX_RESOLUTION" =~ ^[0-9]{1,5}x[0-9]{1,5}$ ]]; then
bashio::log.warning "max_resolution '${MAX_RESOLUTION}' is not WIDTHxHEIGHT; leaving the base image default"
exit 0
fi
# A syntactically valid but nonsensical size (0x0, 99999x99999) would either stop Xvfb from
# starting at all or ask it for a framebuffer larger than the default this option exists to
# shrink. Bound it to something Xvfb and Selkies can actually serve; the upper bound is the
# base image's own default, so this option can only ever reduce the screen.
MAX_WIDTH="${MAX_RESOLUTION%%x*}"
MAX_HEIGHT="${MAX_RESOLUTION##*x}"
if [ "$MAX_WIDTH" -lt 640 ] || [ "$MAX_HEIGHT" -lt 480 ] ||
[ "$MAX_WIDTH" -gt 15360 ] || [ "$MAX_HEIGHT" -gt 8640 ]; then
bashio::log.warning "max_resolution '${MAX_RESOLUTION}' is outside the supported range (640x480 to 15360x8640); leaving the base image default"
exit 0
fi
# cont-init.d completes before any s6-rc service starts, so svc-xorg picks this up on the same
# boot. Both paths are written because the base image's scripts read the legacy /var/run alias.
written=0
for envdir in /var/run/s6/container_environment /run/s6/container_environment; do
if [ -d "$envdir" ]; then
printf '%s' "$MAX_RESOLUTION" > "${envdir}/MAX_RES"
written=1
fi
done
# Claiming success after writing nothing would send someone hunting for a cap that svc-xorg
# never saw.
if [ "$written" -eq 0 ]; then
bashio::log.warning "No s6 environment directory found; leaving the base image's default virtual screen size"
exit 0
fi
bashio::log.info "Virtual screen capped at ${MAX_RESOLUTION} (Selkies still resizes dynamically below this)"

View File

@@ -15,26 +15,6 @@ set -e
# regardless, so every boot picks up the current /defaults/autostart content; ownership/mode
# is left in the normal abc-writable state that init-selkies-config itself uses when
# RESTART_APP is unset, and re-locked by that oneshot afterward if RESTART_APP is set.
# The autostart decides whether to hand Chromium the ANGLE/EGL flags, but it runs as abc under
# openbox where bashio is not available. Publish the resolved option to a file it can read.
# /run is tmpfs, so this is rewritten on every boot and never goes stale.
GPU_MODE="$(bashio::config 'gpu_acceleration' 'auto')"
case "$GPU_MODE" in
auto | on | off) ;;
*)
bashio::log.warning "Unknown gpu_acceleration '${GPU_MODE}'; falling back to auto"
GPU_MODE="auto"
;;
esac
# Best-effort: the autostart falls back to "auto" when the file is absent, so a failure here
# must not abort this script under errexit and leave the openbox autostart unsynced.
if printf '%s\n' "$GPU_MODE" > /run/claude-desktop-gpu-mode 2> /dev/null; then
chmod 0644 /run/claude-desktop-gpu-mode
bashio::log.info "GPU acceleration mode: ${GPU_MODE}"
else
bashio::log.warning "Could not write /run/claude-desktop-gpu-mode; Claude Desktop will probe for GPU support (auto)"
fi
if [ -f /defaults/autostart ]; then
mkdir -p "$HOME/.config/openbox"
cp -f /defaults/autostart "$HOME/.config/openbox/autostart"

View File

@@ -1,196 +0,0 @@
#!/usr/bin/env python3
"""Decide whether Claude Desktop can render on the GPU on this host.
Claude Desktop is Electron/Chromium. Left to itself under Xvfb it probes GLX, finds
only the indirect/software path Xvfb offers, and gives up: the GPU process ends up
running `--use-gl=disabled` and the renderer `--disable-gpu-compositing`, so every
frame is rastered and composited on the CPU. On a small Home Assistant host that is
the single largest CPU consumer the add-on has.
The fix is to point Chromium at ANGLE's OpenGL backend over EGL instead of GLX, but
those flags are only safe where they actually work — forcing them on a host with no
render node, or where Mesa falls back to a software rasterizer, trades a working
software desktop for a black window or a GPU-process crash loop.
So rather than guessing from the presence of /dev/dri, this probe exercises the exact
code path Chromium will use: it loads Claude Desktop's *own bundled ANGLE* libEGL,
initializes the OpenGL backend, creates a real pbuffer context, and reads GL_RENDERER
back. Exit 0 means Chromium's GL stack is known-good here; any other exit means the
caller must leave Chromium alone and keep today's software rendering.
Notes for future readers:
* ANGLE's OpenGL backend needs a reachable X display, so this must run after Xorg is
up (i.e. from the openbox autostart, not from cont-init.d).
* `gles-egl` is deliberately not attempted: Mesa reports "Intel or NVIDIA OpenGL ES
drivers are not supported" and ANGLE refuses to initialize.
* A renderer string naming SwiftShader/llvmpipe/softpipe is a *failure* here. That is
software rendering wearing a GL hat, and forcing the flags for it would add ANGLE
translation overhead on top of the CPU rasterization we are trying to avoid.
"""
import ctypes
import os
import sys
LIBDIR = "/usr/lib/claude-desktop"
# EGL/ANGLE constants (see ANGLE's eglext.h); hardcoded to avoid a build dependency.
EGL_NONE = 0x3038
EGL_PLATFORM_ANGLE_ANGLE = 0x3202
EGL_PLATFORM_ANGLE_TYPE_ANGLE = 0x3203
EGL_PLATFORM_ANGLE_TYPE_OPENGL_ANGLE = 0x320D
EGL_OPENGL_ES_API = 0x30A0
EGL_SURFACE_TYPE = 0x3033
EGL_PBUFFER_BIT = 0x0001
EGL_RENDERABLE_TYPE = 0x3040
EGL_OPENGL_ES2_BIT = 0x0004
EGL_WIDTH = 0x3057
EGL_HEIGHT = 0x3056
EGL_CONTEXT_CLIENT_VERSION = 0x3098
GL_VENDOR = 0x1F00
GL_RENDERER = 0x1F01
SOFTWARE_MARKERS = ("swiftshader", "llvmpipe", "softpipe", "lavapipe", "software rasterizer")
class ProbeFailure(Exception):
"""Raised when this host cannot give Chromium a hardware GL context."""
def log(message):
"""Write a probe diagnostic to stderr, where it lands in the add-on log."""
sys.stderr.write(f"claude-gpu-probe: {message}\n")
def load_angle():
"""Load Claude Desktop's bundled ANGLE and declare the signatures we call."""
egl_path = os.path.join(LIBDIR, "libEGL.so")
gles_path = os.path.join(LIBDIR, "libGLESv2.so")
if not (os.path.exists(egl_path) and os.path.exists(gles_path)):
raise ProbeFailure(f"bundled ANGLE libraries not found under {LIBDIR}")
try:
egl = ctypes.CDLL(egl_path, mode=ctypes.RTLD_GLOBAL)
gles = ctypes.CDLL(gles_path, mode=ctypes.RTLD_GLOBAL)
except OSError as err:
raise ProbeFailure(f"could not load bundled ANGLE: {err}") from err
egl.eglGetProcAddress.restype = ctypes.c_void_p
egl.eglGetError.restype = ctypes.c_int
egl.eglInitialize.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
]
egl.eglCreatePbufferSurface.restype = ctypes.c_void_p
egl.eglCreateContext.restype = ctypes.c_void_p
gles.glGetString.restype = ctypes.c_char_p
gles.glGetString.argtypes = [ctypes.c_uint]
return egl, gles
def open_angle_display(egl):
"""Initialize ANGLE's OpenGL backend and return its EGL display."""
addr = egl.eglGetProcAddress(b"eglGetPlatformDisplayEXT")
if not addr:
raise ProbeFailure("bundled ANGLE has no eglGetPlatformDisplayEXT")
get_platform_display = ctypes.CFUNCTYPE(
ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)
)(addr)
attrs = (ctypes.c_int * 3)(
EGL_PLATFORM_ANGLE_TYPE_ANGLE, EGL_PLATFORM_ANGLE_TYPE_OPENGL_ANGLE, EGL_NONE
)
display = get_platform_display(EGL_PLATFORM_ANGLE_ANGLE, None, attrs)
if not display:
raise ProbeFailure(f"no ANGLE OpenGL display (egl error 0x{egl.eglGetError():x})")
major, minor = ctypes.c_int(), ctypes.c_int()
if not egl.eglInitialize(ctypes.c_void_p(display), ctypes.byref(major), ctypes.byref(minor)):
raise ProbeFailure(
f"ANGLE OpenGL backend failed to initialize (egl error 0x{egl.eglGetError():x})"
)
return display
def make_current_context(egl, display):
"""Bring up a real pbuffer context.
Initialization alone is not proof of anything: only a current context makes
GL_RENDERER report the driver Chromium would actually be handed.
"""
egl.eglBindAPI(EGL_OPENGL_ES_API)
config = ctypes.c_void_p()
count = ctypes.c_int()
config_attrs = (ctypes.c_int * 5)(
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, EGL_NONE
)
chosen = egl.eglChooseConfig(
ctypes.c_void_p(display), config_attrs, ctypes.byref(config), 1, ctypes.byref(count)
)
if not chosen or count.value == 0:
raise ProbeFailure(f"no usable EGL config (egl error 0x{egl.eglGetError():x})")
surface_attrs = (ctypes.c_int * 5)(EGL_WIDTH, 64, EGL_HEIGHT, 64, EGL_NONE)
surface = egl.eglCreatePbufferSurface(ctypes.c_void_p(display), config, surface_attrs)
if not surface:
raise ProbeFailure(f"could not create pbuffer surface (egl error 0x{egl.eglGetError():x})")
context_attrs = (ctypes.c_int * 3)(EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE)
context = egl.eglCreateContext(ctypes.c_void_p(display), config, None, context_attrs)
if not context:
raise ProbeFailure(f"could not create GL context (egl error 0x{egl.eglGetError():x})")
if not egl.eglMakeCurrent(
ctypes.c_void_p(display),
ctypes.c_void_p(surface),
ctypes.c_void_p(surface),
ctypes.c_void_p(context),
):
raise ProbeFailure(
f"could not make the GL context current (egl error 0x{egl.eglGetError():x})"
)
def describe_renderer(gles):
"""Return (renderer, vendor), rejecting software rasterizers."""
renderer = (gles.glGetString(GL_RENDERER) or b"").decode(errors="replace")
vendor = (gles.glGetString(GL_VENDOR) or b"").decode(errors="replace")
if not renderer:
raise ProbeFailure("GL context reported no renderer")
lowered = renderer.lower()
if any(marker in lowered for marker in SOFTWARE_MARKERS):
raise ProbeFailure(
f"software renderer ({renderer}); leaving Chromium on its own software path"
)
return renderer, vendor
def main():
"""Exit 0 only when Chromium's GL stack is known-good on this host."""
if not os.environ.get("DISPLAY"):
log("no DISPLAY; ANGLE's OpenGL backend needs an X server")
return 1
try:
egl, gles = load_angle()
display = open_angle_display(egl)
make_current_context(egl, display)
renderer, vendor = describe_renderer(gles)
except ProbeFailure as err:
log(str(err))
return 1
# A probe is advisory: whatever goes wrong in these native calls, the desktop must still
# start. Any unexpected failure is reported and treated as "no GPU".
# pylint: disable=broad-exception-caught
except Exception as err:
log(f"unexpected probe error: {err}")
return 1
log(f"hardware GL available: {renderer} | {vendor}")
return 0
if __name__ == "__main__":
sys.exit(main())