770 lines
28 KiB
Python
770 lines
28 KiB
Python
"""Admin-only "Open Web UI" access to the in-process server's settings page (#1527).
|
|
|
|
The in-process ha-mcp server serves its web settings UI on the loopback interface
|
|
at ``http://127.0.0.1:<port><secret_path>/settings`` — unreachable from a browser
|
|
and guarded only by the secret path. This module gives every install type the
|
|
add-on's "Open Web UI" experience: an admin-only sidebar panel ("HA-MCP") that
|
|
opens that settings UI through Home Assistant's own HTTP server, so it works over
|
|
the Nabu Casa remote URL and never exposes the loopback secret path to the browser.
|
|
|
|
The sidebar entry is a built-in ``iframe`` panel — the panel type behind
|
|
"webpage" dashboards — NOT a custom panel. Home Assistant itself renders the
|
|
standard header (with the sidebar menu button) around our page, so navigation
|
|
behaves exactly like every other dashboard. The previous custom panel painted a
|
|
bare full-height iframe with no chrome, which left iOS companion-app users with
|
|
no way back to the HA UI: iOS has no system back button, edge swipes land inside
|
|
the iframe where the frontend cannot see them, and the app restores the trapped
|
|
route on every relaunch (#1795).
|
|
|
|
Auth model — an iframe panel navigation is a browser GET that carries no
|
|
``Authorization`` header, so Home Assistant's normal ``requires_auth`` cannot gate
|
|
it. HA's signed-path helper (:func:`homeassistant.components.http.async_sign_path`)
|
|
is also unusable: a signature binds ONE exact path + query string, but the settings
|
|
app issues relative ``./api/settings/*`` fetches that drop the query — each would
|
|
land on a different, unsigned path and 401. Instead:
|
|
|
|
1. The panel's iframe loads :class:`_BootView`, a tiny same-origin bootstrap
|
|
page (public glue, no secrets). Being same-origin with the authenticated
|
|
frontend, it reads the logged-in user's access token from the parent frame's
|
|
``home-assistant`` root element and POSTs it to the session endpoint below.
|
|
2. :class:`_SessionView` (``requires_auth=True``) authenticates that token the
|
|
normal way, refuses non-admins, and returns a short-lived HttpOnly,
|
|
SameSite=Strict session cookie scoped to the proxy path.
|
|
3. The boot page then embeds ``…/ui/app/settings`` in an inner iframe. The
|
|
browser attaches the cookie to every same-origin request under the proxy
|
|
path — including the settings app's relative sub-fetches — so the whole app
|
|
works unchanged.
|
|
4. :class:`_ProxyView` (``requires_auth=False`` because the iframe cannot send a
|
|
bearer) validates that cookie against a live admin user on every request and
|
|
forwards to the loopback settings server.
|
|
|
|
The proxy reuses the server's loopback forwarding config + aiohttp session
|
|
(``hass.data[DOMAIN][DATA_WEBHOOK]`` — stored whenever the server is running,
|
|
even when the public webhook endpoint is disabled), so it is available exactly
|
|
while the server is running and returns 503 otherwise.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import aiohttp
|
|
from aiohttp import web
|
|
from homeassistant.components.http import HomeAssistantView
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from .const import DATA_WEBHOOK, DOMAIN
|
|
|
|
if TYPE_CHECKING:
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# Sidebar panel identity. The url_path is the frontend route (…/ha-mcp).
|
|
# "HA-MCP" (not "MCP Server") avoids confusion with HA's official MCP Server
|
|
# integration.
|
|
PANEL_URL_PATH = "ha-mcp"
|
|
PANEL_TITLE = "HA-MCP"
|
|
PANEL_ICON = "mdi:robot-happy-outline"
|
|
|
|
# HTTP surface, all under one base so the session cookie can be tightly scoped.
|
|
_UI_BASE = "/api/ha_mcp_tools/ui"
|
|
_BOOT_URL = f"{_UI_BASE}/boot"
|
|
_SESSION_URL = f"{_UI_BASE}/session"
|
|
_APP_PREFIX = f"{_UI_BASE}/app/"
|
|
_PROXY_URL = f"{_UI_BASE}/app/{{path:.*}}"
|
|
|
|
# Session cookie. HttpOnly so page JS can never read it; SameSite=Strict so it
|
|
# rides only same-site requests (the iframe is same-origin with the frontend);
|
|
# path-scoped to the proxy so it is never sent to the boot/session endpoints.
|
|
_COOKIE_NAME = "ha_mcp_tools_ui_session"
|
|
_COOKIE_PATH = f"{_UI_BASE}/app"
|
|
# Keep in sync with ``ha_mcp.settings_ui._i18n.LOCALE_COOKIE`` without
|
|
# importing the separately installed server package into the HA component.
|
|
_LOCALE_COOKIE_NAME = "ha_mcp_locale"
|
|
|
|
|
|
def _is_valid_locale_cookie_value(value: str) -> bool:
|
|
"""True for BCP-47-like values: ASCII-alphanumeric runs joined by single
|
|
``-``/``_`` separators (no leading/trailing/doubled separators).
|
|
|
|
A plain character walk instead of a regex: linear by construction, where
|
|
CodeQL's backtracking model flagged every regex shape for this language
|
|
as potentially polynomial.
|
|
"""
|
|
prev_is_sep = True # a separator may not open the value
|
|
for ch in value:
|
|
if ch in "-_":
|
|
if prev_is_sep:
|
|
return False
|
|
prev_is_sep = True
|
|
elif ch.isascii() and ch.isalnum():
|
|
prev_is_sep = False
|
|
else:
|
|
return False
|
|
return bool(value) and not prev_is_sep
|
|
|
|
|
|
# Session lifetime. Short by design; the panel re-mints well within it while open.
|
|
_SESSION_TTL_SECONDS = 8 * 60 * 60
|
|
|
|
# Top-level hass.data keys. Both must survive config-entry teardown: aiohttp
|
|
# cannot unregister a bound view, so the views (and the sessions they validate)
|
|
# outlive a reload / re-enable of the entry.
|
|
_VIEWS_REGISTERED_KEY = "ha_mcp_tools_ui_views_registered"
|
|
_SESSIONS_KEY = "ha_mcp_tools_ui_sessions"
|
|
|
|
# Request headers never forwarded to the loopback server. Hop-by-hop plus the
|
|
# browser's cookie/authorization (the loopback server has no auth on the secret
|
|
# path and must not receive the session cookie or the frontend bearer). The
|
|
# locale cookie is reconstructed separately from the parsed cookie jar so no
|
|
# other browser cookie can cross this trust boundary.
|
|
_STRIPPED_REQUEST_HEADERS = frozenset(
|
|
{
|
|
"host",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"connection",
|
|
"cookie",
|
|
"authorization",
|
|
}
|
|
)
|
|
|
|
# Response headers recomputed by aiohttp on the way out, or invalid once the body
|
|
# has been transparently decompressed by ``resp.read()``. Everything else
|
|
# (Content-Type, Cache-Control, …) passes through so the settings app behaves
|
|
# exactly as when reached directly.
|
|
_STRIPPED_RESPONSE_HEADERS = frozenset(
|
|
{
|
|
"transfer-encoding",
|
|
"connection",
|
|
"content-length",
|
|
"content-encoding",
|
|
"keep-alive",
|
|
}
|
|
)
|
|
|
|
|
|
def _forwarded_locale_cookie(request: web.Request) -> str | None:
|
|
"""Return the single safe locale cookie header allowed upstream.
|
|
|
|
The settings app stores a manual language override in ``ha_mcp_locale``.
|
|
Forwarding the browser's raw Cookie header would also expose Home
|
|
Assistant's authenticated session cookie to the unauthenticated loopback
|
|
server, so rebuild a header containing only a short BCP-47-like value.
|
|
"""
|
|
value = request.cookies.get(_LOCALE_COOKIE_NAME)
|
|
if (
|
|
not isinstance(value, str)
|
|
or len(value) > 64
|
|
or not _is_valid_locale_cookie_value(value)
|
|
):
|
|
return None
|
|
return f"{_LOCALE_COOKIE_NAME}={value}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session store (server-side; no secret ever placed in a URL)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _sessions(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
|
|
"""Return the token → ``{user_id, expires}`` store, creating it once."""
|
|
store = hass.data.get(_SESSIONS_KEY)
|
|
if not isinstance(store, dict):
|
|
store = {}
|
|
hass.data[_SESSIONS_KEY] = store
|
|
return store
|
|
|
|
|
|
def _prune_expired(store: dict[str, dict[str, Any]], now: float) -> None:
|
|
"""Drop expired sessions so the store cannot grow without bound."""
|
|
for token in [t for t, s in store.items() if s["expires"] <= now]:
|
|
del store[token]
|
|
|
|
|
|
def _mint_session(hass: HomeAssistant, user_id: str) -> str:
|
|
"""Create and store a new session token for ``user_id``; return the token."""
|
|
store = _sessions(hass)
|
|
now = time.monotonic()
|
|
_prune_expired(store, now)
|
|
token = secrets.token_urlsafe(32)
|
|
store[token] = {"user_id": user_id, "expires": now + _SESSION_TTL_SECONDS}
|
|
return token
|
|
|
|
|
|
async def _session_user_is_admin(hass: HomeAssistant, token: str | None) -> bool:
|
|
"""Return True iff ``token`` maps to a live, still-admin user session.
|
|
|
|
Re-checks the user's admin flag on every request so revoking admin (or the
|
|
user) takes effect immediately, not only when the session expires. A stale or
|
|
demoted session is dropped so it cannot be retried.
|
|
"""
|
|
if not token:
|
|
return False
|
|
store = _sessions(hass)
|
|
now = time.monotonic()
|
|
_prune_expired(store, now)
|
|
session = store.get(token)
|
|
if session is None:
|
|
return False
|
|
user = await hass.auth.async_get_user(session["user_id"])
|
|
if (
|
|
user is None
|
|
or getattr(user, "system_generated", False)
|
|
or not getattr(user, "is_active", False)
|
|
or not getattr(user, "is_admin", False)
|
|
):
|
|
# Same acceptance bar as the ha_auth webhook gate (review finding:
|
|
# the two admin gates must not drift): active, human, administrator.
|
|
store.pop(token, None)
|
|
return False
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Views
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _BootView(HomeAssistantView):
|
|
"""Serve the bootstrap page the iframe panel embeds (public glue, no secrets).
|
|
|
|
``requires_auth`` is False because the panel's iframe loads this with a plain
|
|
GET that cannot attach a bearer. The page contains only the bootstrap that
|
|
mints a session (with the token it reads from the parent frontend frame) and
|
|
embeds the proxied settings app.
|
|
"""
|
|
|
|
requires_auth = False
|
|
cors_allowed = False
|
|
url = _BOOT_URL
|
|
name = "ha_mcp_tools:ui:boot"
|
|
|
|
async def get(self, request: web.Request) -> web.Response:
|
|
"""Return the bootstrap HTML page."""
|
|
return web.Response(
|
|
body=_BOOT_HTML.encode("utf-8"),
|
|
content_type="text/html",
|
|
charset="utf-8",
|
|
headers={"Cache-Control": "no-cache"},
|
|
)
|
|
|
|
|
|
class _SessionView(HomeAssistantView):
|
|
"""Mint a short-lived session cookie for an authenticated admin user.
|
|
|
|
``requires_auth`` is True, so Home Assistant validates the frontend's bearer
|
|
before this runs. The extra admin check refuses non-admins (the panel is
|
|
admin-only, and the settings UI can change privileged server settings).
|
|
"""
|
|
|
|
requires_auth = True
|
|
cors_allowed = False
|
|
url = _SESSION_URL
|
|
name = "ha_mcp_tools:ui:session"
|
|
|
|
async def post(self, request: web.Request) -> web.Response:
|
|
"""Issue the session cookie, or 403 for a non-admin caller."""
|
|
user = request.get("hass_user")
|
|
if user is None or not getattr(user, "is_admin", False):
|
|
return web.json_response({"error": "admin_required"}, status=403)
|
|
|
|
token = _mint_session(request.app["hass"], user.id)
|
|
response = web.json_response({"ttl": _SESSION_TTL_SECONDS})
|
|
response.set_cookie(
|
|
_COOKIE_NAME,
|
|
token,
|
|
max_age=_SESSION_TTL_SECONDS,
|
|
path=_COOKIE_PATH,
|
|
httponly=True,
|
|
samesite="Strict",
|
|
secure=_request_is_https(request),
|
|
)
|
|
return response
|
|
|
|
|
|
class _ProxyView(HomeAssistantView):
|
|
"""Forward settings-UI traffic to the loopback server for a valid session.
|
|
|
|
``requires_auth`` is False because the iframe (and its relative sub-fetches)
|
|
cannot send a bearer; the session cookie minted by :class:`_SessionView` is
|
|
the credential and is re-validated against a live admin user on every request.
|
|
Returns 503 while the server is not running and 401 without a valid session.
|
|
"""
|
|
|
|
requires_auth = False
|
|
cors_allowed = False
|
|
url = _PROXY_URL
|
|
name = "ha_mcp_tools:ui:proxy"
|
|
|
|
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
|
|
"""Proxy a GET (the settings page and read endpoints)."""
|
|
return await self._forward(request, path)
|
|
|
|
async def post(self, request: web.Request, path: str) -> web.StreamResponse:
|
|
"""Proxy a POST (save endpoints)."""
|
|
return await self._forward(request, path)
|
|
|
|
async def put(self, request: web.Request, path: str) -> web.StreamResponse:
|
|
"""Proxy a PUT (policy-config writes)."""
|
|
return await self._forward(request, path)
|
|
|
|
async def delete(self, request: web.Request, path: str) -> web.StreamResponse:
|
|
"""Proxy a DELETE (backup deletion)."""
|
|
return await self._forward(request, path)
|
|
|
|
async def _forward(self, request: web.Request, path: str) -> web.StreamResponse:
|
|
"""Validate the session, then forward to the loopback settings server."""
|
|
hass: HomeAssistant = request.app["hass"]
|
|
|
|
if not await _session_user_is_admin(hass, request.cookies.get(_COOKIE_NAME)):
|
|
return web.Response(status=401, text="Unauthorized")
|
|
|
|
# Defense in depth: never let a crafted path escape the secret-path
|
|
# prefix on the loopback server (the caller is already an admin, so this
|
|
# only blocks confusing requests, but it keeps the target well-formed).
|
|
if any(segment == ".." for segment in path.split("/")):
|
|
return web.Response(status=400, text="Bad request")
|
|
|
|
cfg = _webhook_cfg(hass)
|
|
if cfg is None:
|
|
return web.Response(status=503, text="The MCP server is not running")
|
|
|
|
target = f"{cfg['target_url']}/{path}"
|
|
if request.query_string:
|
|
target = f"{target}?{request.query_string}"
|
|
session: aiohttp.ClientSession = cfg["session"]
|
|
|
|
body = await request.read()
|
|
forward_headers = {
|
|
key: value
|
|
for key, value in request.headers.items()
|
|
if key.lower() not in _STRIPPED_REQUEST_HEADERS
|
|
}
|
|
locale_cookie = _forwarded_locale_cookie(request)
|
|
if locale_cookie is not None:
|
|
forward_headers["Cookie"] = locale_cookie
|
|
|
|
try:
|
|
async with session.request(
|
|
method=request.method,
|
|
url=target,
|
|
headers=forward_headers,
|
|
data=body if body else None,
|
|
) as upstream:
|
|
return await _relay_response(request, upstream)
|
|
except aiohttp.ClientError as err:
|
|
_LOGGER.error("HA-MCP settings proxy: upstream request failed: %s", err)
|
|
return web.Response(status=502, text="MCP settings server unavailable")
|
|
except Exception as err:
|
|
_LOGGER.exception("HA-MCP settings proxy: unexpected error: %s", err)
|
|
return web.Response(status=500, text="MCP settings server error")
|
|
|
|
|
|
async def _relay_response(
|
|
request: web.Request, upstream: aiohttp.ClientResponse
|
|
) -> web.StreamResponse:
|
|
"""Relay the loopback response, streaming when it is an event stream.
|
|
|
|
The loopback server is our own trusted process, so — unlike the MCP webhook —
|
|
the Content-Type is passed through unchanged (the settings page is text/html,
|
|
the API endpoints are JSON): coercing it would break the page.
|
|
"""
|
|
content_type = upstream.headers.get("Content-Type", "")
|
|
headers = {
|
|
key: value
|
|
for key, value in upstream.headers.items()
|
|
if key.lower() not in _STRIPPED_RESPONSE_HEADERS
|
|
}
|
|
|
|
if "text/event-stream" in content_type:
|
|
headers["Cache-Control"] = "no-cache, no-transform"
|
|
headers["X-Accel-Buffering"] = "no"
|
|
response = web.StreamResponse(status=upstream.status, headers=headers)
|
|
await response.prepare(request)
|
|
try:
|
|
async for chunk in upstream.content.iter_any():
|
|
await response.write(chunk)
|
|
except aiohttp.ClientError as err:
|
|
_LOGGER.error("HA-MCP settings proxy: upstream dropped mid-stream: %s", err)
|
|
with _suppress_connection_reset():
|
|
await response.write_eof()
|
|
return response
|
|
|
|
return web.Response(
|
|
status=upstream.status, body=await upstream.read(), headers=headers
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registration / teardown
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def async_register_ui_panel(hass: HomeAssistant) -> None:
|
|
"""Register the settings-UI proxy views (once) and the sidebar panel.
|
|
|
|
Called from the server entry's setup. The views resolve the running server
|
|
from ``hass.data`` per request, so they are bound once per HA session and
|
|
reused across reloads; the panel is (re)added here and removed on unload.
|
|
Any failure is logged and swallowed — a frontend hiccup must never block the
|
|
config entry from loading.
|
|
"""
|
|
try:
|
|
_register_views(hass)
|
|
await _register_panel(hass)
|
|
except Exception:
|
|
_LOGGER.exception("HA-MCP: failed to register the settings-UI panel")
|
|
|
|
|
|
def async_unregister_ui_panel(hass: HomeAssistant) -> None:
|
|
"""Remove the sidebar panel on entry unload (the views stay bound).
|
|
|
|
aiohttp cannot unregister the views; they return 503 once the server is no
|
|
longer running, so removing the sidebar entry is enough to reflect the
|
|
paused/removed state.
|
|
"""
|
|
from homeassistant.components.frontend import async_remove_panel
|
|
|
|
with _suppress_all():
|
|
async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False)
|
|
|
|
|
|
def _register_views(hass: HomeAssistant) -> None:
|
|
"""Bind the boot / session / proxy views at most once per HA session."""
|
|
if hass.data.get(_VIEWS_REGISTERED_KEY):
|
|
return
|
|
hass.http.register_view(_BootView())
|
|
hass.http.register_view(_SessionView())
|
|
hass.http.register_view(_ProxyView())
|
|
hass.data[_VIEWS_REGISTERED_KEY] = True
|
|
|
|
|
|
async def _register_panel(hass: HomeAssistant) -> None:
|
|
"""Add the admin-only sidebar panel if it is not already present.
|
|
|
|
Registered as a built-in ``iframe`` panel (the "webpage dashboard" panel
|
|
type): Home Assistant renders its standard header around the page, so the
|
|
panel can never trap navigation the way a chrome-less custom panel did on
|
|
iOS (#1795).
|
|
"""
|
|
from homeassistant.components.frontend import (
|
|
async_panel_exists,
|
|
async_register_built_in_panel,
|
|
)
|
|
|
|
if async_panel_exists(hass, PANEL_URL_PATH):
|
|
return
|
|
cfg = panel_config()
|
|
async_register_built_in_panel(hass, cfg.pop("component_name"), **cfg)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Small helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None:
|
|
"""Return the running server's forwarding config, or None when it is down."""
|
|
domain_data = hass.data.get(DOMAIN)
|
|
if not isinstance(domain_data, dict):
|
|
return None
|
|
cfg = domain_data.get(DATA_WEBHOOK)
|
|
return cfg if isinstance(cfg, dict) else None
|
|
|
|
|
|
def _request_is_https(request: web.Request) -> bool:
|
|
"""Return True when the request reached HA over HTTPS (honoring the proxy)."""
|
|
forwarded = request.headers.get("X-Forwarded-Proto")
|
|
return bool((forwarded or request.scheme) == "https")
|
|
|
|
|
|
class _suppress_connection_reset:
|
|
"""Swallow a ConnectionResetError from a client that closed mid-stream."""
|
|
|
|
def __enter__(self) -> None:
|
|
return None
|
|
|
|
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
return exc_type is not None and issubclass(exc_type, ConnectionResetError)
|
|
|
|
|
|
class _suppress_all:
|
|
"""Swallow any Exception from best-effort teardown, logged at WARNING."""
|
|
|
|
def __enter__(self) -> None:
|
|
return None
|
|
|
|
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
if exc_type is None or not issubclass(exc_type, Exception):
|
|
return False # never swallow KeyboardInterrupt/SystemExit
|
|
_LOGGER.warning("HA-MCP: settings-UI panel teardown error", exc_info=exc)
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bootstrap page (embedded by the built-in iframe panel)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Plain same-origin page (no Lit / HA-frontend imports) so it never couples to a
|
|
# specific frontend build. Home Assistant's own iframe panel draws the standard
|
|
# header around it; this page only mints the session and embeds the proxied
|
|
# settings app. The script is a separate string so the node syntax test can
|
|
# parse it alone. Deliberately NOT registered in _js_harness._PY_RENDERERS
|
|
# (importing this module needs Home Assistant installed, which would break the
|
|
# harness for every surface); coverage = the node --check syntax test plus the
|
|
# Python-side session/proxy tests in test_ui_panel.py.
|
|
|
|
_BOOT_JS = f"""
|
|
const SESSION_URL = {_SESSION_URL!r};
|
|
const APP_BASE_URL = {_APP_PREFIX!r} + "settings";
|
|
// Re-mint at half the cookie lifetime so an open panel never expires mid-use.
|
|
const REFRESH_MS = {_SESSION_TTL_SECONDS // 2} * 1000;
|
|
// While the frontend is still booting (a cold start straight into this panel),
|
|
// the parent frame has no token yet -- poll gently until it does (local reads,
|
|
// no network). After TOKEN_HINT_AFTER misses, surface a hint but keep polling.
|
|
const TOKEN_RETRY_MS = 1000;
|
|
const TOKEN_HINT_AFTER = 20;
|
|
// Transient failures (network blip, server starting/restarting) retry on
|
|
// their own. Auth refusals (401/403) never auto-retry: every rejected bearer
|
|
// counts as a failed login for http.ban, and a retry loop got users IP-banned
|
|
// from their own instance (#1802).
|
|
const RETRY_MS = 5000;
|
|
const FETCH_TIMEOUT_MS = 15000;
|
|
|
|
const msg = document.querySelector(".msg");
|
|
const frame = document.querySelector("iframe");
|
|
let timer = null;
|
|
let busy = false;
|
|
let tokenMisses = 0;
|
|
let authDead = false;
|
|
|
|
function homeAssistantRoot() {{
|
|
try {{
|
|
if (window.parent === window) return null;
|
|
return window.parent.document.querySelector("home-assistant");
|
|
}} catch (err) {{
|
|
return null;
|
|
}}
|
|
}}
|
|
|
|
function appUrl() {{
|
|
const root = homeAssistantRoot();
|
|
const language = root && root.hass && root.hass.language;
|
|
if (!language) return APP_BASE_URL;
|
|
return APP_BASE_URL + "?ha_lang=" + encodeURIComponent(language);
|
|
}}
|
|
|
|
function showMessage(text, isError) {{
|
|
frame.classList.add("hidden");
|
|
msg.classList.remove("hidden");
|
|
// Failure messages announce assertively (style guide: status regions switch
|
|
// to role=alert on the failure path); benign progress stays polite.
|
|
msg.setAttribute("role", isError ? "alert" : "status");
|
|
msg.setAttribute("aria-live", isError ? "assertive" : "polite");
|
|
msg.textContent = text;
|
|
}}
|
|
|
|
function transientFailure(text) {{
|
|
// Keep an already-working app visible through a transient blip (the iframe
|
|
// holds state); only surface the message while nothing is showing yet.
|
|
if (!timer) {{
|
|
showMessage(text, true);
|
|
}}
|
|
setTimeout(mint, RETRY_MS);
|
|
}}
|
|
|
|
function fetchWithTimeout(url, options) {{
|
|
// A stalled (never-settling) fetch would wedge `busy` and silently stop all
|
|
// future re-mints; a timeout resolves it into the retry path instead.
|
|
const controller = new AbortController();
|
|
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
return fetch(url, Object.assign({{ signal: controller.signal }}, options)).finally(
|
|
() => clearTimeout(t)
|
|
);
|
|
}}
|
|
|
|
async function token() {{
|
|
// Same-origin parent = the authenticated HA frontend. Its root element owns
|
|
// the live `hass` object the frontend keeps fresh; its auth.accessToken is
|
|
// the same bearer the frontend itself uses. Refresh an expired token before
|
|
// use -- POSTing a stale bearer counts as a failed login for http.ban
|
|
// (#1802). A failed refresh means the sign-in itself is dead: mark it
|
|
// terminal rather than looping.
|
|
try {{
|
|
const root = homeAssistantRoot();
|
|
const auth = root && root.hass && root.hass.auth;
|
|
if (!auth) return null;
|
|
if (auth.expired && typeof auth.refreshAccessToken === "function") {{
|
|
try {{
|
|
await auth.refreshAccessToken();
|
|
}} catch (err) {{
|
|
authDead = true;
|
|
return null;
|
|
}}
|
|
}}
|
|
return auth.accessToken || (auth.data && auth.data.access_token) || null;
|
|
}} catch (err) {{
|
|
return null; // cross-origin parent: not embedded in the HA frontend
|
|
}}
|
|
}}
|
|
|
|
async function mint() {{
|
|
if (busy) return;
|
|
busy = true;
|
|
try {{
|
|
const bearer = await token();
|
|
if (!bearer) {{
|
|
if (authDead) {{
|
|
showMessage(
|
|
"The Home Assistant sign-in has expired. Reload the page to try again.",
|
|
true
|
|
);
|
|
return;
|
|
}}
|
|
if (window.parent === window) {{
|
|
showMessage("Open this page from the HA-MCP entry in the Home Assistant sidebar.");
|
|
return;
|
|
}}
|
|
tokenMisses += 1;
|
|
if (tokenMisses === TOKEN_HINT_AFTER) {{
|
|
showMessage(
|
|
"Still waiting for the Home Assistant sign-in. If this page is not " +
|
|
"inside the Home Assistant frontend, open it from the HA-MCP " +
|
|
"sidebar entry."
|
|
);
|
|
}}
|
|
setTimeout(mint, TOKEN_RETRY_MS);
|
|
return;
|
|
}}
|
|
tokenMisses = 0;
|
|
let resp;
|
|
try {{
|
|
resp = await fetchWithTimeout(SESSION_URL, {{
|
|
method: "POST",
|
|
credentials: "same-origin",
|
|
headers: {{ Authorization: "Bearer " + bearer }},
|
|
}});
|
|
}} catch (err) {{
|
|
transientFailure("Could not reach Home Assistant to open the settings UI.");
|
|
return;
|
|
}}
|
|
if (resp.status === 401) {{
|
|
// Never loop on a rejected bearer -- see the RETRY_MS note (#1802).
|
|
showMessage(
|
|
"Home Assistant rejected the sign-in token. Reload the page to try again.",
|
|
true
|
|
);
|
|
return;
|
|
}}
|
|
if (resp.status === 403) {{
|
|
showMessage("The HA-MCP settings UI is available to administrators only.", true);
|
|
return;
|
|
}}
|
|
if (!resp.ok) {{
|
|
transientFailure("Could not open the settings UI (HTTP " + resp.status + ").");
|
|
return;
|
|
}}
|
|
await showApp();
|
|
}} finally {{
|
|
busy = false;
|
|
}}
|
|
}}
|
|
|
|
async function showApp() {{
|
|
// Probe the proxy so a not-yet-running server shows a friendly message
|
|
// instead of a raw 503 page inside the iframe.
|
|
const targetUrl = appUrl();
|
|
let probe;
|
|
try {{
|
|
probe = await fetchWithTimeout(targetUrl, {{ credentials: "same-origin" }});
|
|
}} catch (err) {{
|
|
transientFailure("Could not reach Home Assistant to load the settings UI.");
|
|
return;
|
|
}}
|
|
if (probe.status === 503) {{
|
|
if (!timer) {{
|
|
showMessage(
|
|
"The in-process MCP server is starting or is not running yet. " +
|
|
"This view will refresh automatically."
|
|
);
|
|
}}
|
|
setTimeout(mint, RETRY_MS);
|
|
return;
|
|
}}
|
|
if (!probe.ok) {{
|
|
transientFailure("The settings UI returned HTTP " + probe.status + ".");
|
|
return;
|
|
}}
|
|
if (frame.getAttribute("src") !== targetUrl) {{
|
|
frame.setAttribute("src", targetUrl);
|
|
}}
|
|
msg.classList.add("hidden");
|
|
frame.classList.remove("hidden");
|
|
if (!timer) {{
|
|
timer = setInterval(mint, REFRESH_MS);
|
|
}}
|
|
}}
|
|
|
|
mint();
|
|
"""
|
|
|
|
_BOOT_HTML = f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>HA-MCP settings</title>
|
|
<style>
|
|
html, body {{ height: 100%; margin: 0; background: #fafafa; }}
|
|
main {{ height: 100%; outline: none; }}
|
|
iframe {{ width: 100%; height: 100%; border: 0; display: block; }}
|
|
.msg {{
|
|
padding: 24px; max-width: 640px; margin: 0 auto; box-sizing: border-box;
|
|
font-family: Roboto, sans-serif; color: #212121;
|
|
}}
|
|
.hidden {{ display: none; }}
|
|
@media (prefers-color-scheme: dark) {{
|
|
html, body {{ background: #111111; }}
|
|
.msg {{ color: #e1e1e1; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main id="main-content" tabindex="-1">
|
|
<div class="msg" role="status" aria-live="polite">Loading the HA-MCP settings UI…</div>
|
|
<iframe class="hidden" title="HA-MCP settings"></iframe>
|
|
</main>
|
|
<script>
|
|
{_BOOT_JS}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def render_boot_script() -> str:
|
|
"""Return the boot-page script source (used by the JS-parse tests)."""
|
|
return _BOOT_JS
|
|
|
|
|
|
def render_boot_page() -> str:
|
|
"""Return the boot-page HTML (used by the tests)."""
|
|
return _BOOT_HTML
|
|
|
|
|
|
def panel_config() -> ConfigType:
|
|
"""Return the sidebar-panel registration parameters (registration + tests)."""
|
|
return {
|
|
"component_name": "iframe",
|
|
"frontend_url_path": PANEL_URL_PATH,
|
|
"sidebar_title": PANEL_TITLE,
|
|
"sidebar_icon": PANEL_ICON,
|
|
"config": {"url": _BOOT_URL},
|
|
"require_admin": True,
|
|
}
|