Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/dashboards/hacs-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions src/hacs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hacs> = { localize: () => "" };
Expand All @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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<Hacs>)
Expand All @@ -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);
Expand Down
84 changes: 84 additions & 0 deletions src/tools/brands-url.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setInterval> | undefined;

const _fetchToken = async (hass: HomeAssistant): Promise<boolean> => {
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<void> => {
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();
};