From 2c17573e60105aef626ebbbeafdb3f1bcf0b7a45 Mon Sep 17 00:00:00 2001 From: Misiu Date: Fri, 28 Aug 2026 07:16:02 +0200 Subject: [PATCH] Release integration 0.6.0 with package asset permissions --- CHANGELOG.md | 10 ++ README.md | 10 +- STAGE2_COMPATIBILITY.md | 14 +- WIDGET_CONTRACT.md | 48 ++++++- .../opendisplay_studio/composer.py | 83 ++++++++++- .../opendisplay_studio/config_flow.py | 2 +- custom_components/opendisplay_studio/const.py | 6 +- .../opendisplay_studio/manifest.json | 2 +- .../opendisplay_studio/media_source.py | 1 + .../opendisplay_studio/renderer.py | 16 ++- .../opendisplay_studio/websocket.py | 1 + .../opendisplay_studio/widgets/__init__.py | 130 +++++++++++++++++- .../widgets/weather/provider.py | 35 +++-- .../widgets/weather/widget.liquid | 87 +++++++----- .../widgets/weather/widget.yml | 2 +- frontend-src/package-lock.json | 4 +- frontend-src/package.json | 2 +- .../src/weather-liquid-contract.test.ts | 6 +- pyproject.toml | 2 +- tests/test_composer.py | 38 ++++- tests/test_config_flow.py | 6 +- tests/test_media_source.py | 1 + tests/test_release_version.py | 2 +- tests/test_renderer.py | 17 ++- tests/test_trmnl_liquid.py | 9 ++ tests/test_websocket.py | 1 + tests/test_widget_providers.py | 2 +- tests/test_widgets.py | 120 +++++++++++++++- 28 files changed, 547 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f6728..898c26a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## 0.6.0 + +- Resolve package-owned files from `widgets//assets` into bounded `data:` + URIs exposed to Liquid through the `assets` mapping. +- Replace all Weather network images with locally bundled Material Design Icon + classes and require Renderer App 0.6.0. +- Add package-level `permissions.network.allowedOrigins`; widgets remain + local-only by default and only origins actually used by a screen are passed + to Renderer API v2. + ## 0.5.1 - Replace the integration-owned Liquid subset with `trmnl-liquid-py` 0.1.0, diff --git a/README.md b/README.md index 4eefff7..125d9d9 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,18 @@ reads current entity states, calendar events, and requested weather forecasts, renders widget Liquid templates through [`trmnl-liquid-py`](https://github.com/Misiu/trmnl-liquid-py), composes one TRMNL Framework document, and sends that final HTML to the Renderer. The -Renderer App still knows only HTML, width, and height. The designer preview +Renderer App still knows only HTML, dimensions, and the request-scoped asset +origin allowlist. The designer preview calls this same Renderer path and shows its PNG, so the visible preview and final Media Source do not have separate CSS or layout implementations. +Widget templates can use the complete, locally bundled Material Design Icons +catalog through `mdi` classes. Package-owned files below `assets/` are converted +by the integration to bounded base64 `data:` URIs before HTML reaches the +Renderer. No display render requires remote image or font requests. +Widgets are local-only by default; packages that intentionally need a remote +asset must declare exact allowed origins in their own manifest. + See [the widget contract](WIDGET_CONTRACT.md) for the schema/data/template boundary, native Home Assistant selectors, and the controlled provider model. See [Stage 2 compatibility](STAGE2_COMPATIBILITY.md) for the Liquid/TRMNL diff --git a/STAGE2_COMPATIBILITY.md b/STAGE2_COMPATIBILITY.md index fc91202..a0974a7 100644 --- a/STAGE2_COMPATIBILITY.md +++ b/STAGE2_COMPATIBILITY.md @@ -3,8 +3,9 @@ ## Component boundary Liquid runs only in the Home Assistant integration. It produces final HTML and -records `liquid_ms`; the Renderer App receives only final HTML, width, and -height. The App has no Liquid dependency or template API. +records `liquid_ms`; the Renderer App receives only final HTML, dimensions, and +the request-scoped asset origin allowlist. The App has no Liquid dependency or +template API. ## Liquid engine @@ -51,6 +52,9 @@ not final e-paper quantization or dithering by OpenDisplay. ## Licenses and offline assets The App bundles TRMNL Framework 3.2.0 CSS/JS and fonts with upstream MIT, OFL, -and CC BY notices. It contains no Highcharts distribution or plugin image -bundle. The integration's `trmnl-liquid-py` dependency and the adapted Liquid -Components source are MIT licensed. +and CC BY notices plus Material Design Icons 7.4.47 under Apache-2.0. It +contains no Highcharts distribution or plugin image bundle. The integration's +`trmnl-liquid-py` dependency and the adapted Liquid Components source are MIT +licensed. Rendered widgets use only local MDI or package-owned data URIs, and +the Renderer blocks undeclared HTTP and HTTPS origins and scopes declared +widget origins to the individual render request. diff --git a/WIDGET_CONTRACT.md b/WIDGET_CONTRACT.md index 2b3050c..24b616d 100644 --- a/WIDGET_CONTRACT.md +++ b/WIDGET_CONTRACT.md @@ -25,6 +25,34 @@ data requirements, the Liquid template, and the optional provider module. `provider.py` and `translations` belong to that widget. A static widget such as Text does not need a provider. +Files below `assets/` are package-owned and available to Liquid as base64 data +URIs keyed by their POSIX relative path: + +```liquid + +``` + +Supported files are SVG, PNG, JPEG, GIF, WebP, WOFF, and WOFF2. Both an +individual asset and the complete package asset set are limited to 512,000 +bytes before base64 encoding. Symbolic links and unsupported executable files +are rejected. Asset data never needs to be copied into the Renderer container. + +Packages are local-only by default. A widget that intentionally loads a remote +asset declares each exact HTTP(S) origin in `widget.yml`: + +```yaml +permissions: + network: + allowedOrigins: + - https://cdn.example.com +``` + +Origins cannot contain credentials, paths, queries, or fragments. The +integration rejects undeclared remote asset references and passes only origins +actually used by the composed screen to the Renderer. The declaration is a +package capability, so installing or reviewing a widget does not require +widget-specific integration code. + The integration ships built-in packages in `custom_components/opendisplay_studio/widgets`. Independently installed packages use `/config/opendisplay_studio/widgets`. The registry scans both @@ -135,10 +163,16 @@ so every package shares one predictable display environment. decisions use the actual CSS region container dimensions, never names such as full or half. A package must work for arbitrary device and region sizes. -The CLI and integration use bounded Liquid engines. Published widgets need -contract fixtures covering missing optional nested fields, not only empty or -null values, and must avoid engine-specific undefined-value behavior. - -External assets cannot fail silently. Published archives will carry declared -assets so rendering remains deterministic. Framework compatibility is also -declared by the package and must be checked rather than silently substituted. +The CLI and integration follow the shared TRMNL Liquid contract. Published +widgets need contract fixtures covering missing optional nested fields, not +only empty or null values, and must avoid engine-specific undefined-value +behavior. + +The Renderer bundles the complete Material Design Icons font. Templates can use +any icon locally with markup such as ``. +Widget-specific files should normally belong in `assets/` and use the Liquid +mapping above. Undeclared HTTP and HTTPS origins are blocked by the Renderer; +remote access is available only through the explicit manifest permission. +Published archives carry their local assets so rendering remains +deterministic. Framework compatibility is also declared by the package and +must be checked rather than silently substituted. diff --git a/custom_components/opendisplay_studio/composer.py b/custom_components/opendisplay_studio/composer.py index 4ad9d20..03b528b 100644 --- a/custom_components/opendisplay_studio/composer.py +++ b/custom_components/opendisplay_studio/composer.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio +import re from dataclasses import dataclass from time import perf_counter from typing import Any +from urllib.parse import urlsplit from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -25,10 +27,74 @@ class ComposedProject: data_ms: float liquid_ms: float compose_ms: float + allowed_asset_origins: tuple[str, ...] = () class ProjectComposeError(Exception): - """Raised when current Home Assistant data cannot be resolved.""" + """Raised when widget data or markup cannot be composed safely.""" + + +REMOTE_ASSET_PATTERNS = ( + re.compile( + r"\b(?:src|srcset)\s*=\s*[\"']([^\"']*https?://[^\"']+)[\"']", + re.IGNORECASE, + ), + re.compile(r"\burl\(\s*[\"']?(https?://[^\"')\s]+)[\"']?\s*\)", re.IGNORECASE), + re.compile( + r"]*\bhref\s*=\s*[\"'](https?://[^\"']+)[\"']", + re.IGNORECASE, + ), + re.compile( + r"@import\s+(?:url\(\s*)?[\"']?(https?://[^\"')\s;]+)[\"']?\s*\)?", + re.IGNORECASE, + ), + re.compile( + r"<(?:image|use)\b[^>]*\bhref\s*=\s*[\"'](https?://[^\"']+)[\"']", + re.IGNORECASE, + ), + re.compile( + r"]*\bdata\s*=\s*[\"'](https?://[^\"']+)[\"']", + re.IGNORECASE, + ), +) +REMOTE_URL_PATTERN = re.compile(r"https?://[^\s,\"']+", re.IGNORECASE) + + +def _assert_asset_permissions( + fragment: str, widget_type: str, registry: WidgetRegistry +) -> set[str]: + """Return used, declared origins and reject undeclared remote assets.""" + allowed = set( + definition(widget_type, registry)["permissions"]["network"]["allowedOrigins"] + ) + used: set[str] = set() + for pattern in REMOTE_ASSET_PATTERNS: + for match in pattern.finditer(fragment): + for remote in REMOTE_URL_PATTERN.findall(match.group(1)): + parsed = urlsplit(remote) + port = parsed.port + host = parsed.hostname or "" + host_literal = f"[{host}]" if ":" in host else host + default_port = (parsed.scheme == "http" and port == 80) or ( + parsed.scheme == "https" and port == 443 + ) + port_suffix = ( + f":{port}" if port is not None and not default_port else "" + ) + origin = f"{parsed.scheme}://{host_literal}{port_suffix}" + if origin not in allowed: + message = ( + f"{widget_type} widget uses undeclared remote asset " + f"origin: {origin}" + ) + raise ProjectComposeError(message) + used.add(origin) + return used + + +def _new_composition_state() -> tuple[float, list[str], set[str]]: + """Create typed mutable accumulators for one screen composition.""" + return 0.0, [], set() STUDIO_STYLES = """ @@ -193,8 +259,7 @@ async def async_compose_project( resolved = dict(zip(provider_keys, provider_values, strict=True)) data_ms = (perf_counter() - started) * 1_000 - liquid_ms = 0.0 - fragments: list[str] = [] + liquid_ms, fragments, allowed_asset_origins = _new_composition_state() width = int(project.get("width", 800)) height = int(project.get("height", 480)) gap = max(3, min(10, round(min(width, height) / 60))) @@ -225,11 +290,15 @@ async def async_compose_project( registry.template(widget_type), config=config, data=data, + assets=registry.assets(widget_type), region={"shape": _region_shape(project, region, gap=layout_gap)}, ) except LiquidError as err: message = f"Could not render {widget_type} widget: {err}" raise ProjectComposeError(message) from err + allowed_asset_origins.update( + _assert_asset_permissions(fragment, widget_type, registry) + ) liquid_ms += (perf_counter() - liquid_started) * 1_000 region_width, region_height = _region_size(project, region, gap=layout_gap) ratio = region_width / max(1, region_height) @@ -271,4 +340,10 @@ async def async_compose_project( f"{body}" ) compose_ms = (perf_counter() - started) * 1_000 - return ComposedProject(html, data_ms, liquid_ms, compose_ms) + return ComposedProject( + html, + data_ms, + liquid_ms, + compose_ms, + tuple(sorted(allowed_asset_origins)), + ) diff --git a/custom_components/opendisplay_studio/config_flow.py b/custom_components/opendisplay_studio/config_flow.py index bbeb4d5..104b5a9 100644 --- a/custom_components/opendisplay_studio/config_flow.py +++ b/custom_components/opendisplay_studio/config_flow.py @@ -198,7 +198,7 @@ async def async_step_start_addon( return self.async_show_progress_done(next_step_id="finish_addon_setup") async def _async_start_addon_and_wait(self) -> None: - """Start if needed, then wait for discovery plus healthy API v1.""" + """Start if needed, then wait for discovery plus healthy API v2.""" manager = get_addon_manager(self.hass) addon_info = await manager.async_get_addon_info() if addon_info.state is not AddonState.RUNNING: diff --git a/custom_components/opendisplay_studio/const.py b/custom_components/opendisplay_studio/const.py index e0ec335..5f355a4 100644 --- a/custom_components/opendisplay_studio/const.py +++ b/custom_components/opendisplay_studio/const.py @@ -6,10 +6,10 @@ DOMAIN = "opendisplay_studio" NAME = "OpenDisplay Studio" -INTEGRATION_VERSION = "0.5.1" +INTEGRATION_VERSION = "0.6.0" -API_VERSION = 1 -MIN_RENDERER_VERSION = "0.5.0" +API_VERSION = 2 +MIN_RENDERER_VERSION = "0.6.0" TRMNL_FRAMEWORK_VERSION = "3.2.0" DEFAULT_WIDTH = 800 DEFAULT_HEIGHT = 480 diff --git a/custom_components/opendisplay_studio/manifest.json b/custom_components/opendisplay_studio/manifest.json index 9b4b595..e769854 100644 --- a/custom_components/opendisplay_studio/manifest.json +++ b/custom_components/opendisplay_studio/manifest.json @@ -11,5 +11,5 @@ "issue_tracker": "https://github.com/Misiu/OpenDisplay-Studio-Integration/issues", "requirements": ["trmnl-liquid-py==0.1.0"], "single_config_entry": true, - "version": "0.5.1" + "version": "0.6.0" } diff --git a/custom_components/opendisplay_studio/media_source.py b/custom_components/opendisplay_studio/media_source.py index 1bfec0b..19aa5ca 100644 --- a/custom_components/opendisplay_studio/media_source.py +++ b/custom_components/opendisplay_studio/media_source.py @@ -51,6 +51,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: html=built.html, width=project["width"], height=project["height"], + allowed_asset_origins=built.allowed_asset_origins, ) except (ProjectComposeError, RendererError) as err: LOGGER.error("Could not render %s: %s", item.identifier, err) diff --git a/custom_components/opendisplay_studio/renderer.py b/custom_components/opendisplay_studio/renderer.py index 73c2332..51cc184 100644 --- a/custom_components/opendisplay_studio/renderer.py +++ b/custom_components/opendisplay_studio/renderer.py @@ -123,13 +123,25 @@ async def async_health(self) -> RendererHealth: trmnlFrameworkVersion=framework_version, ) - async def async_render(self, *, html: str, width: int, height: int) -> RenderResult: + async def async_render( + self, + *, + html: str, + width: int, + height: int, + allowed_asset_origins: tuple[str, ...] = (), + ) -> RenderResult: """Render HTML and return raw PNG data and timing response headers.""" try: async with self._session.post( self._base_url.with_path("/render"), headers=self._headers, - json={"html": html, "width": width, "height": height}, + json={ + "html": html, + "width": width, + "height": height, + "allowedAssetOrigins": list(allowed_asset_origins), + }, timeout=ClientTimeout(total=30), ) as response: if response.status == 401: diff --git a/custom_components/opendisplay_studio/websocket.py b/custom_components/opendisplay_studio/websocket.py index 1610ff5..f4f9816 100644 --- a/custom_components/opendisplay_studio/websocket.py +++ b/custom_components/opendisplay_studio/websocket.py @@ -154,6 +154,7 @@ async def websocket_compose_preview( html=composed.html, width=project["width"], height=project["height"], + allowed_asset_origins=composed.allowed_asset_origins, ) except ProjectValidationError as err: _error(connection, msg, err) diff --git a/custom_components/opendisplay_studio/widgets/__init__.py b/custom_components/opendisplay_studio/widgets/__init__.py index 678b0ce..46bac87 100644 --- a/custom_components/opendisplay_studio/widgets/__init__.py +++ b/custom_components/opendisplay_studio/widgets/__init__.py @@ -6,10 +6,12 @@ import importlib.util import re import sys +from base64 import b64encode from collections.abc import Iterable from hashlib import sha256 from pathlib import Path from typing import Any, Final, Protocol, cast +from urllib.parse import urlsplit import yaml # type: ignore[import-untyped] @@ -18,6 +20,19 @@ VERSION_PATTERN: Final = re.compile( r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$" ) +MAX_WIDGET_ASSET_BYTES: Final = 512_000 +MAX_WIDGET_ASSETS_BYTES: Final = 512_000 +MAX_NETWORK_ORIGINS: Final = 16 +ASSET_MIME_TYPES: Final = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +} class WidgetPackageError(ValueError): @@ -97,9 +112,100 @@ def _load_provider(path: Path) -> DataProvider: return cast("DataProvider", provider) +def _load_assets(directory: Path) -> dict[str, str]: + """Load package-owned assets as bounded data URIs for Liquid templates.""" + root = directory / "assets" + if not root.is_dir(): + return {} + resolved_root = root.resolve() + assets: dict[str, str] = {} + total_bytes = 0 + for path in sorted(root.rglob("*")): + if path.is_symlink(): + message = f"Widget assets cannot contain symbolic links: {path.name}" + raise WidgetPackageError(message) + if path.is_dir(): + continue + resolved = path.resolve() + if not path.is_file() or not resolved.is_relative_to(resolved_root): + message = f"Unsafe widget asset: {path.name}" + raise WidgetPackageError(message) + mime_type = ASSET_MIME_TYPES.get(path.suffix.lower()) + if mime_type is None: + message = f"Unsupported widget asset type: {path.name}" + raise WidgetPackageError(message) + content = path.read_bytes() + if len(content) > MAX_WIDGET_ASSET_BYTES: + message = ( + f"Widget asset exceeds {MAX_WIDGET_ASSET_BYTES} bytes: {path.name}" + ) + raise WidgetPackageError(message) + total_bytes += len(content) + if total_bytes > MAX_WIDGET_ASSETS_BYTES: + message = f"Widget assets exceed {MAX_WIDGET_ASSETS_BYTES} bytes" + raise WidgetPackageError(message) + key = path.relative_to(root).as_posix() + encoded = b64encode(content).decode("ascii") + assets[key] = f"data:{mime_type};base64,{encoded}" + return assets + + +def _load_permissions(raw: object, widget_id: str) -> dict[str, Any]: + """Validate generic package capabilities without widget-specific knowledge.""" + if raw is None: + return {"network": {"allowedOrigins": []}} + if not isinstance(raw, dict) or set(raw) - {"network"}: + message = f"Widget {widget_id} has invalid permissions" + raise WidgetPackageError(message) + network = raw.get("network", {}) + if not isinstance(network, dict) or set(network) - {"allowedOrigins"}: + message = f"Widget {widget_id} has invalid network permissions" + raise WidgetPackageError(message) + origins = network.get("allowedOrigins", []) + if not isinstance(origins, list) or len(origins) > MAX_NETWORK_ORIGINS: + message = f"Widget {widget_id} has invalid allowedOrigins" + raise WidgetPackageError(message) + normalized: list[str] = [] + for value in origins: + if not isinstance(value, str): + message = f"Widget {widget_id} network origins must be strings" + raise WidgetPackageError(message) + parsed = urlsplit(value) + try: + port = parsed.port + except ValueError as err: + message = f"Widget {widget_id} has an invalid network port" + raise WidgetPackageError(message) from err + host = parsed.hostname or "" + host_literal = f"[{host}]" if ":" in host else host + default_port = (parsed.scheme == "http" and port == 80) or ( + parsed.scheme == "https" and port == 443 + ) + port_suffix = f":{port}" if port is not None and not default_port else "" + origin = f"{parsed.scheme}://{host_literal}{port_suffix}" + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path not in {"", "/"} + or parsed.query + or parsed.fragment + or value.rstrip("/") != origin + ): + message = ( + f"Widget {widget_id} network permission must be an HTTP(S) " + "origin without a path" + ) + raise WidgetPackageError(message) + if origin not in normalized: + normalized.append(origin) + return {"network": {"allowedOrigins": normalized}} + + def _load_definition( directory: Path, -) -> tuple[dict[str, Any], str, DataProvider | None]: +) -> tuple[dict[str, Any], str, dict[str, str], DataProvider | None]: """Load and validate one widget package directory.""" manifest_path = directory / "widget.yml" raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) @@ -151,6 +257,7 @@ def _load_definition( "defaults": defaults, "fields": fields, "dataRequirements": requirements, + "permissions": _load_permissions(raw.get("permissions"), widget_id), } ) provider = None @@ -158,7 +265,12 @@ def _load_definition( provider = _load_provider( _safe_package_file(directory, provider_name, "provider") ) - return definition, template_path.read_text(encoding="utf-8"), provider + return ( + definition, + template_path.read_text(encoding="utf-8"), + _load_assets(directory), + provider, + ) class WidgetRegistry: @@ -168,14 +280,16 @@ def __init__(self) -> None: """Initialize the reusable provider set.""" self._definitions: dict[str, dict[str, Any]] = {} self._templates: dict[str, str] = {} + self._assets: dict[str, dict[str, str]] = {} self._providers: dict[tuple[str, str], DataProvider] = {} def reload_from_directories(self, roots: Iterable[Path]) -> None: """Atomically replace discovered packages while keeping this registry.""" candidate = self.from_directories(roots) - definitions, templates, providers = candidate._contents() # noqa: SLF001 + definitions, templates, assets, providers = candidate._contents() # noqa: SLF001 self._definitions = definitions self._templates = templates + self._assets = assets self._providers = providers def _contents( @@ -183,10 +297,11 @@ def _contents( ) -> tuple[ dict[str, dict[str, Any]], dict[str, str], + dict[str, dict[str, str]], dict[tuple[str, str], DataProvider], ]: """Return internal mappings for an atomic same-class replacement.""" - return self._definitions, self._templates, self._providers + return self._definitions, self._templates, self._assets, self._providers @classmethod def from_directories(cls, roots: Iterable[Path]) -> WidgetRegistry: @@ -198,7 +313,7 @@ def from_directories(cls, roots: Iterable[Path]) -> WidgetRegistry: for directory in sorted(root.iterdir()): if not directory.is_dir() or not (directory / "widget.yml").is_file(): continue - definition, template, provider = _load_definition(directory) + definition, template, assets, provider = _load_definition(directory) widget_id = definition["id"] registry._providers = { key: value @@ -207,6 +322,7 @@ def from_directories(cls, roots: Iterable[Path]) -> WidgetRegistry: } registry._definitions[widget_id] = definition registry._templates[widget_id] = template + registry._assets[widget_id] = assets if provider is not None: registry._providers[(widget_id, provider.name)] = provider for widget_definition in registry._definitions.values(): @@ -247,6 +363,10 @@ def template(self, widget_type: str) -> str: """Return one installed Liquid template.""" return self._templates[widget_type] + def assets(self, widget_type: str) -> dict[str, str]: + """Return package-owned assets encoded as immutable data URIs.""" + return dict(self._assets[widget_type]) + def provider(self, widget_type: str, name: str) -> DataProvider: """Return a provider scoped to its owning widget package.""" try: diff --git a/custom_components/opendisplay_studio/widgets/weather/provider.py b/custom_components/opendisplay_studio/widgets/weather/provider.py index 197bb3e..974076c 100644 --- a/custom_components/opendisplay_studio/widgets/weather/provider.py +++ b/custom_components/opendisplay_studio/widgets/weather/provider.py @@ -22,24 +22,22 @@ LOGGER_NAME = "custom_components.opendisplay_studio" LOGGER = logging.getLogger(LOGGER_NAME) TRANSLATIONS_DIRECTORY = Path(__file__).with_name("translations") -WEATHER_ICON_BASE_URL = "https://trmnl.com/images/plugins/weather" - WEATHER_CONDITION_ICONS = { - "clear-night": "wi-night-clear.svg", - "cloudy": "wi-cloudy.svg", - "exceptional": "wi-na.svg", - "fog": "wi-fog.svg", - "hail": "wi-hail.svg", - "lightning": "wi-lightning.svg", - "lightning-rainy": "wi-thunderstorm.svg", - "partlycloudy": "wi-day-cloudy.svg", - "pouring": "wi-rain-wind.svg", - "rainy": "wi-rain.svg", - "snowy": "wi-snow.svg", - "snowy-rainy": "wi-rain-mix.svg", - "sunny": "wi-day-sunny.svg", - "windy": "wi-strong-wind.svg", - "windy-variant": "wi-cloudy-windy.svg", + "clear-night": "mdi-weather-night", + "cloudy": "mdi-weather-cloudy", + "exceptional": "mdi-alert-circle-outline", + "fog": "mdi-weather-fog", + "hail": "mdi-weather-hail", + "lightning": "mdi-weather-lightning", + "lightning-rainy": "mdi-weather-lightning-rainy", + "partlycloudy": "mdi-weather-partly-cloudy", + "pouring": "mdi-weather-pouring", + "rainy": "mdi-weather-rainy", + "snowy": "mdi-weather-snowy", + "snowy-rainy": "mdi-weather-snowy-rainy", + "sunny": "mdi-white-balance-sunny", + "windy": "mdi-weather-windy", + "windy-variant": "mdi-weather-windy-variant", } @@ -122,8 +120,7 @@ def condition(self, condition: str) -> str: def _weather_icon(condition: str) -> str: """Map a Home Assistant condition to the widget's weather icon.""" - filename = WEATHER_CONDITION_ICONS.get(condition, "wi-na.svg") - return f"{WEATHER_ICON_BASE_URL}/{filename}" + return WEATHER_CONDITION_ICONS.get(condition, "mdi-alert-circle-outline") def _placeholder(entity_id: str, localizer: WeatherLocalizer) -> dict[str, Any]: diff --git a/custom_components/opendisplay_studio/widgets/weather/widget.liquid b/custom_components/opendisplay_studio/widgets/weather/widget.liquid index 330ff8f..a81ee51 100644 --- a/custom_components/opendisplay_studio/widgets/weather/widget.liquid +++ b/custom_components/opendisplay_studio/widgets/weather/widget.liquid @@ -1,9 +1,5 @@ {% assign current = data.weather %} -{% if current contains 'forecast' %} - {% assign forecast = current.forecast %} -{% else %} - {% assign forecast = nil %} -{% endif %} +{% assign forecast = current.forecast %} {% assign today = nil %} {% assign tomorrow = nil %} {% for forecast_day in forecast limit: 2 %} @@ -19,7 +15,7 @@
- {{ current.condition_label }} +
@@ -35,7 +31,7 @@
- {{ current.labels.temperature }} +
{{ current.apparent_temperature }}° @@ -47,7 +43,7 @@
- {{ current.labels.humidity }} +
{{ current.humidity }}% @@ -58,7 +54,7 @@
- {{ current.condition_label }} +
{{ current.condition_label }} @@ -77,7 +73,7 @@
- {{ day.condition_label }} +
{{ day.condition_label }} @@ -88,7 +84,7 @@
- {% if day.uv_index != nil %}{{ current.labels.uv }}{% else %}{{ current.labels.precipitation }}{% endif %} +
{% if day.uv_index != nil %}{{ day.uv_label }} ({{ day.uv_index }}){% else %}{{ day.precipitation_probability }}%{% endif %} @@ -100,7 +96,7 @@
- {{ current.labels.temperature }} +
@@ -123,7 +119,7 @@ {% endif %}
- +

