Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

## 0.6.0

- Resolve package-owned files from `widgets/<id>/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,
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions STAGE2_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
48 changes: 41 additions & 7 deletions WIDGET_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<img src="{{ assets['icons/logo.svg'] }}" alt="">
```

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
Expand Down Expand Up @@ -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 `<span class="mdi mdi-weather-rainy"></span>`.
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.
83 changes: 79 additions & 4 deletions custom_components/opendisplay_studio/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"<link\b[^>]*\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"<object\b[^>]*\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 = """
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -271,4 +340,10 @@ async def async_compose_project(
f"{body}</div></div></main>"
)
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)),
)
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions custom_components/opendisplay_studio/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion custom_components/opendisplay_studio/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
1 change: 1 addition & 0 deletions custom_components/opendisplay_studio/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions custom_components/opendisplay_studio/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions custom_components/opendisplay_studio/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading