diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index 7e542077eb..878ac382df 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -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 diff --git a/claude_desktop/Dockerfile b/claude_desktop/Dockerfile index 3e8de0af87..4d1cd34206 100644 --- a/claude_desktop/Dockerfile +++ b/claude_desktop/Dockerfile @@ -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 && \ diff --git a/claude_desktop/README.md b/claude_desktop/README.md index 7e283248db..5d6665cede 100644 --- a/claude_desktop/README.md +++ b/claude_desktop/README.md @@ -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`. | diff --git a/claude_desktop/config.yaml b/claude_desktop/config.yaml index 4ec9bcb829..6ff50d449f 100644 --- a/claude_desktop/config.yaml +++ b/claude_desktop/config.yaml @@ -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 diff --git a/claude_desktop/rootfs/defaults/autostart b/claude_desktop/rootfs/defaults/autostart index e054ac7e70..85ee09cfdc 100644 --- a/claude_desktop/rootfs/defaults/autostart +++ b/claude_desktop/rootfs/defaults/autostart @@ -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 diff --git a/claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh b/claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh new file mode 100755 index 0000000000..084ac5d028 --- /dev/null +++ b/claude_desktop/rootfs/etc/cont-init.d/22-display_tuning.sh @@ -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)" diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index 9568d867f6..b27aae7efa 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -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 diff --git a/claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh b/claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh index feb6b60dd9..70960d5f98 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/85-openbox_autostart.sh @@ -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" diff --git a/claude_desktop/rootfs/usr/local/bin/claude-gpu-probe b/claude_desktop/rootfs/usr/local/bin/claude-gpu-probe new file mode 100755 index 0000000000..95bb4f0b40 --- /dev/null +++ b/claude_desktop/rootfs/usr/local/bin/claude-gpu-probe @@ -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())