mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-10-08 01:01:35 +02:00
Compare commits
5 Commits
c0aa5af0ce
...
49dd9f0e4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49dd9f0e4e | ||
|
|
b453c60c33 | ||
|
|
aceeaf36b4 | ||
|
|
9faae5cc90 | ||
|
|
d3d3476986 |
@@ -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)
|
## 2026.08.02 (02-08-2026)
|
||||||
- Minor bugs fixed
|
- 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
|
# /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
|
# 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.
|
# 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 update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
intel-media-va-driver-non-free \
|
intel-media-va-driver-non-free \
|
||||||
@@ -155,7 +167,7 @@ RUN if [[ "${TARGETARCH}" == "amd64" ]]; then \
|
|||||||
vainfo && \
|
vainfo && \
|
||||||
test -f /usr/lib/x86_64-linux-gnu/dri/iHD_drv_video.so && \
|
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/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; \
|
command -v vainfo > /dev/null; \
|
||||||
fi && \
|
fi && \
|
||||||
apt-get clean && \
|
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. |
|
| `KEYBOARD` | | Optional Selkies keyboard layout. |
|
||||||
| `PASSWORD` | | Optional password for direct Selkies ports. |
|
| `PASSWORD` | | Optional password for direct Selkies ports. |
|
||||||
| `DRINODE` | | Optional GPU device override for Selkies. |
|
| `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. |
|
| `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`. |
|
| `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. |
|
||||||
| `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. |
|
| `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_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. |
|
||||||
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
|
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
|
||||||
| `tokensave_project_paths` | `[]` | Explicit absolute Git repository paths to initialize or sync at startup. |
|
| `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_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. |
|
| `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`. |
|
| `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: ""
|
github_username: ""
|
||||||
enable_tools_health_report: true
|
enable_tools_health_report: true
|
||||||
expose_headroom_dashboard: false
|
expose_headroom_dashboard: false
|
||||||
|
gpu_acceleration: auto
|
||||||
headroom_auto_compress: true
|
headroom_auto_compress: true
|
||||||
headroom_wrap_claude_code: true
|
headroom_wrap_claude_code: true
|
||||||
install_caveman: false
|
install_caveman: false
|
||||||
@@ -65,6 +66,17 @@ options:
|
|||||||
install_headroom: true
|
install_headroom: true
|
||||||
install_rtk: true
|
install_rtk: true
|
||||||
install_tokensave: true
|
install_tokensave: true
|
||||||
|
max_resolution: 1920x1080
|
||||||
|
mcp_servers_desktop:
|
||||||
|
- headroom
|
||||||
|
- tokensave
|
||||||
|
- homeassistant
|
||||||
|
- codex
|
||||||
|
mcp_servers_code:
|
||||||
|
- headroom
|
||||||
|
- tokensave
|
||||||
|
- homeassistant
|
||||||
|
- codex
|
||||||
permission_mode: auto
|
permission_mode: auto
|
||||||
tokensave_project_paths: []
|
tokensave_project_paths: []
|
||||||
panel_admin: false
|
panel_admin: false
|
||||||
@@ -106,6 +118,7 @@ schema:
|
|||||||
github_username: str?
|
github_username: str?
|
||||||
enable_tools_health_report: bool
|
enable_tools_health_report: bool
|
||||||
expose_headroom_dashboard: bool
|
expose_headroom_dashboard: bool
|
||||||
|
gpu_acceleration: list(auto|on|off)?
|
||||||
headroom_auto_compress: bool?
|
headroom_auto_compress: bool?
|
||||||
headroom_wrap_claude_code: bool
|
headroom_wrap_claude_code: bool
|
||||||
install_caveman: bool
|
install_caveman: bool
|
||||||
@@ -115,11 +128,16 @@ schema:
|
|||||||
install_headroom: bool
|
install_headroom: bool
|
||||||
install_rtk: bool
|
install_rtk: bool
|
||||||
install_tokensave: 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)
|
permission_mode: list(strict|auto|bypass)
|
||||||
tokensave_project_paths:
|
tokensave_project_paths:
|
||||||
- str
|
- str
|
||||||
slug: claude_desktop
|
slug: claude_desktop
|
||||||
udev: true
|
udev: true
|
||||||
url: https://github.com/alexbelgium/hassio-addons
|
url: https://github.com/alexbelgium/hassio-addons
|
||||||
version: "2026.08.02"
|
version: "2026.08.03"
|
||||||
video: true
|
video: true
|
||||||
|
|||||||
@@ -20,4 +20,67 @@
|
|||||||
# Headroom is intentionally not injected into the Desktop process: Claude Desktop
|
# Headroom is intentionally not injected into the Desktop process: Claude Desktop
|
||||||
# force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom
|
# force-overrides ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom
|
||||||
# MCP tools instead.
|
# 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
|
fi
|
||||||
|
|
||||||
HA_MCP_ENABLED=false
|
HA_MCP_ENABLED=false
|
||||||
HA_MCP_URL=""
|
|
||||||
HA_MCP_TOKEN=""
|
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
|
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
|
if bashio::config.has_value 'ha_mcp_token'; then
|
||||||
HA_MCP_TOKEN="$(bashio::config 'ha_mcp_token')"
|
HA_MCP_TOKEN="$(bashio::config 'ha_mcp_token')"
|
||||||
fi
|
fi
|
||||||
@@ -325,12 +328,73 @@ if bashio::config.true 'enable_ha_mcp'; then
|
|||||||
fi
|
fi
|
||||||
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_ENABLED="$HEADROOM_ENABLED" HEADROOM_BIN="$(command -v headroom || echo headroom)" \
|
||||||
HEADROOM_HF_HOME="${HOME}/.headroom/hf" \
|
HEADROOM_HF_HOME="${HOME}/.headroom/hf" \
|
||||||
TOKENSAVE_ENABLED="$TOKENSAVE_ENABLED" TOKENSAVE_BIN="$(command -v tokensave || echo tokensave)" \
|
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" \
|
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" \
|
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_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" \
|
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"
|
python3 - <<'PY' || bashio::log.warning "Unable to update the MCP server registrations automatically"
|
||||||
import json
|
import json
|
||||||
@@ -385,6 +449,23 @@ if os.environ["HA_MCP_ENABLED"] == "true":
|
|||||||
"env": {"API_ACCESS_TOKEN": os.environ["HA_MCP_TOKEN"]},
|
"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
|
# 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
|
# 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
|
# 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
|
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):
|
if not isinstance(entry, dict):
|
||||||
return False
|
return False
|
||||||
command = entry.get("command")
|
command = entry.get("command")
|
||||||
if not isinstance(command, str) or command.startswith(HOME_PREFIX):
|
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 False
|
||||||
return os.path.basename(command) == MANAGED_BASENAMES[name]
|
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)):
|
for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_CONFIG", True)):
|
||||||
path = Path(os.environ[config_var])
|
path = Path(os.environ[config_var])
|
||||||
|
selected = SELECTED[config_var]
|
||||||
|
owned_url = state.get(str(path), {}).get("homeassistant_http_url", "")
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text()) if path.exists() else {}
|
data = json.loads(path.read_text()) if path.exists() else {}
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
@@ -417,17 +558,35 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C
|
|||||||
changed = False
|
changed = False
|
||||||
for name in MANAGED_BASENAMES:
|
for name in MANAGED_BASENAMES:
|
||||||
existing = servers.get(name)
|
existing = servers.get(name)
|
||||||
if name in desired:
|
if name in desired and name in selected:
|
||||||
entry = dict(desired[name])
|
if stdio_type and name == "homeassistant" and HA_MCP_CODE_ENTRY is not None:
|
||||||
if stdio_type:
|
# Claude Code talks to Home Assistant over HTTP directly; no bridge process.
|
||||||
entry["type"] = "stdio"
|
entry = dict(HA_MCP_CODE_ENTRY)
|
||||||
if existing is None or is_managed(name, existing):
|
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:
|
if existing != entry:
|
||||||
servers[name] = entry
|
servers[name] = entry
|
||||||
changed = True
|
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]
|
del servers[name]
|
||||||
changed = True
|
changed = True
|
||||||
|
if name == "homeassistant" and state.get(str(path), {}).pop(
|
||||||
|
"homeassistant_http_url", None
|
||||||
|
):
|
||||||
|
owned_url = ""
|
||||||
|
state_changed = True
|
||||||
if changed:
|
if changed:
|
||||||
if servers:
|
if servers:
|
||||||
data["mcpServers"] = servers
|
data["mcpServers"] = servers
|
||||||
@@ -440,6 +599,9 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C
|
|||||||
# permissions between merges.
|
# permissions between merges.
|
||||||
if path.exists():
|
if path.exists():
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
if state_changed:
|
||||||
|
save_state(state)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# Guide Claude to actually use the Headroom compression tools so the MCP integration produces
|
# 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
|
# 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
|
# 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.
|
# 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
|
if [ -f /defaults/autostart ]; then
|
||||||
mkdir -p "$HOME/.config/openbox"
|
mkdir -p "$HOME/.config/openbox"
|
||||||
cp -f /defaults/autostart "$HOME/.config/openbox/autostart"
|
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