{{ current.labels.weather }}

{{ current.name }}
@@ -133,7 +129,7 @@
- {{ current.condition_label }} +
@@ -198,7 +194,7 @@
- +

{{ current.updated_at }}

{{ current.name }}
@@ -208,7 +204,7 @@
- {{ current.condition_label }} +
@@ -254,7 +250,7 @@ {% endif %}
- +

{{ current.updated_at }}

{{ current.name }}
@@ -265,7 +261,7 @@
- {{ current.condition_label }} +
- +

{{ current.updated_at }}

{{ current.name }}
@@ -323,7 +319,7 @@
- {{ current.condition_label }} + {{ current.temperature }}°
{{ current.condition_label }} @@ -332,7 +328,7 @@
- +

{{ current.updated_at }}

{{ current.name }}
@@ -341,12 +337,12 @@
- {{ current.condition_label }} + {{ current.temperature }}°
- +

{{ current.updated_at }}

{{ current.name }}
@@ -387,8 +383,32 @@ height: var(--title-bar-small-height); } - .od-weather__composition:not(.od-weather__composition--expansive) > .title_bar img { - height: var(--title-bar-small-image-height); + .od-weather .title_bar > .mdi { + display: inline-flex; + align-items: center; + font-size: var(--title-bar-image-height); + line-height: 1; + } + + .od-weather__composition:not(.od-weather__composition--expansive) > .title_bar > .mdi { + font-size: var(--title-bar-small-image-height); + } + + .od-weather__condition-icon, + .od-weather__detail-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + line-height: 1; + } + + .od-weather__condition-icon--hero { + font-size: min(46cqw, 46cqh, 180px); + } + + .od-weather__detail-icon { + font-size: clamp(20px, 6cqw, 64px); } .od-weather__composition--compact { @@ -419,11 +439,9 @@ overflow: hidden; } - .od-weather__strip-content > img { - width: min(21cqw, 88px); - height: min(62cqh, 88px); + .od-weather__strip-content > .od-weather__condition-icon { + font-size: min(21cqw, 62cqh, 88px); flex: 0 1 auto; - object-fit: contain; } .od-weather__strip-content > .value { @@ -451,10 +469,8 @@ text-overflow: ellipsis; } - .od-weather__minimal-content img { - width: min(78cqw, 145px); - height: min(60cqh, 120px); - object-fit: contain; + .od-weather__minimal-content .od-weather__condition-icon { + font-size: min(78cqw, 60cqh, 145px); } .od-weather__minimal-content .value { @@ -511,9 +527,8 @@ grid-template-columns: minmax(0, 1fr); } - .od-weather__composition--compact .row img { - width: auto; - height: min(38cqh, 160px); + .od-weather__composition--compact .row .od-weather__condition-icon { + font-size: min(76cqw, 38cqh, 160px); } } diff --git a/custom_components/opendisplay_studio/widgets/weather/widget.yml b/custom_components/opendisplay_studio/widgets/weather/widget.yml index 90cddd2..52c77f6 100644 --- a/custom_components/opendisplay_studio/widgets/weather/widget.yml +++ b/custom_components/opendisplay_studio/widgets/weather/widget.yml @@ -1,6 +1,6 @@ id: weather name: Weather -version: "0.5.1" +version: "0.6.0" description: Current conditions and a daily Home Assistant forecast. icon: mdi:weather-partly-cloudy framework: 3.2.0 diff --git a/frontend-src/package-lock.json b/frontend-src/package-lock.json index 59fa2f0..c27cce4 100644 --- a/frontend-src/package-lock.json +++ b/frontend-src/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendisplay-studio-ha-panel", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendisplay-studio-ha-panel", - "version": "0.5.1", + "version": "0.6.0", "dependencies": { "@mdi/js": "^7.4.47", "lit": "^3.3.3" diff --git a/frontend-src/package.json b/frontend-src/package.json index e91eabd..5d351be 100644 --- a/frontend-src/package.json +++ b/frontend-src/package.json @@ -1,7 +1,7 @@ { "name": "opendisplay-studio-ha-panel", "private": true, - "version": "0.5.1", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite build --watch", diff --git a/frontend-src/src/weather-liquid-contract.test.ts b/frontend-src/src/weather-liquid-contract.test.ts index aa48b2a..6fc5d5a 100644 --- a/frontend-src/src/weather-liquid-contract.test.ts +++ b/frontend-src/src/weather-liquid-contract.test.ts @@ -8,8 +8,8 @@ const templateUrl = new URL( ) const engine = new Liquid({ dynamicPartials: true, - strictFilters: true, - strictVariables: true, + strictFilters: false, + strictVariables: false, }) const englishLabels = { @@ -40,7 +40,7 @@ const current = { name: 'Home', condition: 'rainy', condition_label: 'Rain', - icon: 'https://trmnl.com/images/plugins/weather/wi-rain.svg', + icon: 'mdi-weather-rainy', temperature: 12, temperature_unit: '°C', apparent_temperature: 9, diff --git a/pyproject.toml b/pyproject.toml index bec416e..c6bf7e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opendisplay-studio-integration" -version = "0.5.1" +version = "0.6.0" requires-python = ">=3.14.2" [tool.pytest.ini_options] diff --git a/tests/test_composer.py b/tests/test_composer.py index c670b8d..87cbc9f 100644 --- a/tests/test_composer.py +++ b/tests/test_composer.py @@ -198,6 +198,40 @@ async def test_liquid_syntax_error_is_exposed_as_compose_error(hass) -> None: await async_compose_project(hass, project) +async def test_remote_widget_asset_is_rejected_before_renderer(hass) -> None: + project = { + "palette": "bw", + "grid": {"columns": 1, "rows": 1}, + "regions": [ + { + "id": "text", + "row": 1, + "column": 1, + "rowSpan": 1, + "columnSpan": 1, + "widget": { + "type": "text", + "version": "0.5.0", + "config": {"text": "Remote"}, + }, + } + ], + } + + with ( + patch.object( + DEFAULT_REGISTRY, + "template", + return_value='', + ), + pytest.raises( + ProjectComposeError, + match="text widget uses undeclared remote asset origin", + ), + ): + await async_compose_project(hass, project) + + async def test_weather_widget_renders_normalized_home_assistant_data(hass) -> None: project = { "palette": "bw", @@ -225,7 +259,7 @@ async def test_weather_widget_renders_normalized_home_assistant_data(hass) -> No "name": "Home", "condition": "rainy", "condition_label": "Rain", - "icon": "https://trmnl.com/images/plugins/weather/wi-rain.svg", + "icon": "mdi-weather-rainy", "temperature": 12, "temperature_unit": "°C", "apparent_temperature": 9, @@ -337,7 +371,7 @@ async def test_weather_widget_uses_project_language_for_every_render_surface( "name": "OpenWeatherMap", "condition": "sunny", "condition_label": "słonecznie", - "icon": "https://trmnl.com/images/plugins/weather/wi-day-sunny.svg", + "icon": "mdi-white-balance-sunny", "temperature": 11, "temperature_unit": "°C", "apparent_temperature": 10, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index dedba06..bff391f 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -15,8 +15,8 @@ async def test_container_uses_external_renderer_flow(hass, aioclient_mock) -> No "http://renderer:8099/health", json={ "status": "ok", - "version": "0.5.0", - "apiVersion": 1, + "version": "0.6.0", + "apiVersion": 2, "trmnlFrameworkVersion": "3.2.0", }, ) @@ -37,7 +37,7 @@ async def test_container_uses_external_renderer_flow(hass, aioclient_mock) -> No assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"]["use_addon"] is False - assert result["data"]["api_version"] == 1 + assert result["data"]["api_version"] == 2 async def test_supervisor_requires_repository_when_app_is_unknown(hass) -> None: diff --git a/tests/test_media_source.py b/tests/test_media_source.py index 656418e..5a1946f 100644 --- a/tests/test_media_source.py +++ b/tests/test_media_source.py @@ -65,6 +65,7 @@ async def test_resolve_renders_and_publishes_temporary_png(hass) -> None: assert call["width"] == 800 assert call["height"] == 480 assert call["html"] == "
fresh
" + assert call["allowed_asset_origins"] == () async def test_browse_exposes_ready_projects_only(hass) -> None: diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 826f44d..f2255ec 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -23,7 +23,7 @@ def test_release_rejects_mismatch(tmp_path) -> None: def test_panel_build_version_matches_release() -> None: - assert INTEGRATION_VERSION == "0.5.1" + assert INTEGRATION_VERSION == "0.6.0" def test_panel_does_not_bundle_home_assistant_component_implementations() -> None: diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 1d62f83..09b3ba1 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -18,8 +18,8 @@ async def health(_request): return web.json_response( { "status": "ok", - "version": "0.5.0", - "apiVersion": 1, + "version": "0.6.0", + "apiVersion": 2, "trmnlFrameworkVersion": "3.2.0", } ) @@ -30,6 +30,7 @@ async def render(request): "html": "
test
", "width": 800, "height": 480, + "allowedAssetOrigins": ["https://cdn.example.com"], } return web.Response( body=PNG, @@ -50,12 +51,15 @@ async def render(request): client = RendererClient(session, str(server.make_url("/")), "secret") assert await client.async_health() == { "status": "ok", - "version": "0.5.0", - "apiVersion": 1, + "version": "0.6.0", + "apiVersion": 2, "trmnlFrameworkVersion": "3.2.0", } result = await client.async_render( - html="
test
", width=800, height=480 + html="
test
", + width=800, + height=480, + allowed_asset_origins=("https://cdn.example.com",), ) assert result.png == PNG assert result.timings == { @@ -93,6 +97,7 @@ async def incompatible_health(_request): [ ("0.4.9", "3.2.0"), ("0.5.0-dev", "3.2.0"), + ("0.5.1", "3.2.0"), ("0.5.0", "3.1.0"), ], ) @@ -109,7 +114,7 @@ async def incompatible_health(_request): { "status": "ok", "version": version, - "apiVersion": 1, + "apiVersion": 2, "trmnlFrameworkVersion": framework_version, } ) diff --git a/tests/test_trmnl_liquid.py b/tests/test_trmnl_liquid.py index 6bc9430..f8dbedb 100644 --- a/tests/test_trmnl_liquid.py +++ b/tests/test_trmnl_liquid.py @@ -51,6 +51,14 @@ def test_every_bundled_widget_template_compiles(widget_type: str) -> None: Environment().from_string(DEFAULT_REGISTRY.template(widget_type)) +def test_weather_template_has_no_remote_assets() -> None: + template = DEFAULT_REGISTRY.template("weather") + + assert "http://" not in template + assert "https://" not in template + assert "mdi-weather" in template + + @pytest.mark.parametrize("widget_type", sorted(DEFAULT_REGISTRY.widget_types)) def test_every_bundled_widget_renders_with_missing_provider_data( widget_type: str, @@ -62,6 +70,7 @@ def test_every_bundled_widget_renders_with_missing_provider_data( DEFAULT_REGISTRY.template(widget_type), config=definition["defaults"], data=data, + assets=DEFAULT_REGISTRY.assets(widget_type), region={"shape": "square"}, ) diff --git a/tests/test_websocket.py b/tests/test_websocket.py index be1b11d..25fb58d 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -63,6 +63,7 @@ async def test_preview_uses_renderer_and_returns_cached_png(hass) -> None: html="
same-html
", width=800, height=480, + allowed_asset_origins=(), ) connection.send_error.assert_not_called() result = connection.send_result.call_args.args[1] diff --git a/tests/test_widget_providers.py b/tests/test_widget_providers.py index 85ed605..3ccc52d 100644 --- a/tests/test_widget_providers.py +++ b/tests/test_widget_providers.py @@ -141,7 +141,7 @@ async def test_weather_provider_combines_entity_state_and_daily_forecast(hass) - assert forecast["datetime"] == "2026-08-26T12:00:00+02:00" assert forecast["condition"] == "rainy" assert forecast["condition_label"] == "Rainy" - assert forecast["icon"] == ("https://trmnl.com/images/plugins/weather/wi-rain.svg") + assert forecast["icon"] == "mdi-weather-rainy" assert forecast["temperature"] == 20 assert forecast["templow"] == 13 assert forecast["uv_index"] == 3 diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 262605b..2bdd83e 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -57,18 +57,34 @@ def values(self, resolved, sources, config, requirement): """ -def _write_quote_package(root: Path, *, version: str = "0.5.0") -> Path: +def _write_quote_package( + root: Path, + *, + version: str = "0.5.0", + allowed_origins: tuple[str, ...] = (), + remote_asset: str | None = None, +) -> Path: """Create a complete external widget package for discovery tests.""" package = root / "quote" package.mkdir(parents=True, exist_ok=True) - (package / "widget.yml").write_text( - QUOTE_MANIFEST.format(version=version), encoding="utf-8" - ) + manifest = QUOTE_MANIFEST.format(version=version) + if allowed_origins: + manifest += "permissions:\n network:\n allowedOrigins:\n" + manifest += "".join(f" - {origin}\n" for origin in allowed_origins) + (package / "widget.yml").write_text(manifest, encoding="utf-8") + asset_source = remote_asset or "{{ assets['icons/quote.svg'] }}" (package / "widget.liquid").write_text( - '
{{ data.quote.message }}
', + f'
' + "{{ data.quote.message }}
", encoding="utf-8", ) (package / "provider.py").write_text(QUOTE_PROVIDER, encoding="utf-8") + assets = package / "assets" / "icons" + assets.mkdir(parents=True, exist_ok=True) + (assets / "quote.svg").write_text( + '', + encoding="utf-8", + ) return package @@ -112,6 +128,75 @@ async def test_unknown_package_is_discovered_validated_and_composed( assert registry.definition("quote")["name"] == "Quote" assert "Hello community" in result.html + assert 'src="data:image/svg+xml;base64,' in result.html + assert registry.assets("quote")["icons/quote.svg"].startswith( + "data:image/svg+xml;base64," + ) + assert result.allowed_asset_origins == () + + +async def test_declared_remote_widget_asset_is_passed_to_renderer( + hass, tmp_path: Path +) -> None: + _write_quote_package( + tmp_path, + allowed_origins=("https://cdn.example.com/",), + remote_asset="https://cdn.example.com/quote.svg", + ) + registry = WidgetRegistry.from_directories([tmp_path]) + project = validate_project( + { + "name": "Remote widget", + "status": "draft", + "language": "en", + "displayId": "custom", + "width": 400, + "height": 300, + "orientation": "landscape", + "palette": "bw", + "grid": {"columns": 1, "rows": 1}, + "regions": [ + { + "id": "quote", + "row": 1, + "column": 1, + "rowSpan": 1, + "columnSpan": 1, + "widget": { + "type": "quote", + "version": "0.5.0", + "config": {"source": "community"}, + }, + } + ], + }, + registry, + ) + hass.data[DOMAIN] = SimpleNamespace(widgets=registry) + + result = await async_compose_project(hass, project) + + assert registry.definition("quote")["permissions"] == { + "network": {"allowedOrigins": ["https://cdn.example.com"]} + } + assert result.allowed_asset_origins == ("https://cdn.example.com",) + + +@pytest.mark.parametrize( + "origin", + [ + "https://example.com/path", + "https://user:pass@example.com", + "file:///tmp/assets", + ], +) +def test_invalid_widget_network_permissions_are_rejected( + tmp_path: Path, origin: str +) -> None: + _write_quote_package(tmp_path, allowed_origins=(origin,)) + + with pytest.raises(WidgetPackageError, match="network permission"): + WidgetRegistry.from_directories([tmp_path]) def test_registry_can_reload_updated_packages_without_replacing_consumers( @@ -148,3 +233,28 @@ def test_package_files_cannot_escape_the_widget_directory(tmp_path: Path) -> Non with pytest.raises(WidgetPackageError, match="package"): WidgetRegistry.from_directories([tmp_path]) + + +def test_unsupported_widget_assets_are_rejected(tmp_path: Path) -> None: + package = _write_quote_package(tmp_path) + (package / "assets" / "payload.py").write_text("unsafe", encoding="utf-8") + + with pytest.raises(WidgetPackageError, match="Unsupported widget asset type"): + WidgetRegistry.from_directories([tmp_path]) + + +def test_oversized_widget_assets_are_rejected(tmp_path: Path) -> None: + package = _write_quote_package(tmp_path) + (package / "assets" / "large.png").write_bytes(b"0" * 512_001) + + with pytest.raises(WidgetPackageError, match="exceeds 512000 bytes"): + WidgetRegistry.from_directories([tmp_path]) + + +def test_widget_asset_total_is_bounded(tmp_path: Path) -> None: + package = _write_quote_package(tmp_path) + (package / "assets" / "first.png").write_bytes(b"0" * 300_000) + (package / "assets" / "second.png").write_bytes(b"0" * 300_000) + + with pytest.raises(WidgetPackageError, match="assets exceed 512000 bytes"): + WidgetRegistry.from_directories([tmp_path])