Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
57401b7
Add in-browser WASM live preview for interpreted scenes
mariusandra Jul 7, 2026
6cabc66
Update frontend visual snapshots
Jul 7, 2026
6f8bb79
Preview modal: terminal-styled logs, rename to "In-browser preview"
mariusandra Jul 7, 2026
7bcbb41
Update frontend visual snapshots
Jul 7, 2026
bd98063
Live-preview proxy: honor the app's request timeout
mariusandra Jul 7, 2026
6d61a55
Fix CI: wasm build needs codegen in Docker; update guarded-registry test
mariusandra Jul 7, 2026
39926a8
Chat panel: remove "New chat" button, keep Clear
mariusandra Jul 7, 2026
eed3da3
Scene toolbar: clearer icons, reorder settings/chat buttons
mariusandra Jul 7, 2026
e33fa05
no label
mariusandra Jul 7, 2026
954076a
Homepage scenes: add multiselect + bulk delete
mariusandra Jul 7, 2026
dea124d
Update frontend visual snapshots
Jul 7, 2026
d6d37e8
Split scenes: precise box sizes, split/swap/remove panels
mariusandra Jul 7, 2026
630b053
Live preview: send form field values as scene input
mariusandra Jul 7, 2026
ae6c098
Templates: preview scenes in browser or on frame; taller live preview…
mariusandra Jul 7, 2026
6c17945
Live preview: persist open state in URL hash across reloads
mariusandra Jul 7, 2026
c7dda65
Live preview modal: plain canvas, parsed log lines, editable scene state
mariusandra Jul 7, 2026
b87fce1
Live preview: no scrim behind the loading text
mariusandra Jul 7, 2026
26d17e8
Live preview: press the frame's GPIO buttons in the browser
mariusandra Jul 7, 2026
bddeef7
Update frontend visual snapshots
Jul 7, 2026
b40283d
Scenes: standardize scene actions into one split-button with a dropdown
mariusandra Jul 7, 2026
69936c9
Live preview: fix modal closing on any click; drop generic "button" e…
mariusandra Jul 7, 2026
7c55028
Scene actions: dropdown selection only arms the button; rename modal …
mariusandra Jul 7, 2026
74db4fa
WASM preview: log render:scene / render:done like the device runner
mariusandra Jul 7, 2026
4e94530
Browser preview: warn when the scene uses apps missing from the wasm …
mariusandra Jul 7, 2026
727e70e
Browser preview: preview-on-frame button, click image for full view,
mariusandra Jul 7, 2026
0983b31
Browser preview: preview-on-frame button lives in the controls row
mariusandra Jul 7, 2026
87804a1
Merge branch 'wasm' of github.com:FrameOS/frameos into wasm
mariusandra Jul 7, 2026
3ac4b72
Events panel: drag custom events onto the scene as listen/dispatch nodes
mariusandra Jul 8, 2026
50d3a38
Update frontend visual snapshots
Jul 8, 2026
e5e4c57
Scene updates: track installed scenes' repo origin and offer one-clic…
mariusandra Jul 8, 2026
6f26350
Adding scenes to a frame no longer saves automatically
mariusandra Jul 8, 2026
95a239a
Merge branch 'wasm' of github.com:FrameOS/frameos into wasm
mariusandra Jul 8, 2026
f9482cf
Template preview button opens the browser preview directly
mariusandra Jul 8, 2026
7d3f586
Browser preview: add-to-frame button, actions next to Edit, state row…
mariusandra Jul 8, 2026
8ec7922
Share frames with Home Assistant + ship release notes to the add-on c…
mariusandra Jul 8, 2026
3b9a13e
Fix e2e failures: no controlLogic on frames home, repo template thumb…
mariusandra Jul 8, 2026
8a49082
Update frontend visual snapshots
Jul 8, 2026
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
84 changes: 76 additions & 8 deletions .github/workflows/docker-publish-multi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,37 @@ jobs:
echo "docker_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT"

release-notes:
name: Generate Release Notes
needs: update-versions
runs-on: ubuntu-latest
steps:
- name: Checkout release commit
uses: actions/checkout@v4
with:
ref: ${{ needs.update-versions.outputs.release_sha }}
fetch-depth: 0

