From cd6ef34d5299f564d36a6a35a02081989b90e3e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20CREMOUX?= Date: Fri, 4 Sep 2026 22:44:45 +0200 Subject: [PATCH] Load integration icons through the Home Assistant brands proxy Home Assistant 2026.3 moved brand images behind a local, token-gated proxy at /api/brands/. That proxy also serves the brand/ assets custom integrations now ship themselves, since home-assistant/brands stopped accepting images for custom integrations. The dashboard still built icon URLs against the public CDN through the pinned homeassistant-frontend, so every integration that is not grandfathered into the CDN rendered the placeholder image. Add a small brands-url helper mirroring the upstream module: resolve the brands access token over the WebSocket alongside the initial data, keep it fresh on the same 30 minute cadence core rotates it, and pick up a new one after a core restart. Integration icons are now requested from the proxy with that token, and the CDN URL remains the fallback on cores without the proxy. --- src/dashboards/hacs-dashboard.ts | 3 +- src/hacs.ts | 18 +++++++ src/tools/brands-url.ts | 84 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/tools/brands-url.ts diff --git a/src/dashboards/hacs-dashboard.ts b/src/dashboards/hacs-dashboard.ts index a42c4bb4c..656523c01 100644 --- a/src/dashboards/hacs-dashboard.ts +++ b/src/dashboards/hacs-dashboard.ts @@ -39,7 +39,6 @@ import "../../homeassistant-frontend/src/components/ha-svg-icon"; import { PageNavigation } from "../../homeassistant-frontend/src/layouts/hass-tabs-subpage"; import { haStyle } from "../../homeassistant-frontend/src/resources/styles"; import type { HomeAssistant, Route } from "../../homeassistant-frontend/src/types"; -import { brandsUrl } from "../../homeassistant-frontend/src/util/brands-url"; import { showHacsCustomRepositoriesDialog, showHacsFormDialog, @@ -52,6 +51,7 @@ import { HacsLocalizeKeys } from "../data/localize"; import type { RepositoryBase, RepositoryType } from "../data/repository"; import { repositoriesClearNew } from "../data/websocket"; import { HacsStyles } from "../styles/hacs-common-style"; +import { brandsUrl } from "../tools/brands-url"; import { documentationUrl } from "../tools/documentation"; import { typeIcon } from "../tools/type-icon"; import { showAlertDialog } from "../../homeassistant-frontend/src/dialogs/generic/show-dialog-box"; @@ -330,7 +330,6 @@ export class HacsDashboard extends LitElement { src=${brandsUrl({ domain: repository.domain || "invalid", type: "icon", - useFallback: true, darkOptimized: this.hass.themes?.darkMode, })} referrerpolicy="no-referrer" diff --git a/src/hacs.ts b/src/hacs.ts index adbd16b6f..788b3f22e 100644 --- a/src/hacs.ts +++ b/src/hacs.ts @@ -9,6 +9,11 @@ import { computeLocalize } from "../homeassistant-frontend/src/common/translatio import { getTranslation } from "../homeassistant-frontend/src/util/common-translation"; import { fetchHacsInfo, getRepositories, websocketSubscription } from "./data/websocket"; import { HacsDispatchEvent } from "./data/common"; +import { + clearBrandsTokenRefresh, + fetchBrandsAccessToken, + scheduleBrandsTokenRefresh, +} from "./tools/brands-url"; export class HacsElement extends ProvideHassLitMixin(LitElement) { @property({ attribute: false }) public hacs: Partial = { localize: () => "" }; @@ -23,6 +28,11 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { this._initHacs(); } + public disconnectedCallback(): void { + super.disconnectedCallback(); + clearBrandsTokenRefresh(); + } + protected willUpdate(changedProperties: PropertyValues) { if (!this.hasUpdated) { this._initHacs(); @@ -32,6 +42,10 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { if (oldHass?.language !== this.hass.language) { this._language = this.hass.language; } + if (oldHass && !oldHass.connected && this.hass.connected) { + // Core hands out new brands tokens after a restart. + fetchBrandsAccessToken(this.hass, { retry: true }); + } } if (changedProperties.has("_language") || !this.hasUpdated) { @@ -74,6 +88,7 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { }); this._updateProperties(); + scheduleBrandsTokenRefresh(this.hass); this.addEventListener("update-hacs", (e) => this._updateHacs((e as any).detail as Partial) @@ -94,9 +109,12 @@ export class HacsElement extends ProvideHassLitMixin(LitElement) { const _fetch: any = {}; if (prop === "all") { + // The brands access token is resolved alongside the initial data, so the + // first render already loads integration icons through the local proxy. [_fetch.repositories, _fetch.info] = await Promise.all([ getRepositories(this.hass), fetchHacsInfo(this.hass), + fetchBrandsAccessToken(this.hass), ]); } else if (prop === "info") { _fetch.info = await fetchHacsInfo(this.hass); diff --git a/src/tools/brands-url.ts b/src/tools/brands-url.ts new file mode 100644 index 000000000..861fa8bdb --- /dev/null +++ b/src/tools/brands-url.ts @@ -0,0 +1,84 @@ +import type { HomeAssistant } from "../../homeassistant-frontend/src/types"; + +// Home Assistant 2026.3 moved brand images behind a local, token-gated proxy +// (`/api/brands/...`) that also serves the `brand/` assets custom integrations +// ship in their own folder. The pinned homeassistant-frontend predates that +// change, so this mirrors the relevant parts of its `src/util/brands-url.ts`. +// Once the submodule is bumped, this file can go and the import can point at +// the upstream module again. + +export interface BrandsOptions { + domain: string; + type: "icon" | "logo" | "icon@2x" | "logo@2x"; + darkOptimized?: boolean; +} + +// Core rotates the token every 30 minutes and keeps the previous one valid, so +// refreshing at the same cadence always leaves us with a working token. +const TOKEN_REFRESH_MS = 30 * 60 * 1000; + +// After a core restart the WebSocket can accept connections before the brands +// handler is registered. Back off a few times, like the core frontend does. +const RETRY_DELAYS_MS = [500, 1000, 2000, 5000, 10000, 15000]; + +let _brandsAccessToken: string | undefined; +let _brandsRefreshInterval: ReturnType | undefined; + +const _fetchToken = async (hass: HomeAssistant): Promise => { + try { + const { token } = await hass.connection.sendMessagePromise<{ token: string }>({ + type: "brands/access_token", + }); + _brandsAccessToken = token; + return true; + } catch { + // Core < 2026.3 has no brands proxy; the CDN stays in use. + return false; + } +}; + +export const fetchBrandsAccessToken = async ( + hass: HomeAssistant, + { retry = false }: { retry?: boolean } = {}, +): Promise => { + if ((await _fetchToken(hass)) || !retry) { + return; + } + /* eslint-disable no-await-in-loop -- retries are intentionally sequential */ + for (const delay of RETRY_DELAYS_MS) { + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + if (await _fetchToken(hass)) { + return; + } + } + /* eslint-enable no-await-in-loop */ +}; + +export const scheduleBrandsTokenRefresh = (hass: HomeAssistant): void => { + clearBrandsTokenRefresh(); + _brandsRefreshInterval = setInterval(() => { + fetchBrandsAccessToken(hass); + }, TOKEN_REFRESH_MS); +}; + +export const clearBrandsTokenRefresh = (): void => { + if (_brandsRefreshInterval) { + clearInterval(_brandsRefreshInterval); + _brandsRefreshInterval = undefined; + } +}; + +export const brandsUrl = (options: BrandsOptions): string => { + const image = `${options.darkOptimized ? "dark_" : ""}${options.type}.png`; + + if (!_brandsAccessToken) { + // No proxy available: the CDN's `_/` path falls back to a placeholder. + return `https://brands.home-assistant.io/_/${options.domain}/${image}`; + } + + const url = new URL(`/api/brands/integration/${options.domain}/${image}`, location.origin); + url.searchParams.set("token", _brandsAccessToken); + return url.toString(); +};