Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+71 -7
View File
@@ -82,6 +82,31 @@ _PROXY_URL = f"{_UI_BASE}/app/{{path:.*}}"
# 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
@@ -94,7 +119,9 @@ _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).
# 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",
@@ -121,6 +148,24 @@ _STRIPPED_RESPONSE_HEADERS = frozenset(
)
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)
# ---------------------------------------------------------------------------
@@ -300,6 +345,9 @@ class _ProxyView(HomeAssistantView):
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(
@@ -473,7 +521,7 @@ class _suppress_all:
_BOOT_JS = f"""
const SESSION_URL = {_SESSION_URL!r};
const APP_URL = {_APP_PREFIX!r} + "settings";
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),
@@ -495,6 +543,22 @@ 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");
@@ -532,8 +596,7 @@ async function token() {{
// (#1802). A failed refresh means the sign-in itself is dead: mark it
// terminal rather than looping.
try {{
if (window.parent === window) return null;
const root = window.parent.document.querySelector("home-assistant");
const root = homeAssistantRoot();
const auth = root && root.hass && root.hass.auth;
if (!auth) return null;
if (auth.expired && typeof auth.refreshAccessToken === "function") {{
@@ -615,9 +678,10 @@ async function mint() {{
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(APP_URL, {{ credentials: "same-origin" }});
probe = await fetchWithTimeout(targetUrl, {{ credentials: "same-origin" }});
}} catch (err) {{
transientFailure("Could not reach Home Assistant to load the settings UI.");
return;
@@ -636,8 +700,8 @@ async function showApp() {{
transientFailure("The settings UI returned HTTP " + probe.status + ".");
return;
}}
if (frame.getAttribute("src") !== APP_URL) {{
frame.setAttribute("src", APP_URL);
if (frame.getAttribute("src") !== targetUrl) {{
frame.setAttribute("src", targetUrl);
}}
msg.classList.add("hidden");
frame.classList.remove("hidden");