- name: Generate release notes
env:
DOCKER_VERSION: ${{ needs.update-versions.outputs.docker_version }}
RELEASE_TAG: ${{ needs.update-versions.outputs.release_tag }}
RELEASE_SHA: ${{ needs.update-versions.outputs.release_sha }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python3 tools/generate_release_notes.py \
--version "$DOCKER_VERSION" \
--release-tag "$RELEASE_TAG" \
--head "$RELEASE_SHA" \
--output release-notes.md

- name: Upload release notes
uses: actions/upload-artifact@v4
with:
name: release-notes-${{ needs.update-versions.outputs.docker_version }}
path: release-notes.md
if-no-files-found: error

determine-release-targets:
needs: update-versions
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -337,7 +368,7 @@ jobs:

update-addon-repo:
name: Update Home Assistant Addon
needs: [update-versions, push-multiarch]
needs: [update-versions, push-multiarch, release-notes]
runs-on: ubuntu-latest
steps:
- name: Checkout frameos
Expand All @@ -354,6 +385,11 @@ jobs:
ref: main
path: home-assistant-addon

- name: Download release notes
uses: actions/download-artifact@v4
with:
name: release-notes-${{ needs.update-versions.outputs.docker_version }}

- name: Read Docker version
shell: bash
run: |
Expand All @@ -366,6 +402,39 @@ jobs:
echo "Updating version in config.yaml to ${{ env.DOCKER_VERSION }}"
sed -i "s/^version: .*/version: ${{ env.DOCKER_VERSION }}/" config.yaml

- name: Prepend release notes to the addon changelog
run: |
python3 - <<'EOF'
import datetime
import os
import re
from pathlib import Path

version = os.environ["DOCKER_VERSION"]
date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")

notes = Path("release-notes.md").read_text()
# Keep version headers at the top level: demote "## New features" -> "###"
notes = re.sub(r"^(##+)", r"#\1", notes, flags=re.M)
# The "Docker images" block is appended later for the GitHub release only
notes = re.sub(r"\n?### Docker images\n(?:.*\n?)*?(?=\n### |\Z)", "", notes).strip()
section = f"## {version} ({date})\n\n{notes}\n"

path = Path("home-assistant-addon/frameos/CHANGELOG.md")
header = "# Changelog\n\n"
changelog = path.read_text() if path.exists() else header
if f"\n## {version} " in changelog:
print(f"Changelog already contains {version}, skipping")
else:
first_entry = changelog.find("\n## ")
if first_entry == -1:
changelog = changelog.rstrip() + "\n\n" + section
else:
changelog = changelog[: first_entry + 1] + section + "\n" + changelog[first_entry + 1 :]
path.write_text(changelog)
print(f"Added changelog entry for {version}")
EOF

- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
Expand All @@ -378,7 +447,7 @@ jobs:

github-release:
name: Create GitHub Release
needs: [update-versions, push-multiarch, build-prebuilt-cross, build-buildroot-release-image, update-addon-repo]
needs: [update-versions, push-multiarch, build-prebuilt-cross, build-buildroot-release-image, update-addon-repo, release-notes]
runs-on: ubuntu-latest
steps:
- name: Checkout release commit
Expand All @@ -394,20 +463,19 @@ jobs:
path: release-assets
merge-multiple: true

- name: Download release notes
uses: actions/download-artifact@v4
with:
name: release-notes-${{ needs.update-versions.outputs.docker_version }}

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
DOCKER_VERSION: ${{ needs.update-versions.outputs.docker_version }}
RELEASE_TAG: ${{ needs.update-versions.outputs.release_tag }}
RELEASE_SHA: ${{ needs.update-versions.outputs.release_sha }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
ls -lh release-assets
python3 tools/generate_release_notes.py \
--version "$DOCKER_VERSION" \
--release-tag "$RELEASE_TAG" \
--head "$RELEASE_SHA" \
--output release-notes.md

cat >> release-notes.md <<EOF

Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,7 @@ nimcache/
e2e/scenes/*.so
*.rdb
tmp/
release-assets/
release-assets/
# wasm live-preview bundle (built by frameos/tools/build_wasm.sh)
frontend/public/frameos-wasm/
frameos/build/wasm/
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ FROM nim-toolchain AS app-builder
ARG FRAMEOS_ARCHIVE_BASE_URL=https://archive.frameos.net
ARG QUICKJS_VERSION=2026-06-04
ARG QUICKJS_SHA256=b376e839b322978313d929fd20663b11ba58b75df5a46c126dd19ea2fa70ad2a
# emscripten, for the wasm live-preview bundle served by the frontend
ARG EMSCRIPTEN_VERSION=6.0.2

ENV DEBIAN_FRONTEND=noninteractive

Expand Down Expand Up @@ -184,6 +186,18 @@ RUN set -eux; \
/app/frameos/quickjs/qjs -e 'console.log("quickjs ok")'; \
rm -rf /tmp/quickjs-source /tmp/quickjs-source.tar.xz

# Interpreted-scene runtime compiled to WebAssembly for the frontend's live
# preview modal; lands in frontend/public/frameos-wasm so the frontend build
# below copies it into dist. build_wasm.sh runs makeapploaders.py, which loads
# the Nim codegen from backend/app/codegen (self-contained, no backend
# package imports), so copy just that directory into this stage.
COPY backend/app/codegen /app/backend/app/codegen
RUN set -eux; \
git clone --depth 1 https://github.com/emscripten-core/emsdk.git /opt/emsdk; \
/opt/emsdk/emsdk install "${EMSCRIPTEN_VERSION}"; \
/opt/emsdk/emsdk activate "${EMSCRIPTEN_VERSION}"
RUN bash -c 'source /opt/emsdk/emsdk_env.sh && bash /app/frameos/tools/build_wasm.sh'

WORKDIR /app/frontend
RUN pnpm run build

Expand Down
113 changes: 113 additions & 0 deletions backend/app/api/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# stdlib ---------------------------------------------------------------------
import asyncio
import base64
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
import contextlib
Expand All @@ -10,11 +11,13 @@
import gzip
import hashlib
import io
import ipaddress
import json
import mimetypes
import os
import re
import shlex
import socket
import shutil
import sys
import tempfile
Expand Down Expand Up @@ -47,6 +50,7 @@
from app.models.frame import (
Frame,
compact_timezone_updater,
get_frame_json,
new_frame,
delete_frame,
normalize_error_behavior,
Expand Down Expand Up @@ -1790,6 +1794,115 @@ async def api_frame_get(
return {"frame": data}


@api_project.get("/frames/{id:int}/scene_preview_settings")
async def api_frame_scene_preview_settings(
id: int,
db: Session = Depends(get_db),
):
"""Settings (incl. app API keys) the in-browser wasm live preview needs to
run this frame's scenes, assembled exactly like the config the device
receives (get_frame_json). Project-authenticated: only returns secrets to a
caller already authorized to view/edit them in the settings UI."""
frame = _project_frame(db, id)
if frame is None:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
frame_json = get_frame_json(db, frame)
return {"settings": frame_json.get("settings") or {}}


def _preview_proxy_host_is_blocked(host: str) -> bool:
"""Best-effort SSRF guard for the live-preview HTTP proxy: reject hosts that
resolve to loopback / private / link-local / reserved addresses so an
authenticated project user can't turn the backend into an internal-network
probe. DNS is resolved once here; a rebind between this check and the fetch
is a residual risk accepted for a project-authenticated preview feature."""
if not host:
return True
try:
infos = socket.getaddrinfo(host, None)
except OSError:
return True
for info in infos:
addr = info[4][0]
try:
ip = ipaddress.ip_address(addr.split("%", 1)[0])
except ValueError:
return True
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
):
return True
return False


@api_project.post("/frames/{id:int}/scene_preview_proxy")
async def api_frame_scene_preview_proxy(
id: int,
request: Request,
db: Session = Depends(get_db),
):
"""Server-side HTTP fetch for the in-browser wasm live preview. Data apps
(image galleries, weather, ...) fetch external URLs that the browser can't
reach directly because of CORS; the preview routes those requests here so
they behave like the device, which fetches server-side. Project-authed and
SSRF-guarded. Request body: {method, url, headers, bodyBase64, timeoutMs}.
The response mirrors the upstream status code and body bytes."""
try:
envelope = await request.json()
except Exception:
_bad_request("Invalid proxy request body")

frame = _project_frame(db, id)
if frame is None:
_not_found()

url = (envelope.get("url") or "").strip()
method = (envelope.get("method") or "GET").upper()
req_headers = envelope.get("headers") or {}
body_b64 = envelope.get("bodyBase64") or ""

# Honor the app's requested timeout (e.g. openaiImage asks for 300s — DALL-E
# is slow), clamped so one preview can't tie up the backend indefinitely.
try:
timeout_ms = int(envelope.get("timeoutMs") or 0)
except (TypeError, ValueError):
timeout_ms = 0
timeout_seconds = 30.0 if timeout_ms <= 0 else min(max(timeout_ms / 1000.0, 5.0), 300.0)

parsed = httpx.URL(url) if url else None
if parsed is None or parsed.scheme not in ("http", "https") or not parsed.host:
_bad_request(f"Invalid proxy URL: {url}")
if _preview_proxy_host_is_blocked(parsed.host):
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Proxy target host is not allowed")

body_bytes = base64.b64decode(body_b64) if body_b64 else None
# Drop hop-by-hop / host-scoped headers; forward app-supplied ones (API keys etc.).
forward_headers = {
k: v
for k, v in req_headers.items()
if isinstance(k, str) and k.lower() not in ("host", "content-length", "connection")
}

# Short connect timeout, long read timeout for slow APIs (image generation).
timeout = httpx.Timeout(timeout_seconds, connect=min(timeout_seconds, 15.0))
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
upstream = await client.request(method, url, headers=forward_headers, content=body_bytes)
except httpx.HTTPError as exc:
# Timeout exceptions often stringify empty; include the type so the
# failure is diagnosable in the preview's runtime log.
detail = str(exc).strip() or type(exc).__name__
raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"Proxy fetch failed: {detail}")

media_type = upstream.headers.get("content-type", "application/octet-stream")
return Response(content=upstream.content, status_code=upstream.status_code, media_type=media_type)


@api_project.get("/frames/{id:int}/sync")
async def api_frame_sync_status(
id: int,
Expand Down
28 changes: 28 additions & 0 deletions backend/app/api/repositories.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import logging
import asyncio
import hashlib
import json
from functools import lru_cache
from datetime import datetime, timedelta
from http import HTTPStatus
from pathlib import Path
Expand Down Expand Up @@ -37,6 +39,15 @@ def _system_template_subject(repository_slug: str, template_slug: str) -> str:
return f"system-template={repository_slug}/{template_slug}"


@lru_cache(maxsize=256)
def _scenes_file_version(path: str, mtime_ns: int, size: int) -> str:
digest = hashlib.sha256()
with open(path, "rb") as scenes_file:
for chunk in iter(lambda: scenes_file.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()[:12]


def _load_template_definition(repository_slug: str, template_dir: Path):
template_path = template_dir / "template.json"
if not template_path.is_file():
Expand All @@ -45,10 +56,17 @@ def _load_template_definition(repository_slug: str, template_dir: Path):
with template_path.open("r", encoding="utf-8") as template_file:
template_data = json.load(template_file)

template_data.setdefault("id", template_dir.name)

image_path = template_data.get("image")
if image_path:
template_data["image"] = f"/api/repositories/system/{repository_slug}/templates/{template_dir.name}/image"

# Installed scenes track the template version they came from, so clients
# can offer updates. An explicit "version" in template.json wins; otherwise
# the version is a content hash of the scenes, so any change counts.
version = template_data.get("version")

# Scenes can be large (embedded app sources), so the listing only carries
# a URL; clients fetch the scenes on install or when otherwise needed.
scenes_reference = template_data.pop("scenes", None)
Expand All @@ -58,8 +76,18 @@ def _load_template_definition(repository_slug: str, template_dir: Path):
template_data["scenesUrl"] = (
f"/api/repositories/system/{repository_slug}/templates/{template_dir.name}/scenes.json"
)
if not version:
scenes_stat = scenes_path.stat()
version = _scenes_file_version(str(scenes_path), scenes_stat.st_mtime_ns, scenes_stat.st_size)
elif isinstance(scenes_reference, list):
template_data["scenes"] = scenes_reference
if not version:
version = hashlib.sha256(
json.dumps(scenes_reference, sort_keys=True).encode("utf-8")
).hexdigest()[:12]

if version:
template_data["version"] = str(version)

return template_data

Expand Down
Loading