Compare commits

...
20 changed files with 435 additions and 161 deletions
+5
View File
@@ -0,0 +1,5 @@
export const isExternalHassUrl = (): boolean =>
Boolean(__HASS_URL__) && __HASS_URL__ !== location.origin;
export const resolveHassUrl = (path: string): string =>
isExternalHassUrl() ? new URL(path, __HASS_URL__).toString() : path;
+29 -13
View File
@@ -1,11 +1,12 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-alert";
import "./ha-app-icon";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
@@ -18,13 +19,22 @@ const SEARCH_KEYS = [
{ name: "search_labels.repository", weight: 5 },
];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
interface AddonPickerItem extends PickerComboBoxItem {
slug: string;
hasIcon: boolean;
}
const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
<span slot="supporting-text">${item.secondary}</span>
${
item.icon
? html` <img alt="" slot="start" .src=${item.icon} /> `
item.hasIcon
? html`<ha-app-icon
slot="start"
.slug=${item.slug}
.hasIcon=${item.hasIcon}
></ha-app-icon>`
: nothing
}
</ha-combo-box-item>
@@ -40,7 +50,7 @@ class HaAddonPicker extends LitElement {
@property() public helper?: string;
@state() private _addons?: PickerComboBoxItem[];
@state() private _addons?: AddonPickerItem[];
@property({ type: Boolean }) public disabled = false;
@@ -102,11 +112,10 @@ class HaAddonPicker extends LitElement {
.filter((addon) => addon.version)
.map((addon) => ({
id: addon.slug,
slug: addon.slug,
hasIcon: addon.icon,
primary: addon.name,
secondary: addon.slug,
icon: addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined,
search_labels: {
description: addon.description || null,
repository: addon.repository || null,
@@ -151,15 +160,22 @@ class HaAddonPicker extends LitElement {
private _valueRenderer = (itemId: string) => {
const item = this._addons!.find((addon) => addon.id === itemId);
return html`${
item?.icon
? html`<img
item?.hasIcon
? html`<ha-app-icon
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.icon}
/>`
.slug=${item.slug}
.hasIcon=${item.hasIcon}
.alt=${item.primary ?? "Unknown"}
></ha-app-icon>`
: nothing
}<span slot="headline">${item?.primary || "Unknown"}</span>`;
};
static styles = css`
ha-app-icon {
--ha-app-icon-size: var(--ha-space-8);
}
`;
}
declare global {
+77
View File
@@ -0,0 +1,77 @@
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { keyed } from "lit/directives/keyed";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import { isExternalHassUrl } from "../common/url/hass-url";
import { supervisorUrl } from "../data/hassio/common";
@customElement("ha-app-icon")
export class HaAppIcon extends LitElement {
@property() public slug = "";
@property({ attribute: "has-icon", type: Boolean })
public hasIcon?: boolean;
@property() public alt = "";
@property() public loading: "eager" | "lazy" = "eager";
@state() private _failedSrc?: string;
@query("img") private _image?: HTMLImageElement;
protected render() {
const src = supervisorUrl(`addons/${this.slug}/icon`);
if (!this.slug || this.hasIcon === false || this._failedSrc === src) {
return html`<slot></slot>`;
}
return keyed(
src,
html`
<img
src=${src}
alt=${this.alt}
loading=${this.loading}
crossorigin=${ifDefined(
isExternalHassUrl() ? undefined : "anonymous"
)}
referrerpolicy="no-referrer"
@error=${this._handleError}
/>
`
);
}
private _handleError(ev: HASSDomCurrentTargetEvent<HTMLImageElement>) {
if (ev.currentTarget !== this._image) {
return;
}
this._failedSrc = ev.currentTarget.getAttribute("src") ?? undefined;
}
static styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--ha-app-icon-size, var(--mdc-icon-size));
height: var(--ha-app-icon-size, var(--mdc-icon-size));
flex-shrink: 0;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-app-icon": HaAppIcon;
}
}
+10 -1
View File
@@ -51,10 +51,19 @@ export class HaComboBoxItem extends HaMdListItem {
white-space: normal;
}
::slotted(state-badge),
::slotted(img) {
::slotted(img),
::slotted(ha-app-icon) {
width: 32px;
height: 32px;
}
::slotted(ha-app-icon.colored) {
width: var(--ha-space-6);
height: var(--ha-space-6);
padding: var(--ha-space-1);
border-radius: var(--ha-border-radius-circle);
background-color: var(--app-icon-background-color);
color: var(--white-color);
}
::slotted(.code) {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-xs);
+2 -1
View File
@@ -2,6 +2,7 @@ import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant, LogFileDisabledReason } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { supervisorUrl } from "./hassio/common";
export interface LogProvider {
key: string;
@@ -18,7 +19,7 @@ export const fetchErrorLog = (hass: HomeAssistant) =>
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
hasSupervisorCoreLogDownload(hass)
? "/api/hassio/core/logs/latest"
? supervisorUrl("core/logs/latest")
: "/api/error_log";
export const getCoreLogFileDownloadUnavailableReason = (
+4
View File
@@ -1,5 +1,9 @@
import { resolveHassUrl } from "../../common/url/hass-url";
import type { CallWS } from "../../types";
export const supervisorUrl = (path: string): string =>
resolveHassUrl(`/api/hassio/${path}`);
export interface HassioResponse<T> {
data: T;
message?: string;
+10 -8
View File
@@ -1,6 +1,6 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { HassioResponse } from "./common";
import { supervisorUrl, type HassioResponse } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch;
@@ -198,18 +198,20 @@ export const fetchHassioLogsFollowSkip = async (
);
export const getHassioLogDownloadUrl = (provider: string) =>
`/api/hassio/${
provider.includes("_") ? `addons/${provider}` : provider
}/logs`;
supervisorUrl(
`${provider.includes("_") ? `addons/${provider}` : provider}/logs`
);
export const getHassioLogDownloadLinesUrl = (
provider: string,
lines: number,
boot = 0
) =>
`/api/hassio/${
provider.includes("_") ? `addons/${provider}` : provider
}/logs${boot !== 0 ? `/boots/${boot}` : ""}?lines=${lines}`;
supervisorUrl(
`${
provider.includes("_") ? `addons/${provider}` : provider
}/logs${boot !== 0 ? `/boots/${boot}` : ""}?lines=${lines}`
);
export const setSupervisorOption = async (
hass: HomeAssistant,
@@ -223,4 +225,4 @@ export const setSupervisorOption = async (
});
};
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
export const coreLatestLogsUrl = supervisorUrl("core/logs/latest");
+7 -10
View File
@@ -28,6 +28,7 @@ export interface NavigationComboBoxItem extends PickerComboBoxItem {
path: string;
image?: string;
iconColor?: string;
app?: Pick<HassioAddonInfo, "slug" | "icon">;
}
export interface BaseNavigationCommand {
@@ -38,6 +39,7 @@ export interface BaseNavigationCommand {
iconPath?: string;
iconColor?: string;
image?: string;
app?: Pick<HassioAddonInfo, "slug" | "icon">;
}
export interface ActionCommandComboBoxItem extends PickerComboBoxItem {
@@ -62,19 +64,14 @@ const generateNavigationPanelCommands = (
const primary = localize(translationKey) || panel.title || panel.url_path;
let image: string | undefined;
if (apps) {
const app = apps.find(({ slug }) => slug === panel.url_path);
if (app) {
image = app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined;
}
}
const panelApp = apps?.find(({ slug }) => slug === panel.url_path);
return {
primary,
icon,
image,
app: panelApp
? { slug: panelApp.slug, icon: panelApp.icon }
: undefined,
path: `/${panel.url_path}`,
};
});
@@ -171,7 +168,7 @@ export const generateNavigationCommands = (
for (const app of apps.filter((a) => a.version)) {
appItems.push({
path: `/config/app/${app.slug}`,
image: app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined,
app: { slug: app.slug, icon: app.icon },
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.app_info",
{ app: app.name }
+53 -30
View File
@@ -12,6 +12,7 @@ import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import "../../components/entity/state-badge";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-app-icon";
import "../../components/ha-combo-box-item";
import "../../components/ha-domain-icon";
import "../../components/ha-icon";
@@ -310,6 +311,7 @@ export class QuickBar extends LitElement {
}
const iconPath = item.icon_path || mdiDevices;
const iconColor = "iconColor" in item ? item.iconColor : undefined;
return html`
<ha-combo-box-item
@@ -334,44 +336,65 @@ export class QuickBar extends LitElement {
brand-fallback
></ha-domain-icon>
`
: "image" in item && item.image
: "app" in item && item.app
? html`
<img
<ha-app-icon
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.image}
.slug=${item.app.slug}
.hasIcon=${item.app.icon}
.alt=${item.primary ?? "Unknown"}
class=${iconColor ? "colored" : nothing}
style=${
"iconColor" in item && item.iconColor
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
: ""
iconColor
? `--app-icon-background-color: ${iconColor}`
: nothing
}
/>
>
${
item.icon
? html`<ha-icon .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon .path=${iconPath}></ha-svg-icon>`
}
</ha-app-icon>
`
: item.icon
? html`<ha-icon
style="margin: var(--ha-space-1);"
slot="start"
.icon=${item.icon}
></ha-icon>`
: "iconColor" in item && item.iconColor
? html`
<div
slot="start"
style=${`padding: 4px; border-radius: var(--ha-border-radius-circle); background-color: ${item.iconColor};`}
>
: "image" in item && item.image
? html`
<img
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.image}
style=${
"iconColor" in item && item.iconColor
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
: ""
}
/>
`
: item.icon
? html`<ha-icon
style="margin: var(--ha-space-1);"
slot="start"
.icon=${item.icon}
></ha-icon>`
: "iconColor" in item && item.iconColor
? html`
<div
slot="start"
style=${`padding: 4px; border-radius: var(--ha-border-radius-circle); background-color: ${item.iconColor};`}
>
<ha-svg-icon
style="color: var(--white-color); --mdc-icon-size: 24px;"
.path=${iconPath}
></ha-svg-icon>
</div>
`
: html`
<ha-svg-icon
style="color: var(--white-color); --mdc-icon-size: 24px;"
style="margin: var(--ha-space-1);"
slot="start"
.path=${iconPath}
></ha-svg-icon>
</div>
`
: html`
<ha-svg-icon
style="margin: var(--ha-space-1);"
slot="start"
.path=${iconPath}
></ha-svg-icon>
`
`
}
<span slot="headline">${item.primary}</span>
${
@@ -86,6 +86,7 @@ import type { HassioStats } from "../../../../../data/hassio/common";
import {
extractApiErrorMessage,
fetchHassioStats,
supervisorUrl,
} from "../../../../../data/hassio/common";
import type { StoreAddonDetails } from "../../../../../data/supervisor/store";
import {
@@ -205,7 +206,9 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
src=${supervisorUrl(
`addons/${this._currentAddon.slug}/logo`
)}
/>
`
: nothing
@@ -3,6 +3,7 @@ import { mdiCheckCircle, mdiHelpCircleOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-app-icon";
import "../../../../components/ha-svg-icon";
import type { AddonStage, AddonState } from "../../../../data/hassio/addon";
import type { HomeAssistant } from "../../../../types";
@@ -41,21 +42,28 @@ class SupervisorAppsCardContent extends LitElement {
@property() public icon = mdiHelpCircleOutline;
@property({ attribute: false }) public iconImage?: string;
@property({ attribute: false }) public appSlug?: string;
@property({ attribute: false }) public hasAppIcon?: boolean;
protected render(): TemplateResult {
return html`
<div class="app">
<div class="icon-wrapper">
${
this.iconImage
this.appSlug
? html`
<img
class="icon-image"
src=${this.iconImage}
.title=${this.iconTitle}
alt=${this.iconTitle ?? ""}
/>
<ha-app-icon
.slug=${this.appSlug}
.hasIcon=${this.hasAppIcon}
.alt=${this.iconTitle ?? ""}
.title=${this.iconTitle ?? ""}
>
<ha-svg-icon
class="app-icon"
.path=${this.icon}
></ha-svg-icon>
</ha-app-icon>
`
: html`
<ha-svg-icon
@@ -134,15 +142,15 @@ class SupervisorAppsCardContent extends LitElement {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.app-icon {
margin-left: var(--ha-space-2);
margin-top: var(--ha-space-2);
color: var(--secondary-text-color);
}
.icon-image {
max-height: 40px;
max-width: 40px;
ha-app-icon {
--ha-app-icon-size: 40px;
}
.title {
flex: 1;
@@ -151,11 +151,8 @@ export class HaConfigAppsInstalled extends LitElement {
"ui.panel.config.apps.installed.app_running"
)
}
.iconImage=${
addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined
}
.appSlug=${addon.slug}
.hasAppIcon=${addon.icon}
></supervisor-apps-card-content>
</div>
</ha-card>
@@ -116,11 +116,8 @@ export class SupervisorAppsRepositoryEl extends LitElement {
? "not_available"
: ""
}
.iconImage=${
addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined
}
.appSlug=${addon.slug}
.hasAppIcon=${addon.icon}
.showTopbar=${addon.installed || !addon.available}
.topbarClass=${
addon.installed
@@ -4,8 +4,10 @@ import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-app-icon";
import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import "./ha-backup-formfield-label";
@@ -50,13 +52,17 @@ export class HaBackupAddonsPicker extends LitElement {
<ha-backup-formfield-label
.label=${item.name}
.version=${this.hideVersion ? undefined : item.version}
.iconPath=${item.iconPath || mdiPuzzle}
.imageUrl=${
this.addons?.find((a) => a.slug === item.slug)?.icon
? `/api/hassio/addons/${item.slug}/icon`
: undefined
}
>
<ha-app-icon
slot="icon"
.slug=${item.slug}
.hasIcon=${item.icon}
loading="lazy"
>
<ha-svg-icon
.path=${item.iconPath || mdiPuzzle}
></ha-svg-icon>
</ha-app-icon>
</ha-backup-formfield-label>
</ha-checkbox>
`
@@ -86,6 +92,10 @@ export class HaBackupAddonsPicker extends LitElement {
padding-inline-start: var(--ha-space-2);
padding-bottom: var(--ha-space-3);
}
ha-app-icon {
--ha-app-icon-size: var(--ha-space-6);
}
`;
}
@@ -15,20 +15,25 @@ class SupervisorFormfieldLabel extends LitElement {
protected render(): TemplateResult {
return html`
${
this.imageUrl
? html`<img
loading="lazy"
alt=""
src=${this.imageUrl}
class="icon"
/>`
: this.iconPath
? html`
<ha-svg-icon .path=${this.iconPath} class="icon"></ha-svg-icon>
`
: nothing
}
<slot name="icon">
${
this.imageUrl
? html`<img
loading="lazy"
alt=""
src=${this.imageUrl}
class="icon"
/>`
: this.iconPath
? html`
<ha-svg-icon
.path=${this.iconPath}
class="icon"
></ha-svg-icon>
`
: nothing
}
</slot>
<span class="label">
${this.label}
${
@@ -16,6 +16,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-app-icon";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
@@ -194,16 +195,6 @@ export class SerialConfigDashboard extends LitElement {
});
}
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
return html`<img
slot="start"
.src=${src}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${alt}
/>`;
}
private _renderConsumer(consumer: SerialPortConsumer): TemplateResult {
const href =
consumer.kind === "config_entry"
@@ -214,22 +205,28 @@ export class SerialConfigDashboard extends LitElement {
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? this._renderConsumerIcon(
brandsUrl(
? html`<img
slot="start"
.src=${brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
),
consumer.domain!
)
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
: consumer.kind === "app"
? this._renderConsumerIcon(
`/api/hassio/addons/${consumer.slug}/icon`,
consumer.slug!
)
? html`<ha-app-icon
slot="start"
.slug=${consumer.slug!}
.alt=${consumer.title || consumer.slug!}
>
<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>
</ha-app-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
@@ -634,7 +631,8 @@ export class SerialConfigDashboard extends LitElement {
--md-list-item-leading-space: var(--ha-space-14);
}
ha-md-list-item.consumer img[slot="start"] {
ha-md-list-item.consumer img[slot="start"],
ha-md-list-item.consumer ha-app-icon[slot="start"] {
width: 24px;
height: 24px;
}
+39 -33
View File
@@ -16,6 +16,7 @@ import { atLeastVersion } from "../../../common/config/version";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-app-icon";
import "../../../components/ha-button";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
@@ -23,7 +24,10 @@ import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import type { LogProvider } from "../../../data/error_log";
import { fetchHassioAddonsInfo } from "../../../data/hassio/addon";
import {
fetchHassioAddonsInfo,
type HassioAddonInfo,
} from "../../../data/hassio/addon";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
@@ -60,6 +64,11 @@ const logProviders: LogProvider[] = [
},
];
interface LogProviderPickerItem extends PickerComboBoxItem {
addon?: HassioAddonInfo;
hasAppIcon?: boolean;
}
@customElement("ha-config-logs")
export class HaConfigLogs extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -137,18 +146,9 @@ export class HaConfigLogs extends LitElement {
@click=${this._openPicker}
>
${
selectedProvider?.icon
? html`<img
src=${selectedProvider.icon}
alt=${selectedProvider.primary}
slot="start"
/>`
: selectedProvider?.icon_path
? html`<ha-svg-icon
slot="start"
.path=${selectedProvider.icon_path}
></ha-svg-icon>`
: nothing
selectedProvider
? this._renderProviderIcon(selectedProvider)
: nothing
}
${selectedProvider?.primary}
<ha-svg-icon
@@ -263,33 +263,40 @@ export class HaConfigLogs extends LitElement {
}
}
private _getLogProviderItems = (): PickerComboBoxItem[] =>
private _getLogProviderItems = (): LogProviderPickerItem[] =>
this._logProviders.map((provider) => ({
id: provider.key,
primary: provider.name,
icon: provider.addon
addon: provider.addon,
hasAppIcon: provider.addon
? atLeastVersion(this.hass.config.version, 0, 105) &&
provider.addon.icon
? `/api/hassio/addons/${provider.addon.slug}/icon`
: undefined
: undefined,
icon_path: provider.addon
? mdiPuzzle
: this._getProviderIconPath(provider.key),
}));
private _providerRenderer = (item: PickerComboBoxItem) => html`
private _renderProviderIcon(item: LogProviderPickerItem) {
if (item.addon) {
return html`<ha-app-icon
slot="start"
.alt=${item.primary}
.hasIcon=${item.hasAppIcon}
.slug=${item.addon.slug}
>
<ha-svg-icon .path=${item.icon_path}></ha-svg-icon>
</ha-app-icon>`;
}
return item.icon_path
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
: nothing;
}
private _providerRenderer = (item: LogProviderPickerItem) => html`
<ha-combo-box-item type="button" compact>
${
item.icon
? html`<img src=${item.icon} alt=${item.primary} slot="start" />`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing
}
${this._renderProviderIcon(item)}
<span slot="headline">${item.primary}</span>
${
item.secondary
@@ -307,11 +314,10 @@ export class HaConfigLogs extends LitElement {
return {
id: provider.key,
primary: provider.name,
icon: provider.addon
addon: provider.addon,
hasAppIcon: provider.addon
? atLeastVersion(this.hass.config.version, 0, 105) &&
provider.addon.icon
? `/api/hassio/addons/${provider.addon.slug}/icon`
: undefined
: undefined,
icon_path: provider.addon
? mdiPuzzle
@@ -364,8 +370,8 @@ export class HaConfigLogs extends LitElement {
--mdc-icon-size: var(--ha-space-6);
}
img {
height: 32px;
ha-app-icon {
--ha-app-icon-size: 32px;
}
@media all and (max-width: 870px) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveHassUrl } from "../common/url/hass-url";
import { isExternalAndroid } from "../data/external";
// 10 seconds gives the Android WebView download listener enough time
@@ -9,7 +10,7 @@ const BLOB_REVOKE_DELAY_MS = 10_000;
export const fileDownload = (href: string, filename = ""): void => {
const element = document.createElement("a");
element.target = "_blank";
element.href = href;
element.href = resolveHassUrl(href);
element.download = filename;
element.style.display = "none";
document.body.appendChild(element);
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaAppIcon } from "../../src/components/ha-app-icon";
import "../../src/components/ha-app-icon";
let appIcon: HaAppIcon | undefined;
const mountAppIcon = async (properties: Partial<HaAppIcon> = {}) => {
appIcon = document.createElement("ha-app-icon");
Object.assign(appIcon, properties);
appIcon.append(document.createElement("ha-svg-icon"));
document.body.append(appIcon);
await appIcon.updateComplete;
return appIcon;
};
afterEach(() => {
appIcon?.remove();
appIcon = undefined;
vi.unstubAllGlobals();
});
describe("ha-app-icon", () => {
it("does not request an unavailable icon", async () => {
const element = await mountAppIcon({ slug: "example", hasIcon: false });
expect(element.shadowRoot!.querySelector("img")).toBeNull();
expect(element.shadowRoot!.querySelector("slot")).not.toBeNull();
});
it.each([true, undefined])(
"requests the icon when availability is %s",
async (hasIcon) => {
const element = await mountAppIcon({ slug: "example", hasIcon });
expect(
element.shadowRoot!.querySelector("img")!.getAttribute("src")
).toBe("/api/hassio/addons/example/icon");
}
);
it("requests the icon from the configured Home Assistant URL", async () => {
vi.stubGlobal("__HASS_URL__", "http://homeassistant.local:8123");
const element = await mountAppIcon({ slug: "example", hasIcon: true });
const image = element.shadowRoot!.querySelector("img")!;
expect(image.getAttribute("src")).toBe(
"http://homeassistant.local:8123/api/hassio/addons/example/icon"
);
expect(image.hasAttribute("crossorigin")).toBe(false);
});
it("renders the fallback after an image error", async () => {
const element = await mountAppIcon({ slug: "example", hasIcon: true });
element.shadowRoot!.querySelector("img")!.dispatchEvent(new Event("error"));
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")).toBeNull();
expect(element.shadowRoot!.querySelector("slot")).not.toBeNull();
});
it("tries a new source after the slug changes", async () => {
const element = await mountAppIcon({ slug: "first", hasIcon: true });
element.shadowRoot!.querySelector("img")!.dispatchEvent(new Event("error"));
await element.updateComplete;
element.slug = "second";
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
"/api/hassio/addons/second/icon"
);
});
it("ignores an error from a previous source", async () => {
const element = await mountAppIcon({ slug: "first", hasIcon: true });
const firstImage = element.shadowRoot!.querySelector("img")!;
element.slug = "second";
await element.updateComplete;
firstImage.dispatchEvent(new Event("error"));
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
"/api/hassio/addons/second/icon"
);
});
it("ignores an old error after returning to the same source", async () => {
const element = await mountAppIcon({ slug: "first", hasIcon: true });
const firstImage = element.shadowRoot!.querySelector("img")!;
element.slug = "second";
await element.updateComplete;
element.slug = "first";
await element.updateComplete;
firstImage.dispatchEvent(new Event("error"));
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
"/api/hassio/addons/first/icon"
);
});
});
+11
View File
@@ -36,6 +36,7 @@ describe("fileDownload", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (window as any).externalApp;
delete (window as any).externalAppV2;
});
@@ -58,6 +59,16 @@ describe("fileDownload", () => {
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
});
it("resolves relative URLs against the configured Home Assistant URL", async () => {
vi.stubGlobal("__HASS_URL__", "http://homeassistant.local:8123");
await loadFileDownload();
fileDownload("/api/hassio/core/logs/follow");
expect(createdElement.href).toBe(
"http://homeassistant.local:8123/api/hassio/core/logs/follow"
);
});
it("revokes blob URLs immediately outside Android", async () => {
await loadFileDownload();
fileDownload("blob:http://localhost/abc-123", "file.json");