mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-20 03:47:20 +02:00
perf(claude_desktop): render on the GPU and stop duplicating MCP servers (#2934)
* perf(claude_desktop): render on the GPU and stop duplicating MCP servers Measured live inside a running add-on (amd64, 4 cores): 3388 MB RSS across 73 processes, with the Electron renderer burning ~44% of a core even with no browser client connected. GPU: Chromium was rendering everything on the CPU. Under Xvfb it probes GLX, finds only Xvfb's indirect/software path, and falls back to `--use-gl=disabled` plus `--disable-gpu-compositing` — while a perfectly good iGPU sits idle behind /dev/dri. Claude Desktop is now launched through ANGLE's OpenGL backend over EGL, but only when the new claude-gpu-probe confirms that Desktop's own bundled ANGLE can create a hardware GL context on this host; the probe rejects llvmpipe/SwiftShader, is bounded by a timeout, and any failure leaves the command line exactly as it was. New `gpu_acceleration` option (auto|on|off). MCP: every stdio MCP server is a separate process per client, and Desktop starts another full set for each Claude Code session it hosts. Claude Code now reaches the Home Assistant MCP server over its native HTTP transport instead of the mcp-proxy stdio bridge, removing the most expensive duplicate (~45 MB of private RSS per copy). Desktop keeps the bridge: its remote-entry config schema could not be confirmed, and guessing would silently break it. New `mcp_servers_desktop` / `mcp_servers_code` options let each client register only what it actually uses; defaults are unchanged. Display: new `max_resolution` option (default 1920x1080) caps the virtual screen via the base image's MAX_RES. Xvfb ran at 15360x8640, so it and the Selkies capture loop tracked damage over a 133-megapixel area continuously. This is a CPU saving, not a memory one — the framebuffer is a lazily populated shared segment whose unused portion was never resident. Dockerfile: the Intel graphics block was dead code. It was gated on TARGETARCH, which this repo's builder does not pass, so it 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. It now uses BUILD_ARCH, and its Vulkan ICD check no longer names intel_icd.x86_64.json, a file Debian does not ship. Also removes `--disable-dev-shm-usage` (a workaround for a 64 MB /dev/shm; this image has 7.7 GB) and fixes stale Home Assistant MCP registrations, including the bearer token inside them, being left behind when enable_ha_mcp is disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(claude_desktop): satisfy static analysis in claude-gpu-probe Codacy flagged two new issues, both in the probe: a broad exception catch and too many locals in main(). Split the EGL bring-up into load_angle(), open_angle_display(), make_current_context() and describe_renderer(), each raising a dedicated ProbeFailure, so the failure paths read as intent rather than as a chain of early returns. The catch-all remains — a probe must never stop the desktop from starting — but is now explicit and narrowly scoped. No behaviour change: exits 0 with a hardware renderer under DISPLAY, 1 without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(claude_desktop): address PR review on shm and MCP entry ownership Two review findings, both correct. /dev/shm: dropping --disable-dev-shm-usage outright was generalised from one host. The flag was added to fix a real Electron renderer crash loop on Docker's 64 MB default, and Home Assistant ignores the add-on's shm_size, so the size genuinely varies per install and cannot be asserted from this repo. The size is now read at startup: the workaround is kept below 256 MB, dropped above it, and kept when the size cannot be determined. MCP ownership: claiming an HTTP 'homeassistant' entry by URL and shape would have deleted a user's own manually configured server on the first boot after upgrade, since ha_mcp_url defaults to the same public endpoint that a hand- written entry would use, and enable_ha_mcp defaults to false. An HTTP entry is now only ever modified or removed when the add-on recorded writing it, in ~/.config/claude_desktop_addon/managed-mcp.json. Anything not written by the add-on is untouchable regardless of how it looks. Also drops the invalid '?' optional marker from the list *item* type in the mcp_servers_* schema; both keys always carry defaults, so the marker was meaningless as well as wrong. Tests cover the regression directly: a user-owned HTTP entry on the default URL now survives both a disabled and an enabled boot, while the add-on's own entry is still removed with its token when enable_ha_mcp is turned off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(claude_desktop): tighten max_resolution validation and reporting grep anchors ^...$ per line, so a multi-line max_resolution such as "1920x1080\n640x480" passed validation on its first line and was then written to MAX_RES verbatim, leaving svc-xorg with a corrupt screen size. Bash's =~ anchors the whole string and rejects the embedded newline. Also stop reporting success when no s6 environment directory existed and nothing was written — the cap silently did not apply, and the log said it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,37 @@
|
||||
## 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
|
||||
`--use-gl=disabled` + `--disable-gpu-compositing`, making the renderer the add-on's largest
|
||||
CPU consumer. It is now launched with `--ozone-platform=x11 --use-gl=angle --use-angle=gl-egl`,
|
||||
but only when the new `claude-gpu-probe` confirms Claude Desktop's own bundled ANGLE can
|
||||
create a hardware GL context on this host. New `gpu_acceleration` option (`auto`/`on`/`off`,
|
||||
default `auto`); any probe failure keeps the previous software rendering unchanged.
|
||||
- Performance: new `max_resolution` option (default `1920x1080`) caps the virtual screen via the
|
||||
base image's `MAX_RES`. Xvfb previously ran at 15360x8640, so Xvfb and the Selkies capture
|
||||
loop tracked damage over a 133-megapixel area continuously, even with no browser connected.
|
||||
Selkies still resizes dynamically below the cap.
|
||||
- Performance: Claude Code now talks to the Home Assistant MCP server over its native HTTP
|
||||
transport instead of through the `mcp-proxy` stdio bridge, removing one Python process per
|
||||
Claude Code session (~45 MB of private resident memory each). Claude Desktop keeps the
|
||||
bridge, as the config schema for a remote entry there is not confirmed.
|
||||
- Performance: new `mcp_servers_desktop` / `mcp_servers_code` options select which MCP servers
|
||||
each client registers. Every stdio MCP server is a separate process per client, and Desktop
|
||||
starts another full set per Claude Code session it hosts. Defaults register all of them in
|
||||
both clients, i.e. the previous behaviour.
|
||||
- Fix: the Dockerfile's Intel graphics block was dead code. It was gated on `TARGETARCH`, which
|
||||
the repo's builder does not pass, so it never ran: the shipped amd64 image has no `vainfo` and
|
||||
no `intel-media-va-driver-non-free`. It now uses `BUILD_ARCH`, and its Vulkan ICD check no
|
||||
longer names `intel_icd.x86_64.json`, a file Debian does not ship.
|
||||
- Fix: stale Home Assistant MCP registrations (and the bearer token in them) are now removed
|
||||
from Claude Code's config when `enable_ha_mcp` is turned off. Ownership of an HTTP entry is
|
||||
recorded when the add-on writes it, so a manually configured `homeassistant` server is never
|
||||
claimed, overwritten, or deleted — not even when it sits on the default URL.
|
||||
- `--disable-dev-shm-usage` is now applied only when `/dev/shm` is actually small (under
|
||||
256 MB). It is a workaround for Docker's 64 MB default, but Home Assistant ignores the add-on's
|
||||
`shm_size` so the real size varies per install; the size is now read at startup, keeping the
|
||||
crash workaround where it is needed and dropping it where it only pushed Chromium's shared
|
||||
memory into ordinary files. If the size cannot be determined, the flag is kept.
|
||||
|
||||
## 2026.08.02 (02-08-2026)
|
||||
- Migrate deprecated Home Assistant map names to their app equivalents.
|
||||
- Minor bugs fixed
|
||||
|
||||
@@ -146,7 +146,19 @@ RUN install -d -m 0755 /etc/apt/keyrings && \
|
||||
# /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.
|
||||
RUN if [[ "${TARGETARCH}" == "amd64" ]]; then \
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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 \
|
||||
@@ -155,7 +167,7 @@ RUN if [[ "${TARGETARCH}" == "amd64" ]]; then \
|
||||
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 && \
|
||||
test -f /usr/share/vulkan/icd.d/intel_icd.x86_64.json && \
|
||||
ls /usr/share/vulkan/icd.d/intel_icd*.json > /dev/null && \
|
||||
command -v vainfo > /dev/null; \
|
||||
fi && \
|
||||
apt-get clean && \
|
||||
|
||||
@@ -97,6 +97,8 @@ 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. |
|
||||
| `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. |
|
||||
@@ -106,6 +108,8 @@ Git synchronization hooks. A repository is indexed only when it is listed in
|
||||
| `install_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. |
|
||||
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
|
||||
| `tokensave_project_paths` | `[]` | Explicit absolute Git repository paths to initialize or sync at startup. |
|
||||
| `mcp_servers_desktop` | all | Which managed MCP servers Claude Desktop registers (`headroom`, `tokensave`, `homeassistant`, `codex`). |
|
||||
| `mcp_servers_code` | all | Which managed MCP servers Claude Code registers. Each stdio server is a separate process per client, and Desktop starts another set per Claude Code session it hosts, so trimming this is the cheapest way to cut memory. |
|
||||
| `install_caveman` | `false` | Install the third-party Caveman Claude Code plugin at startup. |
|
||||
| `install_codex_cli` | `false` | Install the latest stable OpenAI Codex CLI at startup and register its native MCP server so Claude can delegate work to ChatGPT Codex. |
|
||||
| `codex_sandbox_mode` | `workspace-write` | Filesystem scope Codex runs with: `read-only`, `workspace-write`, or `danger-full-access`. |
|
||||
|
||||
@@ -56,6 +56,7 @@ 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
|
||||
@@ -65,6 +66,17 @@ options:
|
||||
install_headroom: true
|
||||
install_rtk: true
|
||||
install_tokensave: true
|
||||
max_resolution: 1920x1080
|
||||
mcp_servers_desktop:
|
||||
- headroom
|
||||
- tokensave
|
||||
- homeassistant
|
||||
- codex
|
||||
mcp_servers_code:
|
||||
- headroom
|
||||
- tokensave
|
||||
- homeassistant
|
||||
- codex
|
||||
permission_mode: auto
|
||||
tokensave_project_paths: []
|
||||
panel_admin: false
|
||||
@@ -106,6 +118,7 @@ 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
|
||||
@@ -115,11 +128,16 @@ schema:
|
||||
install_headroom: bool
|
||||
install_rtk: bool
|
||||
install_tokensave: bool
|
||||
max_resolution: str?
|
||||
mcp_servers_desktop:
|
||||
- list(headroom|tokensave|homeassistant|codex)
|
||||
mcp_servers_code:
|
||||
- list(headroom|tokensave|homeassistant|codex)
|
||||
permission_mode: list(strict|auto|bypass)
|
||||
tokensave_project_paths:
|
||||
- str
|
||||
slug: claude_desktop
|
||||
udev: true
|
||||
url: https://github.com/alexbelgium/hassio-addons
|
||||
version: "2026.08.02"
|
||||
version: "2026.08.03"
|
||||
video: true
|
||||
|
||||
@@ -20,4 +20,67 @@
|
||||
# Headroom is intentionally not injected into the Desktop process: Claude Desktop
|
||||
# force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom
|
||||
# MCP tools instead.
|
||||
exec claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=basic
|
||||
|
||||
# GPU acceleration.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# `--use-angle=gles-egl` is deliberately not used: Mesa rejects it with "Intel or NVIDIA
|
||||
# OpenGL ES drivers are not supported".
|
||||
#
|
||||
# /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
|
||||
|
||||
# Shared memory.
|
||||
#
|
||||
# --disable-dev-shm-usage exists because Docker's default /dev/shm is 64 MB, which is not
|
||||
# enough for Chromium's renderers and produced a crash loop here (see the 1.3 changelog entry).
|
||||
# Home Assistant ignores the add-on's shm_size, so the size cannot be set from this repo and
|
||||
# genuinely varies between installs — it is 7.7 GB on some hosts and the 64 MB default on
|
||||
# others. Hardcoding either answer is wrong, so ask the kernel: keep the workaround when
|
||||
# /dev/shm is small, and drop it when there is plenty, where it would otherwise push Chromium's
|
||||
# shared memory into ordinary files for no benefit. If the size cannot be determined, keep the
|
||||
# flag — the crash it prevents is worse than the overhead it costs.
|
||||
SHM_FLAGS="--disable-dev-shm-usage"
|
||||
SHM_KB="$(df -k /dev/shm 2> /dev/null | awk 'NR==2 {print $2}')"
|
||||
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.
|
||||
# shellcheck disable=SC2086
|
||||
exec claude-desktop --no-sandbox --password-store=basic $SHM_FLAGS $GPU_FLAGS
|
||||
|
||||
66
claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh
Executable file
66
claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/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)"
|
||||
@@ -308,10 +308,13 @@ if bashio::config.true 'install_codex_cli'; then
|
||||
fi
|
||||
|
||||
HA_MCP_ENABLED=false
|
||||
HA_MCP_URL=""
|
||||
HA_MCP_TOKEN=""
|
||||
# Read unconditionally, even when enable_ha_mcp is off. Home Assistant keeps an option's value
|
||||
# when its toggle is disabled, and the reconciliation below needs this URL to recognise the
|
||||
# HTTP entry it previously wrote so that it can be removed — together with the bearer token
|
||||
# inside it — rather than orphaned in ~/.claude.json.
|
||||
HA_MCP_URL="$(bashio::config 'ha_mcp_url' 'http://homeassistant:8123/api/mcp')"
|
||||
if bashio::config.true 'enable_ha_mcp'; then
|
||||
HA_MCP_URL="$(bashio::config 'ha_mcp_url' 'http://homeassistant:8123/api/mcp')"
|
||||
if bashio::config.has_value 'ha_mcp_token'; then
|
||||
HA_MCP_TOKEN="$(bashio::config 'ha_mcp_token')"
|
||||
fi
|
||||
@@ -325,12 +328,73 @@ if bashio::config.true 'enable_ha_mcp'; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Which of the managed MCP servers each client gets.
|
||||
#
|
||||
# Every stdio MCP server is a separate process *per client*, and Claude Desktop starts another
|
||||
# full set for each Claude Code session it hosts — so a server registered in both clients is
|
||||
# paid for several times over. Measured on a live add-on with three sets running, the private
|
||||
# (non-shared) resident cost was roughly 54 MB per extra `headroom mcp serve`, 45 MB per extra
|
||||
# `mcp-proxy`, and only ~11 MB and ~2 MB for `codex` and `tokensave`, which share most of their
|
||||
# pages. Registering a server only where it is actually used is therefore the cheapest lever
|
||||
# available; these two options expose that choice.
|
||||
#
|
||||
# Defaults keep every enabled server in both clients, i.e. the pre-existing behaviour.
|
||||
MCP_ALL_SERVERS="headroom tokensave homeassistant codex"
|
||||
|
||||
mcp_client_list() {
|
||||
local option="$1"
|
||||
local selected=() entry raw rc=0
|
||||
|
||||
# An option that is absent entirely — i.e. an existing install upgrading from a config that
|
||||
# predates these options — keeps the previous behaviour of registering every enabled server.
|
||||
# bashio distinguishes this from an explicitly empty list: an unset key yields the literal
|
||||
# "null", while `[]` yields an empty string. Those must not be conflated, because an empty
|
||||
# list is a legitimate way to say "no MCP servers in this client" and defaulting it back to
|
||||
# all four would silently ignore the user.
|
||||
if ! bashio::config.exists "$option"; then
|
||||
echo "$MCP_ALL_SERVERS"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Capture first: reading a bashio list straight into `while read` via process substitution
|
||||
# silently yields nothing under this script's errexit. The exit status is kept separately
|
||||
# so that a failed read is not mistaken for a deliberate empty selection.
|
||||
raw="$(bashio::config "$option" 2> /dev/null)" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
bashio::log.warning "Could not read '${option}'; registering every enabled MCP server for this client"
|
||||
echo "$MCP_ALL_SERVERS"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while read -r entry; do
|
||||
[ -n "$entry" ] || continue
|
||||
# Reconciliation deletes any managed server not named here, so an unrecognised value
|
||||
# must never be treated as an authoritative selection.
|
||||
case " $MCP_ALL_SERVERS " in
|
||||
*" $entry "*) selected+=("$entry") ;;
|
||||
*)
|
||||
bashio::log.warning "Ignoring unknown MCP server '${entry}' in '${option}'; registering every enabled server for this client"
|
||||
echo "$MCP_ALL_SERVERS"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
|
||||
echo "${selected[@]:-}"
|
||||
}
|
||||
|
||||
MCP_SERVERS_DESKTOP="$(mcp_client_list 'mcp_servers_desktop')"
|
||||
MCP_SERVERS_CODE="$(mcp_client_list 'mcp_servers_code')"
|
||||
bashio::log.info "MCP servers for Claude Desktop: ${MCP_SERVERS_DESKTOP}"
|
||||
bashio::log.info "MCP servers for Claude Code: ${MCP_SERVERS_CODE}"
|
||||
|
||||
HEADROOM_ENABLED="$HEADROOM_ENABLED" HEADROOM_BIN="$(command -v headroom || echo headroom)" \
|
||||
HEADROOM_HF_HOME="${HOME}/.headroom/hf" \
|
||||
TOKENSAVE_ENABLED="$TOKENSAVE_ENABLED" TOKENSAVE_BIN="$(command -v tokensave || echo tokensave)" \
|
||||
CODEX_ENABLED="$CODEX_ENABLED" CODEX_BIN="$CODEX_BIN" CODEX_SANDBOX_MODE="$CODEX_SANDBOX_MODE" \
|
||||
HA_MCP_ENABLED="$HA_MCP_ENABLED" HA_MCP_URL="$HA_MCP_URL" HA_MCP_TOKEN="$HA_MCP_TOKEN" \
|
||||
MCP_PROXY_BIN="$(command -v mcp-proxy || echo mcp-proxy)" \
|
||||
MCP_SERVERS_DESKTOP="$MCP_SERVERS_DESKTOP" MCP_SERVERS_CODE="$MCP_SERVERS_CODE" \
|
||||
CLAUDE_DESKTOP_CONFIG="$CLAUDE_DESKTOP_CONFIG" CLAUDE_CODE_CONFIG="$CLAUDE_CODE_CONFIG" \
|
||||
python3 - <<'PY' || bashio::log.warning "Unable to update the MCP server registrations automatically"
|
||||
import json
|
||||
@@ -385,6 +449,23 @@ if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
"env": {"API_ACCESS_TOKEN": os.environ["HA_MCP_TOKEN"]},
|
||||
}
|
||||
|
||||
# Claude Code speaks Streamable HTTP MCP natively, so pointing it straight at Home Assistant
|
||||
# removes the mcp-proxy bridge process entirely — it exists only to translate stdio to the HTTP
|
||||
# transport Home Assistant already serves. That bridge was the most expensive duplicate
|
||||
# measured (~45 MB of private RSS per copy, one per Claude Code session).
|
||||
#
|
||||
# Claude Desktop keeps the stdio bridge. Its bundled MCP SDK does contain a remote transport,
|
||||
# but the shape `claude_desktop_config.json` accepts for a remote entry — and whether it
|
||||
# persists a static bearer header — could not be confirmed, and a wrong guess would silently
|
||||
# break Home Assistant access in Desktop. Revisit once that schema is verified upstream.
|
||||
HA_MCP_CODE_ENTRY = None
|
||||
if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
HA_MCP_CODE_ENTRY = {
|
||||
"type": "http",
|
||||
"url": os.environ["HA_MCP_URL"],
|
||||
"headers": {"Authorization": "Bearer " + os.environ["HA_MCP_TOKEN"]},
|
||||
}
|
||||
|
||||
# An entry is add-on-managed when its command is one of our binaries living outside the
|
||||
# persistent home. Matching on the basename (rather than the exact path recorded at write
|
||||
# time) keeps entries updatable when a base-image upgrade moves the binary, while commands
|
||||
@@ -392,17 +473,77 @@ if os.environ["HA_MCP_ENABLED"] == "true":
|
||||
HOME_PREFIX = os.path.expanduser("~") + os.sep
|
||||
|
||||
|
||||
def is_managed(name, entry):
|
||||
# Ownership record for the HTTP Home Assistant entry.
|
||||
#
|
||||
# The stdio entries can be recognised on sight, because their `command` points at a binary this
|
||||
# image installs outside $HOME. An HTTP entry has no such tell: it is just a URL plus a bearer
|
||||
# header, and a user who configured `homeassistant` by hand — very plausibly at the same default
|
||||
# http://homeassistant:8123/api/mcp — would be indistinguishable from ours. Inferring ownership
|
||||
# from shape or URL would let this script delete or overwrite that entry, including their token.
|
||||
#
|
||||
# So ownership is recorded rather than guessed: the URL of an entry this script actually wrote is
|
||||
# remembered here, and only an entry matching that record is ever modified or removed. Anything
|
||||
# this script did not write is untouchable, whatever it looks like. The file holds no secrets —
|
||||
# just the endpoint — but is written 0600 to match the configs it describes.
|
||||
STATE_PATH = Path(os.path.expanduser("~")) / ".config" / "claude_desktop_addon" / "managed-mcp.json"
|
||||
|
||||
|
||||
def load_state():
|
||||
try:
|
||||
state = json.loads(STATE_PATH.read_text())
|
||||
return state if isinstance(state, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state):
|
||||
try:
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
||||
STATE_PATH.chmod(0o600)
|
||||
except Exception:
|
||||
# Losing the record only costs us the ability to clean up later; never fail the boot.
|
||||
pass
|
||||
|
||||
|
||||
def is_managed_http_ha(entry, owned_url):
|
||||
"""True only for an HTTP entry this script previously wrote."""
|
||||
return (
|
||||
bool(owned_url)
|
||||
and entry.get("url") == owned_url
|
||||
and set(entry) == {"type", "url", "headers"}
|
||||
and entry.get("type") == "http"
|
||||
and isinstance(entry.get("headers"), dict)
|
||||
and set(entry["headers"]) == {"Authorization"}
|
||||
)
|
||||
|
||||
|
||||
def is_managed(name, entry, owned_url):
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
command = entry.get("command")
|
||||
if not isinstance(command, str) or command.startswith(HOME_PREFIX):
|
||||
# A commandless entry is ours only when it is an HTTP Home Assistant registration this
|
||||
# script recorded writing. Anything else — including a user's own remote server that
|
||||
# reuses the name, even on the same URL — is left alone.
|
||||
if command is None and name == "homeassistant":
|
||||
return is_managed_http_ha(entry, owned_url)
|
||||
return False
|
||||
return os.path.basename(command) == MANAGED_BASENAMES[name]
|
||||
|
||||
|
||||
SELECTED = {
|
||||
"CLAUDE_DESKTOP_CONFIG": set(os.environ["MCP_SERVERS_DESKTOP"].split()),
|
||||
"CLAUDE_CODE_CONFIG": set(os.environ["MCP_SERVERS_CODE"].split()),
|
||||
}
|
||||
|
||||
state = load_state()
|
||||
state_changed = False
|
||||
|
||||
for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_CONFIG", True)):
|
||||
path = Path(os.environ[config_var])
|
||||
selected = SELECTED[config_var]
|
||||
owned_url = state.get(str(path), {}).get("homeassistant_http_url", "")
|
||||
try:
|
||||
data = json.loads(path.read_text()) if path.exists() else {}
|
||||
if not isinstance(data, dict):
|
||||
@@ -417,17 +558,35 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C
|
||||
changed = False
|
||||
for name in MANAGED_BASENAMES:
|
||||
existing = servers.get(name)
|
||||
if name in desired:
|
||||
entry = dict(desired[name])
|
||||
if stdio_type:
|
||||
entry["type"] = "stdio"
|
||||
if existing is None or is_managed(name, existing):
|
||||
if name in desired and name in selected:
|
||||
if stdio_type and name == "homeassistant" and HA_MCP_CODE_ENTRY is not None:
|
||||
# Claude Code talks to Home Assistant over HTTP directly; no bridge process.
|
||||
entry = dict(HA_MCP_CODE_ENTRY)
|
||||
else:
|
||||
entry = dict(desired[name])
|
||||
if stdio_type:
|
||||
entry["type"] = "stdio"
|
||||
# An entry we did not write is never overwritten, so a user's own HTTP
|
||||
# `homeassistant` survives even when it sits on the configured URL.
|
||||
claimable = existing is None or is_managed(name, existing, owned_url)
|
||||
if claimable:
|
||||
if existing != entry:
|
||||
servers[name] = entry
|
||||
changed = True
|
||||
elif existing is not None and is_managed(name, existing):
|
||||
if name == "homeassistant" and entry.get("type") == "http":
|
||||
if owned_url != entry["url"]:
|
||||
state.setdefault(str(path), {})["homeassistant_http_url"] = entry["url"]
|
||||
owned_url = entry["url"]
|
||||
state_changed = True
|
||||
elif existing is not None and is_managed(name, existing, owned_url):
|
||||
# Covers both "feature disabled" and "deselected for this client".
|
||||
del servers[name]
|
||||
changed = True
|
||||
if name == "homeassistant" and state.get(str(path), {}).pop(
|
||||
"homeassistant_http_url", None
|
||||
):
|
||||
owned_url = ""
|
||||
state_changed = True
|
||||
if changed:
|
||||
if servers:
|
||||
data["mcpServers"] = servers
|
||||
@@ -440,6 +599,9 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C
|
||||
# permissions between merges.
|
||||
if path.exists():
|
||||
path.chmod(0o600)
|
||||
|
||||
if state_changed:
|
||||
save_state(state)
|
||||
PY
|
||||
|
||||
# Guide Claude to actually use the Headroom compression tools so the MCP integration produces
|
||||
|
||||
@@ -15,6 +15,26 @@ 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"
|
||||
|
||||
196
claude_desktop/rootfs/usr/local/bin/claude-gpu-probe
Executable file
196
claude_desktop/rootfs/usr/local/bin/claude-gpu-probe
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user