mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-08 17:28:46 +01:00
Add new app panel (#28214)
* Add ingress panel to Home Assistant * Better wait until app loaded logic * Cleaner slug extraction * Support HA-aware apps integrating more tightly * Add new file too * Memoize * Cursor: use clearTimeout * Cursor: fix race conditions * Claude: fixes * Cursor: fix issues * Rename hideToolbar to kioskMode * Hook kiosk mode into native kiosk mode * Update src/data/route.ts * Move computeRouteTail to common URL module * dry refactor --------- Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
@@ -205,6 +205,7 @@ const createRspackConfig = ({
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
"lit/directives/until$": "lit/directives/until.js",
|
||||
"lit/directives/ref$": "lit/directives/ref.js",
|
||||
"lit/directives/class-map$": "lit/directives/class-map.js",
|
||||
"lit/directives/style-map$": "lit/directives/style-map.js",
|
||||
"lit/directives/if-defined$": "lit/directives/if-defined.js",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Route } from "../../types";
|
||||
|
||||
export const computeRouteTail = (route: Route) => {
|
||||
const dividerPos = route.path.indexOf("/", 1);
|
||||
return dividerPos === -1
|
||||
? {
|
||||
prefix: route.prefix + route.path,
|
||||
path: "",
|
||||
}
|
||||
: {
|
||||
prefix: route.prefix + route.path.substring(0, dividerPos),
|
||||
path: route.path.substring(dividerPos),
|
||||
};
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { ReactiveElement } from "lit";
|
||||
import { property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { computeRouteTail } from "../common/url/route";
|
||||
import type { Route } from "../types";
|
||||
|
||||
const extractPage = (path: string, defaultPage: string) => {
|
||||
@@ -56,18 +57,7 @@ export class HassRouterPage extends ReactiveElement {
|
||||
|
||||
private _initialLoadDone = false;
|
||||
|
||||
private _computeTail = memoizeOne((route: Route) => {
|
||||
const dividerPos = route.path.indexOf("/", 1);
|
||||
return dividerPos === -1
|
||||
? {
|
||||
prefix: route.prefix + route.path,
|
||||
path: "",
|
||||
}
|
||||
: {
|
||||
prefix: route.prefix + route.path.substr(0, dividerPos),
|
||||
path: route.path.substr(dividerPos),
|
||||
};
|
||||
});
|
||||
private _computeTail = memoizeOne(computeRouteTail);
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { HassRouterPage } from "./hass-router-page";
|
||||
|
||||
const CACHE_URL_PATHS = ["lovelace", "developer-tools"];
|
||||
const COMPONENTS = {
|
||||
app: () => import("../panels/app/ha-panel-app"),
|
||||
energy: () => import("../panels/energy/ha-panel-energy"),
|
||||
calendar: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
config: () => import("../panels/config/ha-panel-config"),
|
||||
@@ -155,6 +156,7 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
// iFrames will lose their state when disconnected
|
||||
// Do not disconnect any iframe panel
|
||||
curPanel.component_name !== "iframe" &&
|
||||
curPanel.component_name !== "app" &&
|
||||
// Do not disconnect any custom panel that embeds into iframe (ie hassio)
|
||||
(curPanel.component_name !== "custom" ||
|
||||
!(curPanel as CustomPanelInfo).config._panel_custom.embed_iframe)
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
import { mdiMenu } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { nextRender } from "../../common/util/render-status";
|
||||
import "../../components/ha-icon-button";
|
||||
import type { HassioAddonDetails } from "../../data/hassio/addon";
|
||||
import {
|
||||
fetchHassioAddonInfo,
|
||||
startHassioAddon,
|
||||
} from "../../data/hassio/addon";
|
||||
import { extractApiErrorMessage } from "../../data/hassio/common";
|
||||
import {
|
||||
createHassioSession,
|
||||
validateHassioSession,
|
||||
} from "../../data/hassio/ingress";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../dialogs/generic/show-dialog-box";
|
||||
import "../../layouts/hass-loading-screen";
|
||||
import { computeRouteTail } from "../../common/url/route";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../../types";
|
||||
|
||||
interface AppPanelConfig {
|
||||
addon?: string;
|
||||
}
|
||||
|
||||
// Time to wait for app to start before we ask the user if we should try again
|
||||
const START_WAIT_TIME = 20000; // ms
|
||||
const RETRY_START_WAIT_TIME = 5000; // ms
|
||||
|
||||
@customElement("ha-panel-app")
|
||||
class HaPanelApp extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@property({ attribute: false }) public panel!: PanelInfo<AppPanelConfig>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _addon?: HassioAddonDetails;
|
||||
|
||||
@state() private _loadingMessage?: string;
|
||||
|
||||
@state() private _kioskMode = false;
|
||||
|
||||
private _enabledKioskMode = false;
|
||||
|
||||
private _sessionKeepAlive?: number;
|
||||
|
||||
private _fetchDataTimeout?: number;
|
||||
|
||||
private _autoRetryUntil?: number;
|
||||
|
||||
private _iframeRef = createRef<HTMLIFrameElement>();
|
||||
|
||||
/**
|
||||
* iFrames can subscribe to Home Assistant specific updates
|
||||
*/
|
||||
private _iframeSubscribeUpdates = false;
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
|
||||
// Send property updates to iframe when narrow or route changes
|
||||
if (
|
||||
this._iframeSubscribeUpdates &&
|
||||
(changedProps.has("narrow") || changedProps.has("route"))
|
||||
) {
|
||||
this._sendPropertiesToIframe();
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (oldHass && oldHass.kioskMode !== this.hass.kioskMode) {
|
||||
this._kioskMode = this.hass.kioskMode;
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this._handleIframeMessage);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this._handleIframeMessage);
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
this._sessionKeepAlive = undefined;
|
||||
}
|
||||
if (this._fetchDataTimeout) {
|
||||
clearTimeout(this._fetchDataTimeout);
|
||||
this._fetchDataTimeout = undefined;
|
||||
}
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._addon) {
|
||||
return html`<hass-loading-screen
|
||||
.message=${this._loadingMessage}
|
||||
></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
// Make sure this all is 1 template so hiding toolbar doesn't reload iframe
|
||||
return html`
|
||||
${!this._kioskMode &&
|
||||
(this.narrow || this.hass.dockedSidebar === "always_hidden")
|
||||
? html`
|
||||
<div class="header">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
|
||||
.path=${mdiMenu}
|
||||
@click=${this._toggleMenu}
|
||||
></ha-icon-button>
|
||||
<div class="main-title">${this._addon.name}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<iframe
|
||||
title=${this._addon.name}
|
||||
src=${this._addon.ingress_url!}
|
||||
@load=${this._checkLoaded}
|
||||
${ref(this._iframeRef)}
|
||||
>
|
||||
</iframe>
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!changedProps.has("route") && !changedProps.has("panel")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addon = this._getAddonSlug();
|
||||
|
||||
const oldRoute = changedProps.has("route")
|
||||
? (changedProps.get("route") as this["route"] | undefined)
|
||||
: this.route;
|
||||
const oldPanel = changedProps.has("panel")
|
||||
? (changedProps.get("panel") as this["panel"] | undefined)
|
||||
: this.panel;
|
||||
const oldAddon = this._getAddonSlugFromRoutePanel(oldRoute, oldPanel);
|
||||
|
||||
if (addon && addon !== oldAddon) {
|
||||
this._loadingMessage = undefined;
|
||||
// Reset state when switching addons
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
this._enabledKioskMode = false;
|
||||
}
|
||||
this._iframeSubscribeUpdates = false;
|
||||
this._autoRetryUntil = undefined;
|
||||
this._fetchData(addon);
|
||||
}
|
||||
}
|
||||
|
||||
private _getAddonSlug(): string | undefined {
|
||||
return this._getAddonSlugFromRoutePanel(this.route, this.panel);
|
||||
}
|
||||
|
||||
private _getAddonSlugFromRoutePanel(
|
||||
route?: Route,
|
||||
panel?: PanelInfo<AppPanelConfig>
|
||||
): string | undefined {
|
||||
// First check panel config (for dedicated add-on panels)
|
||||
if (panel?.config?.addon) {
|
||||
return panel.config.addon;
|
||||
}
|
||||
// Fall back to route path (e.g., /app/core_configurator)
|
||||
if (route?.path) {
|
||||
const dividerPos = route.path.indexOf("/", 1);
|
||||
const slug =
|
||||
dividerPos === -1
|
||||
? route.path.substring(1)
|
||||
: route.path.substring(1, dividerPos);
|
||||
if (slug) {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _showErrorAndNavigateHome(title: string, text: string) {
|
||||
await this.updateComplete;
|
||||
await showAlertDialog(this, { title, text });
|
||||
await nextRender();
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
|
||||
private async _fetchData(addonSlug: string) {
|
||||
const createSessionPromise = createHassioSession(this.hass);
|
||||
|
||||
let addon: HassioAddonDetails;
|
||||
|
||||
try {
|
||||
addon = await fetchHassioAddonInfo(this.hass, addonSlug);
|
||||
} catch (err: any) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addonSlug,
|
||||
extractApiErrorMessage(err)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addon.version) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
this.hass.localize("ui.panel.app.error_app_not_installed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addon.ingress_url) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
this.hass.localize("ui.panel.app.error_app_no_ingress")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!addon.state || !["startup", "started"].includes(addon.state)) {
|
||||
await this.updateComplete;
|
||||
const confirm = await showConfirmationDialog(this, {
|
||||
text: this.hass.localize("ui.panel.app.error_app_not_running"),
|
||||
title: addon.name,
|
||||
confirmText: this.hass.localize("ui.panel.app.start_app"),
|
||||
dismissText: this.hass.localize("ui.common.no"),
|
||||
});
|
||||
if (confirm) {
|
||||
try {
|
||||
this._loadingMessage = this.hass.localize(
|
||||
"ui.panel.app.app_starting"
|
||||
);
|
||||
// Set auto-retry window for after starting the app
|
||||
this._autoRetryUntil = Date.now() + START_WAIT_TIME;
|
||||
await startHassioAddon(this.hass, addonSlug);
|
||||
this._fetchData(addonSlug);
|
||||
return;
|
||||
} catch (_err) {
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
this.hass.localize("ui.panel.app.error_starting_app")
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await nextRender();
|
||||
navigate("/", { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (addon.state === "startup") {
|
||||
// Addon is starting up, wait for it to start
|
||||
this._loadingMessage = this.hass.localize("ui.panel.app.app_starting");
|
||||
|
||||
this._fetchDataTimeout = window.setTimeout(() => {
|
||||
this._fetchData(addonSlug);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (addon.state !== "started") {
|
||||
return;
|
||||
}
|
||||
|
||||
this._loadingMessage = undefined;
|
||||
|
||||
if (this._fetchDataTimeout) {
|
||||
clearTimeout(this._fetchDataTimeout);
|
||||
this._fetchDataTimeout = undefined;
|
||||
}
|
||||
|
||||
let session: string;
|
||||
|
||||
try {
|
||||
session = await createSessionPromise;
|
||||
} catch (_err: any) {
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
}
|
||||
await this._showErrorAndNavigateHome(
|
||||
addon.name,
|
||||
this.hass.localize("ui.panel.app.error_creating_session")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user navigated away while we were fetching
|
||||
if (this._getAddonSlug() !== addonSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
}
|
||||
this._sessionKeepAlive = window.setInterval(async () => {
|
||||
try {
|
||||
await validateHassioSession(this.hass, session);
|
||||
} catch (_err: any) {
|
||||
session = await createHassioSession(this.hass);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
this._addon = addon;
|
||||
}
|
||||
|
||||
private async _checkLoaded(ev: Event): Promise<void> {
|
||||
const iframe = ev.target as HTMLIFrameElement;
|
||||
if (
|
||||
!this._addon ||
|
||||
iframe.contentDocument?.body.textContent !== "502: Bad Gateway"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-retry if within the retry window
|
||||
if (this._autoRetryUntil && Date.now() < this._autoRetryUntil) {
|
||||
this._reloadIframe();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear auto-retry window and show dialog
|
||||
this._autoRetryUntil = undefined;
|
||||
|
||||
await this.updateComplete;
|
||||
showConfirmationDialog(this, {
|
||||
text: this.hass.localize("ui.panel.app.error_app_not_ready"),
|
||||
title: this._addon.name,
|
||||
confirmText: this.hass.localize("ui.panel.app.retry"),
|
||||
dismissText: this.hass.localize("ui.common.no"),
|
||||
confirm: () => {
|
||||
// Set auto-retry window for a bit more time.
|
||||
this._autoRetryUntil = Date.now() + RETRY_START_WAIT_TIME;
|
||||
this._reloadIframe();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _reloadIframe(): Promise<void> {
|
||||
const addonSlug = this._addon!.slug;
|
||||
this._addon = undefined;
|
||||
await Promise.all([
|
||||
this.updateComplete,
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
}),
|
||||
]);
|
||||
// Guard for user navigating away during the delay
|
||||
if (this._getAddonSlug() === addonSlug) {
|
||||
this._fetchData(addonSlug);
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleMenu(): void {
|
||||
fireEvent(this, "hass-toggle-menu");
|
||||
}
|
||||
|
||||
private _handleIframeMessage = (event: MessageEvent) => {
|
||||
if (event.source !== this._iframeRef.value?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const { type, ...data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case "home-assistant/navigate":
|
||||
navigate(data.path, data.options);
|
||||
break;
|
||||
|
||||
case "home-assistant/toggle-menu":
|
||||
this._toggleMenu();
|
||||
break;
|
||||
|
||||
case "home-assistant/subscribe-properties":
|
||||
this._iframeSubscribeUpdates = true;
|
||||
this._sendPropertiesToIframe();
|
||||
if (data.kioskMode && !this.hass.kioskMode) {
|
||||
this._enabledKioskMode = true;
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: true });
|
||||
}
|
||||
break;
|
||||
|
||||
case "home-assistant/unsubscribe-properties":
|
||||
this._iframeSubscribeUpdates = false;
|
||||
if (this._enabledKioskMode) {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: false });
|
||||
this._enabledKioskMode = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private _sendPropertiesToIframe() {
|
||||
if (!this._iframeRef.value?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._iframeRef.value.contentWindow.postMessage(
|
||||
{
|
||||
type: "home-assistant/properties",
|
||||
narrow: this.narrow,
|
||||
route: this._computeRouteTail(this.route),
|
||||
},
|
||||
"*"
|
||||
);
|
||||
}
|
||||
|
||||
private _computeRouteTail = memoizeOne(computeRouteTail);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.header + iframe {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--ha-font-size-l);
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
pointer-events: none;
|
||||
background-color: var(--app-header-background-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
color: var(--app-header-text-color, white);
|
||||
border-bottom: var(--app-header-border-bottom, none);
|
||||
box-sizing: border-box;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin: var(--margin-title);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
ha-icon-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-panel-app": HaPanelApp;
|
||||
}
|
||||
}
|
||||
@@ -2260,6 +2260,17 @@
|
||||
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
|
||||
},
|
||||
"panel": {
|
||||
"app": {
|
||||
"error_app_not_installed": "The app is not installed. Please install it first.",
|
||||
"error_app_no_ingress": "This app does not support ingress.",
|
||||
"error_app_not_running": "The app is not running. Do you want to start it now?",
|
||||
"start_app": "Start app",
|
||||
"app_starting": "The app is starting, this can take some time...",
|
||||
"error_starting_app": "Error starting the app",
|
||||
"error_creating_session": "Unable to create an ingress session",
|
||||
"error_app_not_ready": "The app seems to not be ready, it might still be starting. Do you want to try again?",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"home": {
|
||||
"editor": {
|
||||
"title": "Edit home page",
|
||||
|
||||
Reference in New Issue
Block a user