diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 6682a02c7..57ee48e6c 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -25,6 +25,7 @@ from .chats import * # noqa: E402, F403 from .cloud import * # noqa: E402, F403 from .cloud_backups import * # noqa: E402, F403 +from .cloud_store import * # noqa: E402, F403 from .embedded_device import * # noqa: E402, F403 from .frame_bootstrap import * # noqa: E402, F403 from .frames import * # noqa: E402, F403 diff --git a/backend/app/api/cloud_store.py b/backend/app/api/cloud_store.py new file mode 100644 index 000000000..8f92e54d6 --- /dev/null +++ b/backend/app/api/cloud_store.py @@ -0,0 +1,169 @@ +"""Publish scenes to the FrameOS Cloud store (STORE-TODO Phases 2-3). + +The payload is the same template interchange zip the backups use; the cloud +keeps immutable versions per scene. Publishing needs the ``store:publish`` +scope on the cloud link. Browsing the public store needs nothing at all — +it is a plain scenes repository at ``{provider}/api/store/repository.json`` +(auto-added per project in app/api/repositories.py). + +"My cloud drive" is the account's own scenes, private ones included. The +browser cannot attach the link token to tags, so this module proxies +the drive listing and preview images; zips install through the normal +``POST /api/templates {url}`` flow, which attaches the link token for +provider URLs (see cloud_headers_for_url). +""" +from __future__ import annotations + +import base64 +import io +from http import HTTPStatus + +from arq import ArqRedis as Redis +from fastapi import Depends, HTTPException +from fastapi.responses import Response +from PIL import Image +from sqlalchemy.orm import Session + +from app.api.auth import get_current_user +from app.api.cloud_backups import _require_linked, _require_user +from app.api.templates import template_zip_bytes +from app.database import get_db +from app.models.frame import Frame +from app.models.template import Template +from app.models.user import User +from app.redis import get_redis +from app.schemas.cloud import CloudStatusResponse, CloudStorePublishRequest +from app.tenancy import get_user_project +from app.utils import cloud_link + +from . import api_user + + +async def _template_from_request( + data: CloudStorePublishRequest, db: Session, redis: Redis, user: User +) -> Template: + """The template to publish: an existing one, or a transient one built + from inline scenes ("Save to cloud drive" straight off a frame).""" + if data.template_id: + template = db.query(Template).filter_by(id=data.template_id).first() + if template is None or get_user_project(db, user, template.project_id) is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Template not found") + return template + + if not data.name or not data.scenes: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Provide template_id, or name and scenes", + ) + + template = Template(name=data.name, description=data.description, scenes=data.scenes, config={}) + + if data.from_frame_id: + frame = db.query(Frame).filter_by(id=data.from_frame_id).first() + if frame is None or get_user_project(db, user, frame.project_id) is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found") + template.project_id = frame.project_id + image = None + if data.image_scene_id: + from app.models.scene_image import SceneImage + + scene_image = ( + db.query(SceneImage) + .filter_by(project_id=frame.project_id, frame_id=frame.id, scene_id=data.image_scene_id) + .first() + ) + if scene_image: + image = scene_image.image + if not image: + image = await redis.get(f"frame:{frame.id}:image") + if image: + try: + img_obj = Image.open(io.BytesIO(image)) + template.image = image + template.image_width = img_obj.width + template.image_height = img_obj.height + except Exception: # noqa: BLE001 — a broken snapshot just means no preview + pass + + return template + + +@api_user.post("/cloud/store/publish", response_model=CloudStatusResponse) +async def publish_template_to_store( + data: CloudStorePublishRequest, + db: Session = Depends(get_db), + redis: Redis = Depends(get_redis), + current_user: User | None = Depends(get_current_user), +): + user = _require_user(current_user) + link, access_token = _require_linked(db, "store:publish") + template = await _template_from_request(data, db, redis, user) + + payload: dict = { + "name": template.name, + "content_base64": base64.b64encode(template_zip_bytes(template)).decode(), + "content_type": "application/zip", + } + if template.description: + payload["description"] = template.description + # Omitted visibility means: private on first publish, unchanged afterwards. + if data.visibility in ("private", "public"): + payload["visibility"] = data.visibility + + try: + status_code, response = await cloud_link.store_publish(link.provider_url, access_token, payload) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + detail = response.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}") + return {"status": "published", "scene": response.get("scene")} + + +@api_user.get("/cloud/store/drive", response_model=CloudStatusResponse) +async def cloud_store_drive( + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + _require_user(current_user) + link, access_token = _require_linked(db, "store:publish") + try: + status_code, payload = await cloud_link.store_drive(link.provider_url, access_token) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + detail = payload.get("error") or f"unexpected status {status_code}" + raise HTTPException(status_code=HTTPStatus.BAD_GATEWAY, detail=f"FrameOS Cloud error: {detail}") + + # Preview images of private scenes need the link token, which the browser + # does not have — point them at our authenticated proxy below instead. + for template in payload.get("templates", []): + scene_id = template.get("sceneId") + if scene_id and template.get("image"): + template["image"] = f"/api/cloud/store/drive/image/{scene_id}" + return payload + + +@api_user.get("/cloud/store/drive/image/{scene_id}") +async def cloud_store_drive_image( + scene_id: str, + db: Session = Depends(get_db), + current_user: User | None = Depends(get_current_user), +): + _require_user(current_user) + link, access_token = _require_linked(db, "store:publish") + try: + status_code, content_type, content = await cloud_link.cloud_get_binary( + link.provider_url, f"/api/store/scenes/{scene_id}/image", access_token + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException( + status_code=HTTPStatus.BAD_GATEWAY, detail=f"Could not reach {link.provider_url}: {exc}" + ) from exc + if status_code != 200: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Image not found") + return Response(content, media_type=content_type, headers={"cache-control": "private, max-age=300"}) diff --git a/backend/app/api/repositories.py b/backend/app/api/repositories.py index 21b888f7b..7b4a84806 100644 --- a/backend/app/api/repositories.py +++ b/backend/app/api/repositories.py @@ -31,6 +31,7 @@ FRAMEOS_SAMPLES_URL = "https://repo.frameos.net/samples/repository.json" FRAMEOS_GALLERY_URL = "https://repo.frameos.net/gallery/repository.json" +CLOUD_STORE_REPOSITORY_MARKER = "@system/cloud_store_repository_added" SYSTEM_REPOSITORIES_PATH = Path(__file__).resolve().parents[3] / "repo" / "scenes" @@ -284,6 +285,53 @@ async def get_repositories(db: Session = Depends(get_db)): db.add(Settings(project_id=project_id, key="@system/repository_global_cleanup", value="true")) db.commit() + # Seed the connected provider's store once per provider. The marker + # records its URL so changing providers replaces the old store, while + # deleting the repository remains respected until the provider changes. + from app.models.cloud import current_cloud_backend_link + + link = current_cloud_backend_link(db) + if link is not None and link.status == "connected" and link.provider_url: + store_url = link.provider_url.rstrip("/") + "/api/store/repository.json" + marker = db.query(Settings).filter_by( + project_id=project_id, key=CLOUD_STORE_REPOSITORY_MARKER + ).first() + marker_url = marker.value if marker is not None and isinstance(marker.value, str) else None + should_seed = marker is None or (marker_url is not None and marker_url != store_url) + + if marker is not None and marker_url in (None, "true"): + # Migrate the old boolean marker. An absent repository is + # ambiguous and may have been explicitly deleted, so preserve + # that choice. An old provider repository makes a URL change + # observable and safe to migrate. + legacy_repositories = db.query(Repository).filter( + Repository.project_id == project_id, + Repository.url.like("%/api/store/repository.json"), + ).all() + should_seed = bool(legacy_repositories) and all(repo.url != store_url for repo in legacy_repositories) + for repository in legacy_repositories: + if repository.url != store_url: + db.delete(repository) + marker.value = store_url + elif marker is not None and marker_url != store_url: + old_repository = db.query(Repository).filter_by(project_id=project_id, url=marker_url).first() + if old_repository is not None: + db.delete(old_repository) + marker.value = store_url + + if marker is None: + marker = Settings(project_id=project_id, key=CLOUD_STORE_REPOSITORY_MARKER, value=store_url) + db.add(marker) + + if should_seed and not db.query(Repository).filter_by(project_id=project_id, url=store_url).first(): + store_repository = Repository(project_id=project_id, name="FrameOS Cloud store", url=store_url) + try: + await store_repository.update_templates() + except Exception: # noqa: BLE001 — provider may be unreachable; seed anyway + pass + db.add(store_repository) + db.commit() + repositories = project_query(db, Repository).all() for r in repositories: diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index 76cab7451..998a58eda 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -1,5 +1,7 @@ import base64 import io +import re +import urllib.parse import zipfile import json import string @@ -37,6 +39,29 @@ def safe_template_name(template: Template) -> str: return ' '.join(template_name.split()) or 'Template' +_FRAMEOS_ZIP_META_RE = re.compile( + r']*?(?:name=["\']frameos:zip["\'][^>]*?content=["\']([^"\']+)["\']' + r'|content=["\']([^"\']+)["\'][^>]*?name=["\']frameos:zip["\'])', + re.IGNORECASE, +) + + +def frameos_zip_url_from_html(content: bytes, page_url: str) -> str | None: + """The template zip URL a scene page advertises via + , resolved against the page URL.""" + try: + html = content[:262144].decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 + return None + match = _FRAMEOS_ZIP_META_RE.search(html) + if not match: + return None + zip_url = (match.group(1) or match.group(2) or "").strip().replace("&", "&") + if not zip_url: + return None + return urllib.parse.urljoin(page_url, zip_url) + + def template_zip_bytes(template: Template) -> bytes: """The template interchange zip: {name}/template.json + scenes.json + image.jpg. Also the payload format for cloud template backups (CLOUD-TODO Phase 3).""" @@ -133,6 +158,9 @@ async def create_template( name = name or parsed_json.get('name') description = description or parsed_json.get('description') from_frame_id = from_frame_id or parsed_json.get('from_frame_id') + # When saving specific scenes off a frame, the preview should be the + # snapshot of one of *those* scenes, not whatever the frame shows now. + image_scene_id = parsed_json.get('image_scene_id') # Scenes/config might come as JSON arrays or as strings if not scenes and parsed_json.get('scenes') is not None: @@ -157,11 +185,28 @@ async def create_template( file_bytes = await file.read() zip_file = zipfile.ZipFile(io.BytesIO(file_bytes)) elif url: - # If we have a URL, fetch it + # If we have a URL, fetch it. URLs on the linked cloud provider get + # the link token attached so private "My cloud drive" scenes install. + from app.utils.cloud_backup import cloud_headers_for_url + async with httpx.AsyncClient() as client: - resp = await client.get(url) - resp.raise_for_status() - zip_file = zipfile.ZipFile(io.BytesIO(resp.content)) + resp = await client.get(url, headers=cloud_headers_for_url(db, url)) + resp.raise_for_status() + content = resp.content + if not zipfile.is_zipfile(io.BytesIO(content)): + # Not a zip: maybe a scene page (e.g. a FrameOS Cloud store + # page) that advertises its zip in a + # tag — so pasting the page URL installs the scene. + zip_url = frameos_zip_url_from_html(content, url) + if not zip_url: + raise HTTPException( + status_code=422, + detail="URL is neither a template .zip nor a page with a frameos:zip meta tag", + ) + resp = await client.get(zip_url, headers=cloud_headers_for_url(db, zip_url)) + resp.raise_for_status() + content = resp.content + zip_file = zipfile.ZipFile(io.BytesIO(content)) data = { "from_frame_id": from_frame_id, @@ -207,8 +252,10 @@ async def create_template( image_path = img_val[len('./'):] img_val = zip_file.read(f'{folder_name}{image_path}') elif img_val.startswith('http:') or img_val.startswith('https:'): + from app.utils.cloud_backup import cloud_headers_for_url + async with httpx.AsyncClient() as client: - resp = await client.get(img_val) + resp = await client.get(img_val, headers=cloud_headers_for_url(db, img_val)) resp.raise_for_status() img_val = resp.content else: @@ -225,8 +272,20 @@ async def create_template( frame_id = data['from_frame_id'] frame = db.query(Frame).filter_by(project_id=project_id, id=frame_id).first() if frame: - cache_key = f'frame:{frame.id}:image' - last_image = await redis.get(cache_key) + last_image = None + if image_scene_id: + from app.models.scene_image import SceneImage + + scene_image = ( + db.query(SceneImage) + .filter_by(project_id=project_id, frame_id=frame.id, scene_id=image_scene_id) + .first() + ) + if scene_image: + last_image = scene_image.image + if not last_image: + cache_key = f'frame:{frame.id}:image' + last_image = await redis.get(cache_key) if last_image: try: img_obj = Image.open(io.BytesIO(last_image)) diff --git a/backend/app/api/tests/test_cloud_e2e.py b/backend/app/api/tests/test_cloud_e2e.py new file mode 100644 index 000000000..cbf5de1e6 --- /dev/null +++ b/backend/app/api/tests/test_cloud_e2e.py @@ -0,0 +1,190 @@ +"""End-to-end happy path against a real FrameOS Cloud dev server (Phase 0). + +Skipped unless a local provider is running and the env vars below are set. +The private frameos-cloud repo ships `scripts/e2e-frameos.sh`, which boots the +dev server, creates a verified account with a browser session, and runs this +file with: + + FRAMEOS_CLOUD_E2E_URL e.g. http://localhost:3000 + FRAMEOS_CLOUD_E2E_COOKIE the account's session cookie ("name=value") + FRAMEOS_CLOUD_E2E_EMAIL the account's email (for assertions) + +Everything here goes over real HTTP: the device-authorization link, grants +sync, the login handoff (Phase 1), and config backups (Phase 3). +""" +import os + +import httpx +import pytest + +from app.models.cloud import CloudIdentity +from app.models.frame import Frame + +E2E_URL = os.environ.get("FRAMEOS_CLOUD_E2E_URL") +E2E_COOKIE = os.environ.get("FRAMEOS_CLOUD_E2E_COOKIE") +E2E_EMAIL = os.environ.get("FRAMEOS_CLOUD_E2E_EMAIL") + +pytestmark = pytest.mark.skipif( + not E2E_URL or not E2E_COOKIE, + reason="Set FRAMEOS_CLOUD_E2E_URL and FRAMEOS_CLOUD_E2E_COOKIE (see frameos-cloud/scripts/e2e-frameos.sh)", +) + +# Connect with the bare link scopes; the included ("safe") features are then +# auto-granted through the in-place feature-change flow, and cloud login — a +# security feature — needs the owner's approval (both exercised below). +SCOPES = ["backend:link", "backend:read"] +INCLUDED_SCOPES = ["backup:scenes", "backup:frames", "store:publish"] +ALL_SCOPES = SCOPES + INCLUDED_SCOPES + ["auth:login"] + + +def cloud_client() -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=E2E_URL, + headers={"cookie": E2E_COOKIE, "origin": E2E_URL}, + timeout=30.0, + follow_redirects=False, + ) + + +@pytest.mark.asyncio +async def test_cloud_link_login_and_backups_happy_path(async_client, db): + # ---- Phase 0: link through the device authorization flow ---------------- + response = await async_client.post("/api/cloud/provider", json={"provider_url": E2E_URL}) + assert response.status_code == 200, response.text + + response = await async_client.post("/api/cloud/connect", json={"scopes": SCOPES}) + assert response.status_code == 200, response.text + connection = response.json()["connection"] + assert connection["user_code"] + + async with cloud_client() as cloud: + approve = await cloud.post("/api/device/authorize", json={"user_code": connection["user_code"]}) + assert approve.status_code == 200, approve.text + + response = await async_client.post("/api/cloud/poll") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "connected", data + assert sorted(data["link"]["scopes"]) == sorted(SCOPES) + if E2E_EMAIL: + assert data["link"]["account_email"] == E2E_EMAIL + + # ---- enabled features: included scopes are auto-granted ------------------ + # /api/cloud/features always sends the included feature scopes along; + # adding only safe scopes needs no approval on the provider. + response = await async_client.post("/api/cloud/features", json={"scopes": []}) + assert response.status_code == 200, response.text + data = response.json() + assert data.get("upgrade") is None, data + assert sorted(data["link"]["scopes"]) == sorted(SCOPES + INCLUDED_SCOPES) + + # ---- enabled features: cloud login needs the owner's approval ------------ + response = await async_client.post("/api/cloud/features", json={"scopes": ["auth:login"]}) + assert response.status_code == 200, response.text + upgrade = response.json().get("upgrade") + assert upgrade and upgrade["user_code"], response.text + + async with cloud_client() as cloud: + approve = await cloud.post("/api/device/authorize", json={"user_code": upgrade["user_code"]}) + assert approve.status_code == 200, approve.text + + response = await async_client.post("/api/cloud/poll") + assert response.status_code == 200 + data = response.json() + assert data.get("upgrade") is None + assert sorted(data["link"]["scopes"]) == sorted(ALL_SCOPES) + + # ---- Phase 1: login handoff over real HTTP ------------------------------- + response = await async_client.post("/api/cloud/identity/link", json={}) + assert response.status_code == 200, response.text + authorization_url = response.json()["authorization_url"] + assert authorization_url.startswith(E2E_URL) + + async with cloud_client() as cloud: + authorize = await cloud.get(authorization_url) + assert authorize.status_code in (302, 303, 307), authorize.text + callback_url = authorize.headers["location"] + assert callback_url.startswith("http://test/api/cloud/login/callback"), callback_url + assert "code=" in callback_url and "state=" in callback_url + + response = await async_client.get(callback_url[len("http://test"):]) + assert response.status_code == 303, response.text + assert response.headers["location"] == "/settings" + + identity = db.query(CloudIdentity).first() + assert identity is not None + if E2E_EMAIL: + assert identity.email == E2E_EMAIL + + status = (await async_client.get("/api/cloud/status")).json() + assert status["identity"] is not None + + # A second handoff now signs the linked user in (fresh session cookie). + response = await async_client.post("/api/cloud/login/start", json={"next": "/frames"}) + assert response.status_code == 200, response.text + async with cloud_client() as cloud: + authorize = await cloud.get(response.json()["authorization_url"]) + callback_url = authorize.headers["location"] + response = await async_client.get(callback_url[len("http://test"):]) + assert response.status_code == 303 + assert response.headers["location"] == "/frames" + assert "frameos_session" in response.headers.get("set-cookie", "") + + # ---- Phase 3: config backups over real HTTP ------------------------------ + frame = Frame( + project_id=async_client.project_id, + name="E2E frame", + frame_host="10.0.0.99", + ssh_pass="e2e-secret-pass", + status="ready", + scenes=[{"id": "scene-e2e", "nodes": []}], + ) + db.add(frame) + db.commit() + db.refresh(frame) + + # The scope alone is a permission, not the feature: uploads are refused + # until the local backup switches are turned on. + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 403, response.text + + response = await async_client.post("/api/cloud/backup-features", json={"scenes": True, "frames": True}) + assert response.status_code == 200, response.text + data = response.json() + assert data["backup_scenes_enabled"] is True + assert data["backup_frames_enabled"] is True + + response = await async_client.post("/api/cloud/backups/frames", json={"frame_id": frame.id}) + assert response.status_code == 200, response.text + + response = await async_client.get("/api/cloud/backups") + assert response.status_code == 200 + backups = response.json()["backups"] + match = next(b for b in backups if b["item_key"] == f"frame-{frame.id}") + assert match["kind"] == "frames" + + # The blob stored in the cloud is sanitized. + async with cloud_client() as cloud: + blob = await cloud.get( + f"/api/backends/backups/{match['id']}", + headers={"cookie": "", "origin": E2E_URL}, + ) + # (bearer-only endpoint; expect 401 without the link token — the local + # restore path below proves the content instead) + assert blob.status_code == 401 + + response = await async_client.post( + "/api/cloud/backups/restore", + json={"backup_id": match["id"], "project_id": async_client.project_id}, + ) + assert response.status_code == 200, response.text + restored_id = response.json()["id"] + restored = db.get(Frame, restored_id) + assert restored.name == "E2E frame" + assert restored.scenes == [{"id": "scene-e2e", "nodes": []}] + assert restored.ssh_pass is None # the secret never reached the cloud + + # ---- unlink --------------------------------------------------------------- + response = await async_client.post("/api/cloud/disconnect") + assert response.status_code == 200 + assert response.json()["status"] == "disconnected" diff --git a/backend/app/api/tests/test_cloud_store.py b/backend/app/api/tests/test_cloud_store.py new file mode 100644 index 000000000..81d7d0926 --- /dev/null +++ b/backend/app/api/tests/test_cloud_store.py @@ -0,0 +1,268 @@ +"""Store publishing (STORE-TODO Phase 2): publish templates to the cloud store.""" +import base64 +import io +import json +import zipfile + +import pytest + +from app.models.cloud import CloudBackendLink +from app.models.template import Template +from app.utils import cloud_link +from app.utils.versions import current_frameos_version + +PROVIDER = "https://cloud.frameos.net" + +STORE_SCOPES = "backend:link backend:read store:publish" + + +def make_connected_link(db, scope=STORE_SCOPES): + link = CloudBackendLink( + provider_url=PROVIDER, + status="connected", + access_token=cloud_link.encrypt_cloud_secret("link-token-secret"), + linked_client_id="lc-1", + scope=scope, + local_origin="http://test", + cloud_account_id="acc-1", + ) + db.add(link) + db.commit() + return link + + +def make_template(db, project_id): + template = Template( + project_id=project_id, + name="Sunrise Clock", + description="A calm sunrise clock", + scenes=[{"id": "scene-1", "nodes": []}], + config={}, + ) + db.add(template) + db.commit() + db.refresh(template) + return template + + +@pytest.fixture +def store_calls(monkeypatch): + calls = [] + response = { + "status": "published", + "scene": { + "id": "scene-uuid", + "slug": "sunrise-clock", + "url": f"{PROVIDER}/scenes/sunrise-clock", + "version": 1, + "visibility": "private", + }, + } + + async def store_publish(*args): + calls.append(args) + return 200, response + + monkeypatch.setattr(cloud_link, "store_publish", store_publish) + return calls + + +@pytest.mark.asyncio +async def test_publish_sends_template_zip(async_client, db, store_calls): + make_connected_link(db) + template = make_template(db, async_client.project_id) + + response = await async_client.post("/api/cloud/store/publish", json={"template_id": str(template.id)}) + assert response.status_code == 200, response.text + assert response.json()["scene"]["slug"] == "sunrise-clock" + + provider_url, token, payload = store_calls[0] + assert provider_url == PROVIDER + assert payload["name"] == "Sunrise Clock" + assert payload["description"] == "A calm sunrise clock" + assert payload["content_type"] == "application/zip" + assert "visibility" not in payload + + # The payload is the standard template interchange zip. + zip_bytes = base64.b64decode(payload["content_base64"]) + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + names = archive.namelist() + manifest_path = next(name for name in names if name.endswith("template.json")) + scenes_path = next(name for name in names if name.endswith("scenes.json")) + manifest = json.loads(archive.read(manifest_path)) + scenes = json.loads(archive.read(scenes_path)) + assert manifest["name"] == "Sunrise Clock" + # Zips are stamped with the FrameOS version they were exported with; the + # store surfaces it on scene listings. + assert manifest["frameosVersion"] == current_frameos_version() + assert scenes == [{"id": "scene-1", "nodes": []}] + + +@pytest.mark.asyncio +async def test_publish_passes_explicit_visibility(async_client, db, store_calls): + make_connected_link(db) + template = make_template(db, async_client.project_id) + + response = await async_client.post( + "/api/cloud/store/publish", json={"template_id": str(template.id), "visibility": "public"} + ) + assert response.status_code == 200 + assert store_calls[0][2]["visibility"] == "public" + + +@pytest.mark.asyncio +async def test_publish_requires_scope(async_client, db, store_calls): + make_connected_link(db, scope="backend:link backend:read") + template = make_template(db, async_client.project_id) + + response = await async_client.post("/api/cloud/store/publish", json={"template_id": str(template.id)}) + assert response.status_code == 403 + assert store_calls == [] + + +@pytest.mark.asyncio +async def test_publish_requires_link(async_client, db, store_calls): + template = make_template(db, async_client.project_id) + response = await async_client.post("/api/cloud/store/publish", json={"template_id": str(template.id)}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_publish_unknown_template_404s(async_client, db, store_calls): + make_connected_link(db) + response = await async_client.post( + "/api/cloud/store/publish", json={"template_id": "00000000-0000-0000-0000-000000000000"} + ) + assert response.status_code == 404 + + +def make_frame(db, project_id): + from app.models.frame import Frame + + frame = Frame( + project_id=project_id, + name="Kitchen frame", + frame_host="10.0.0.5", + ssh_user="pi", + status="ready", + scenes=[{"id": "scene-1", "nodes": []}, {"id": "scene-2", "nodes": []}], + ) + db.add(frame) + db.commit() + db.refresh(frame) + return frame + + +@pytest.mark.asyncio +async def test_publish_inline_scenes_from_frame(async_client, db, store_calls): + make_connected_link(db) + frame = make_frame(db, async_client.project_id) + + response = await async_client.post( + "/api/cloud/store/publish", + json={ + "name": "Straight off the frame", + "description": "One scene", + "scenes": [{"id": "scene-2", "nodes": []}], + "from_frame_id": frame.id, + "image_scene_id": "scene-2", + }, + ) + assert response.status_code == 200, response.text + + payload = store_calls[0][2] + assert payload["name"] == "Straight off the frame" + zip_bytes = base64.b64decode(payload["content_base64"]) + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + scenes_path = next(name for name in archive.namelist() if name.endswith("scenes.json")) + scenes = json.loads(archive.read(scenes_path)) + assert scenes == [{"id": "scene-2", "nodes": []}] + # Nothing is persisted locally: publishing to the cloud drive is not "save to my scenes". + from app.models.template import Template as TemplateModel + + assert db.query(TemplateModel).filter_by(name="Straight off the frame").count() == 0 + + +@pytest.mark.asyncio +async def test_publish_inline_requires_name_and_scenes(async_client, db, store_calls): + make_connected_link(db) + response = await async_client.post("/api/cloud/store/publish", json={"name": "No scenes"}) + assert response.status_code == 422 + assert store_calls == [] + + +@pytest.mark.asyncio +async def test_publish_inline_unknown_frame_404s(async_client, db, store_calls): + make_connected_link(db) + response = await async_client.post( + "/api/cloud/store/publish", + json={"name": "X", "scenes": [{"id": "s", "nodes": []}], "from_frame_id": 424242}, + ) + assert response.status_code == 404 + assert store_calls == [] + + +@pytest.mark.asyncio +async def test_drive_lists_scenes_and_proxies_images(async_client, db, monkeypatch): + make_connected_link(db) + + async def store_drive(provider_url, access_token): + assert provider_url == PROVIDER + assert access_token == "link-token-secret" + return 200, { + "name": "My cloud drive", + "templates": [ + { + "id": "sunrise-clock", + "name": "Sunrise Clock", + "sceneId": "11111111-1111-1111-1111-111111111111", + "image": f"{PROVIDER}/api/store/scenes/11111111-1111-1111-1111-111111111111/image", + "zip": f"{PROVIDER}/api/store/scenes/11111111-1111-1111-1111-111111111111/download", + "visibility": "private", + } + ], + } + + monkeypatch.setattr(cloud_link, "store_drive", store_drive) + response = await async_client.get("/api/cloud/store/drive") + assert response.status_code == 200, response.text + template = response.json()["templates"][0] + # Image URLs are rewritten to the authenticated backend proxy; zips keep + # their provider URL (POST /api/templates attaches the token for those). + assert template["image"] == "/api/cloud/store/drive/image/11111111-1111-1111-1111-111111111111" + assert template["zip"].startswith(PROVIDER) + + +@pytest.mark.asyncio +async def test_drive_requires_store_scope(async_client, db): + make_connected_link(db, scope="backend:link backend:read") + response = await async_client.get("/api/cloud/store/drive") + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_drive_image_proxy(async_client, db, monkeypatch): + make_connected_link(db) + + async def cloud_get_binary(provider_url, path, access_token): + assert path == "/api/store/scenes/abc/image" + assert access_token == "link-token-secret" + return 200, "image/jpeg", b"jpeg-bytes" + + monkeypatch.setattr(cloud_link, "cloud_get_binary", cloud_get_binary) + response = await async_client.get("/api/cloud/store/drive/image/abc") + assert response.status_code == 200 + assert response.headers["content-type"].startswith("image/jpeg") + assert response.content == b"jpeg-bytes" + + +def test_cloud_headers_only_for_provider_urls(db): + from app.utils.cloud_backup import cloud_headers_for_url + + make_connected_link(db) + assert cloud_headers_for_url(db, f"{PROVIDER}/api/store/scenes/x/download") == { + "authorization": "Bearer link-token-secret" + } + assert cloud_headers_for_url(db, "https://evil.example.com/api/store/scenes/x/download") == {} + assert cloud_headers_for_url(db, f"{PROVIDER}.evil.example.com/zip") == {} + assert cloud_headers_for_url(db, None) == {} diff --git a/backend/app/api/tests/test_repositories.py b/backend/app/api/tests/test_repositories.py index 6930acf8a..54d86625c 100644 --- a/backend/app/api/tests/test_repositories.py +++ b/backend/app/api/tests/test_repositories.py @@ -151,3 +151,102 @@ async def test_system_repository_image_allows_session_cookie_without_token(no_au response = await no_auth_client.get('/api/repositories/system/samples/templates/Calendar/image?t=-1') assert response.status_code == 200 assert response.content + + +@pytest.mark.asyncio +async def test_get_repositories_seeds_cloud_store_once(async_client, db, monkeypatch): + """A connected cloud link seeds once per provider; deletion is respected.""" + from app.models.cloud import CloudBackendLink + from app.models.settings import Settings + from app.utils import cloud_link as cloud_link_utils + + async def fake_update(self): + self.templates = [] + + monkeypatch.setattr(Repository, "update_templates", fake_update) + + link = CloudBackendLink( + provider_url="https://cloud.frameos.net", + status="connected", + access_token=cloud_link_utils.encrypt_cloud_secret("link-token"), + linked_client_id="lc-1", + scope="backend:link backend:read", + local_origin="http://test", + ) + db.add(link) + db.commit() + + store_url = "https://cloud.frameos.net/api/store/repository.json" + response = await async_client.get('/api/repositories') + assert response.status_code == 200 + assert store_url in [r["url"] for r in response.json()] + marker = db.query(Settings).filter_by( + project_id=async_client.project_id, key="@system/cloud_store_repository_added" + ).one() + assert marker.value == store_url + + repo = db.query(Repository).filter_by(url=store_url).first() + delete = await async_client.delete(f'/api/repositories/{repo.id}') + assert delete.status_code == 200 + + # Not re-added behind the user's back. + response = await async_client.get('/api/repositories') + assert store_url not in [r["url"] for r in response.json()] + + # Changing providers resets the one-time choice for the new provider and + # records its URL, even when the previous provider's store was deleted. + link.provider_url = "https://cloud.example.com" + db.commit() + next_store_url = "https://cloud.example.com/api/store/repository.json" + response = await async_client.get('/api/repositories') + assert next_store_url in [r["url"] for r in response.json()] + db.refresh(marker) + assert marker.value == next_store_url + + +@pytest.mark.asyncio +async def test_get_repositories_migrates_legacy_marker_after_provider_change(async_client, db, monkeypatch): + from app.models.cloud import CloudBackendLink + from app.models.settings import Settings + + async def fake_update(self): + self.templates = [] + + monkeypatch.setattr(Repository, "update_templates", fake_update) + db.add( + CloudBackendLink( + provider_url="https://cloud.example.com", + status="connected", + linked_client_id="lc-2", + scope="backend:link backend:read", + local_origin="http://test", + ) + ) + db.add( + Settings( + project_id=async_client.project_id, + key="@system/cloud_store_repository_added", + value="true", + ) + ) + old_store_url = "https://cloud.frameos.net/api/store/repository.json" + db.add(Repository(project_id=async_client.project_id, name="Old cloud store", url=old_store_url)) + db.commit() + + new_store_url = "https://cloud.example.com/api/store/repository.json" + response = await async_client.get('/api/repositories') + assert response.status_code == 200 + urls = [repository["url"] for repository in response.json()] + assert old_store_url not in urls + assert new_store_url in urls + marker = db.query(Settings).filter_by( + project_id=async_client.project_id, key="@system/cloud_store_repository_added" + ).one() + assert marker.value == new_store_url + + +@pytest.mark.asyncio +async def test_get_repositories_does_not_seed_store_without_link(async_client, db): + response = await async_client.get('/api/repositories') + assert response.status_code == 200 + assert all("api/store/repository.json" not in (r["url"] or "") for r in response.json()) diff --git a/backend/app/api/tests/test_templates.py b/backend/app/api/tests/test_templates.py index cc01a8910..e5f1ce68e 100644 --- a/backend/app/api/tests/test_templates.py +++ b/backend/app/api/tests/test_templates.py @@ -59,3 +59,103 @@ async def test_delete_nonexistent_template(async_client): response = await async_client.delete('/api/templates/999999') assert response.status_code == 404 assert "Template not found" in response.json()['detail'] + + +def test_frameos_zip_url_from_html(): + from app.api.templates import frameos_zip_url_from_html + + page = "https://cloud.example.com/scenes/sunrise" + # name before content, relative URL resolved against the page. + html = b'' + assert ( + frameos_zip_url_from_html(html, page) + == "https://cloud.example.com/api/store/scenes/abc/download" + ) + # content before name, absolute URL, escaped ampersand. + html = b'' + assert frameos_zip_url_from_html(html, page) == "https://x.example.com/y.zip?a=1&b=2" + assert frameos_zip_url_from_html(b"", page) is None + + +@pytest.mark.asyncio +async def test_create_template_from_scene_page_url(async_client, db, monkeypatch): + """Pasting a scene page URL (not a zip) installs through the page's + frameos:zip meta tag — the flow behind 'copy this link into the + Templates search box' on FrameOS Cloud scene pages.""" + import io + import json as jsonlib + import zipfile as zipfile_lib + + buffer = io.BytesIO() + with zipfile_lib.ZipFile(buffer, "w") as zf: + zf.writestr( + "Sunrise/template.json", + jsonlib.dumps({"name": "Sunrise", "scenes": "./scenes.json"}), + ) + zf.writestr("Sunrise/scenes.json", jsonlib.dumps([{"id": "scene-1", "nodes": []}])) + zip_bytes = buffer.getvalue() + + page_url = "https://cloud.example.com/scenes/sunrise" + zip_url = "https://cloud.example.com/api/store/scenes/abc/download" + page_html = ( + b'Sunrise' + ) + + class FakeResponse: + def __init__(self, content): + self.content = content + + def raise_for_status(self): + return None + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, headers=None): + if url == page_url: + return FakeResponse(page_html) + if url == zip_url: + return FakeResponse(zip_bytes) + raise AssertionError(f"unexpected URL fetched: {url}") + + import app.api.templates as templates_module + + monkeypatch.setattr(templates_module.httpx, "AsyncClient", lambda **kwargs: FakeClient()) + + response = await async_client.post("/api/templates", json={"url": page_url}) + assert response.status_code == 201, response.text + assert response.json()["name"] == "Sunrise" + + +@pytest.mark.asyncio +async def test_create_template_from_url_rejects_pages_without_meta(async_client, db, monkeypatch): + class FakeResponse: + content = b"Not a scene" + + def raise_for_status(self): + return None + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url, headers=None): + return FakeResponse() + + import app.api.templates as templates_module + + monkeypatch.setattr(templates_module.httpx, "AsyncClient", lambda **kwargs: FakeClient()) + + response = await async_client.post( + "/api/templates", json={"url": "https://example.com/some-page"} + ) + assert response.status_code == 422 + assert "frameos:zip" in response.json()["detail"] diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index fd13107f5..d165b095d 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -60,3 +60,19 @@ class CloudBackupSaveFrameRequest(BaseModel): class CloudBackupRestoreRequest(BaseModel): backup_id: str project_id: int + + +class CloudStorePublishRequest(BaseModel): + # Either an existing template... + template_id: str | None = None + # ...or inline scenes straight off a frame ("Save to cloud drive"). + name: str | None = None + description: str | None = None + scenes: list[dict] | None = None + from_frame_id: int | None = None + # Use this scene's cached snapshot as the preview image instead of the + # frame's current display. + image_scene_id: str | None = None + # "private" | "public"; omitted = private on first publish, unchanged on + # republish. + visibility: str | None = None diff --git a/backend/app/utils/cloud_backup.py b/backend/app/utils/cloud_backup.py index 5f33bac9a..1ce7c4962 100644 --- a/backend/app/utils/cloud_backup.py +++ b/backend/app/utils/cloud_backup.py @@ -74,6 +74,27 @@ def link_access_token(link: CloudBackendLink | None) -> str | None: return cloud.decrypt_cloud_secret(link.access_token) +def cloud_headers_for_url(db, url: str | None) -> dict[str, str]: + """Authorization header for requests that target the linked cloud provider. + + Lets template installs and repository refreshes fetch the account's + private store scenes ("My cloud drive"); any other host gets no header, so + the link token never leaks to third-party repositories. + """ + if not url: + return {} + from app.models.cloud import current_cloud_backend_link + + link = current_cloud_backend_link(db) + access_token = link_access_token(link) + if link is None or access_token is None or not link.provider_url: + return {} + provider = link.provider_url.rstrip("/") + if url == provider or url.startswith(provider + "/"): + return {"authorization": f"Bearer {access_token}"} + return {} + + async def push_frame_backup( link: CloudBackendLink, access_token: str, frame_dict: dict, project_name: str | None = None ) -> tuple[int, dict]: diff --git a/backend/app/utils/cloud_link.py b/backend/app/utils/cloud_link.py index e89da7c3a..aed34a733 100644 --- a/backend/app/utils/cloud_link.py +++ b/backend/app/utils/cloud_link.py @@ -197,3 +197,34 @@ async def backup_delete( return await cloud_request( "DELETE", provider_url, f"/api/backends/backups/{backup_id}", access_token=access_token ) + + +# ---- store (scene publishing) -------------------------------------------------- + + +async def store_publish( + provider_url: str, access_token: str, payload: dict[str, Any] +) -> tuple[int, dict[str, Any]]: + """Publish a scene (template zip) to the cloud store (store:publish).""" + return await cloud_request( + "POST", provider_url, "/api/store/publish", access_token=access_token, json_body=payload + ) + + +async def store_drive(provider_url: str, access_token: str) -> tuple[int, dict[str, Any]]: + """The account's own store scenes ("My cloud drive"), private ones included.""" + return await cloud_request( + "GET", provider_url, "/api/store/account/repository.json", access_token=access_token + ) + + +async def cloud_get_binary(provider_url: str, path: str, access_token: str) -> tuple[int, str, bytes]: + """One authenticated binary GET (preview images, zips). Returns (status, content_type, body).""" + headers = {"authorization": f"Bearer {access_token}"} + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client: + response = await client.get(cloud_api_url(provider_url, path), headers=headers) + return ( + response.status_code, + response.headers.get("content-type", "application/octet-stream"), + response.content, + ) diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png index ee3d8a45e..fffc3d13b 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png index 57b86fdcf..c7a1d9355 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png index e6dfac12b..8651bdd6d 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png index c57d23bd1..a352d7ac5 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png index 3d75aaf2c..587a2c534 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png index 64639a49a..9625c0448 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/frame-scenes--add-scene--light--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png index 6a84f5a5b..b269e74b8 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png index ef5b07536..b434377a6 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png index 200c43785..fc711eaef 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--dark--mobile.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png index c0dd81bd1..39c6f7346 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--full.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png index 749857c4e..f40dcb4d8 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mid.png differ diff --git a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png index e1511251c..8102240ba 100644 Binary files a/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png and b/e2e/frontend-visual/snapshots/chromium/visual.spec.ts/global-settings--default--light--mobile.png differ diff --git a/frontend/src/scenes/frame/panels/Scenes/SceneDropDown.tsx b/frontend/src/scenes/frame/panels/Scenes/SceneDropDown.tsx index 6b1a269d6..5bf330305 100644 --- a/frontend/src/scenes/frame/panels/Scenes/SceneDropDown.tsx +++ b/frontend/src/scenes/frame/panels/Scenes/SceneDropDown.tsx @@ -8,6 +8,7 @@ import { ArrowPathIcon, ClipboardDocumentIcon, CloudArrowDownIcon, + CloudArrowUpIcon, Cog6ToothIcon, DocumentDuplicateIcon, DocumentMagnifyingGlassIcon, @@ -15,6 +16,7 @@ import { TagIcon, } from '@heroicons/react/24/outline' import { templatesLogic } from '../Templates/templatesLogic' +import { cloudDriveLogic } from '../Templates/cloudDriveLogic' import { findConnectedScenes } from './utils' import { openWorkspaceSceneUtility, workspaceLogic } from '../../../workspace/workspaceLogic' @@ -45,7 +47,8 @@ export function SceneDropDown({ const { scenes, sceneUpdateVersions } = useValues(scenesLogic({ frameId })) const { renameScene, duplicateScene, deleteScene, setAsDefault, removeDefault, copySceneJSON, updateSceneFromRepo } = useActions(scenesLogic({ frameId })) - const { saveAsTemplate, saveAsZip } = useActions(templatesLogic({ frameId })) + const { saveAsTemplate, saveAsZip, saveAsCloudTemplate } = useActions(templatesLogic({ frameId })) + const { hasDriveScope } = useValues(cloudDriveLogic) const scene = scenes.find((s) => s.id === sceneId) if (!scene) { return null @@ -124,6 +127,19 @@ export function SceneDropDown({ saveAsTemplate({ name: scene.name ?? '', exportScenes: findConnectedScenes(scenes, scene.id) }), icon: , }, + { + label: 'Save to cloud drive', + onClick: () => { + if (!hasDriveScope) { + window.alert( + 'FrameOS Cloud is not connected (or store publishing is disabled). Enable it under Settings → FrameOS Cloud.' + ) + return + } + saveAsCloudTemplate({ name: scene.name ?? '', exportScenes: findConnectedScenes(scenes, scene.id) }) + }, + icon: , + }, { label: 'Download as .zip', onClick: () => saveAsZip({ name: scene.name ?? '', exportScenes: findConnectedScenes(scenes, scene.id) }), diff --git a/frontend/src/scenes/frame/panels/Templates/CloudDrive.tsx b/frontend/src/scenes/frame/panels/Templates/CloudDrive.tsx new file mode 100644 index 000000000..a11a7cc93 --- /dev/null +++ b/frontend/src/scenes/frame/panels/Templates/CloudDrive.tsx @@ -0,0 +1,137 @@ +import { useActions, useValues } from 'kea' +import { A } from 'kea-router' +import { ArrowPathIcon, ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/solid' +import { H6 } from '../../../../components/H6' +import { Box } from '../../../../components/Box' +import { Spinner } from '../../../../components/Spinner' +import { DropdownMenu } from '../../../../components/DropdownMenu' +import { frameLogic } from '../../frameLogic' +import { appsModel } from '../../../../models/appsModel' +import { templatesLogic } from './templatesLogic' +import { cloudDriveLogic } from './cloudDriveLogic' +import { TemplateRow } from './Template' +import { templateCompatibilityForFrame } from '../../../../utils/embeddedCompatibility' +import { searchInText } from '../../../../utils/searchInText' +import { urls } from '../../../../urls' + +const EXPANDED_KEY = 'cloud-drive' + +interface CloudDriveProps { + openInstalledSceneDrawer?: boolean +} + +/** "My cloud drive" section of the Templates panel: your own FrameOS Cloud + * store scenes (private and public), installable on any frame. Shows a short + * promo with a settings link while the cloud is not connected. */ +export function CloudDrive({ openInstalledSceneDrawer = false }: CloudDriveProps): JSX.Element { + const { frameId, mode, frameForm } = useValues(frameLogic) + const { apps } = useValues(appsModel) + const { isExpanded, search, installedTemplatesByName } = useValues(templatesLogic({ frameId })) + const { toggleExpanded, applyRemoteToFrame, saveRemoteAsLocal } = useActions(templatesLogic({ frameId })) + const { driveTemplates, driveTemplatesLoading, hasDriveScope, cloudConnected, driveRepository } = + useValues(cloudDriveLogic) + const { loadDrive } = useActions(cloudDriveLogic) + + const expanded = isExpanded(EXPANDED_KEY) + const templates = + search === '' + ? driveTemplates + : driveTemplates.filter((t) => searchInText(search, t.name) || searchInText(search, t.description)) + + return ( +
+
+
toggleExpanded(EXPANDED_KEY)}> + {expanded ? : } + My cloud drive + {hasDriveScope && driveTemplates.length ? ` (${driveTemplates.length})` : ''} + {driveTemplatesLoading ? : null} +
+ {hasDriveScope ? ( + , + }, + ]} + /> + ) : null} +
+ {expanded ? ( + !hasDriveScope ? ( + +
+ Save scenes to your own private drive on FrameOS Cloud, restore them on any install, and share the best + ones on the public store. +
+
+ {cloudConnected ? ( + <> + This comes with your cloud account — reconnect in{' '} + + Settings → FrameOS Cloud + {' '} + to pick it up. + + ) : ( + <> + + Connect FrameOS Cloud + {' '} + in Settings to use it. + + )} +
+
+ ) : ( +
+ {templates + .map((template, index) => ({ + template, + index, + compatibility: templateCompatibilityForFrame(mode, template, apps, frameForm), + })) + .toSorted((a, b) => a.template.name.localeCompare(b.template.name)) + .map(({ template, index, compatibility }) => ( + saveRemoteAsLocal(driveRepository, template)} + applyTemplate={(template) => { + applyRemoteToFrame(driveRepository, template, openInstalledSceneDrawer) + }} + installedTemplatesByName={installedTemplatesByName} + templateDragData={ + compatibility.supported + ? { + template, + repository: { + id: driveRepository.id, + name: driveRepository.name, + url: driveRepository.url, + }, + } + : undefined + } + compatibility={compatibility} + /> + ))} + {templates.length === 0 && !driveTemplatesLoading ? ( +
+ {search === '' + ? 'Your cloud drive is empty. Use "Save to cloud drive" on any scene to fill it.' + : `No cloud drive scenes match "${search}"`} +
+ ) : null} +
+ ) + ) : null} +
+ ) +} diff --git a/frontend/src/scenes/frame/panels/Templates/EditTemplateModal.tsx b/frontend/src/scenes/frame/panels/Templates/EditTemplateModal.tsx index 5288c35ee..c0e577b3d 100644 --- a/frontend/src/scenes/frame/panels/Templates/EditTemplateModal.tsx +++ b/frontend/src/scenes/frame/panels/Templates/EditTemplateModal.tsx @@ -14,28 +14,55 @@ import { Tag } from '../../../../components/Tag' import { CheckIcon } from '@heroicons/react/24/solid' import clsx from 'clsx' import { Spinner } from '../../../../components/Spinner' -import React from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Label } from '../../../../components/Label' import { FrameImage } from '../../../../components/FrameImage' +const MODAL_TITLES: Record = { + localTemplate: 'Save to "My scenes"', + zip: 'Download as .zip', + cloud: 'Save to cloud drive', +} + +const SUBMIT_LABELS: Record = { + localTemplate: 'Save to "My scenes"', + zip: 'Download .zip', + cloud: 'Save to cloud drive', +} + export function EditTemplateModal() { - const { frameId, sortedScenes } = useValues(frameLogic) + const { frameId, frame, sortedScenes } = useValues(frameLogic) const { isTemplateFormSubmitting, showingModal, modalTarget, templateForm } = useValues(templatesLogic({ frameId })) const { hideModal, submitTemplateForm } = useActions(templatesLogic({ frameId })) const newTemplate = !templateForm.id + // Scenes selected when the modal opened float to the top (they are what + // you came here to save); toggling later does not reshuffle the list. + const [initialSelection, setInitialSelection] = useState([]) + useEffect(() => { + if (showingModal) { + setInitialSelection(templateForm.exportScenes ?? []) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showingModal]) + const orderedScenes = useMemo(() => { + const selected = new Set(initialSelection) + return [...sortedScenes].sort((a, b) => Number(selected.has(b.id)) - Number(selected.has(a.id))) + }, [initialSelection, sortedScenes]) + + // The preview image is what gets saved with the template: the frame's + // current snapshot when the active scene is included in the export, + // otherwise the first selected scene's cached snapshot. + const exportScenes = templateForm.exportScenes ?? [] + const activeSceneId = frame?.active_scene_id + const imageSceneId = activeSceneId && exportScenes.includes(activeSceneId) ? undefined : exportScenes[0] + return ( <> {showingModal ? (
} @@ -87,14 +108,16 @@ export function EditTemplateModal() { ) : null} - {sortedScenes.map((scene) => { + {orderedScenes.map((scene) => { const included = (value || []).includes(scene.id) return ( { e.preventDefault() @@ -134,8 +157,23 @@ export function EditTemplateModal() { )} - - + + {imageSceneId ? ( +
+ +
+ The cached snapshot of the first selected scene — the frame currently shows a scene that is + not part of this template. +
+
+ ) : ( +
+ +
+ The frame's current snapshot. This image is shown wherever the template is listed. +
+
+ )}
) : null} diff --git a/frontend/src/scenes/frame/panels/Templates/Template.tsx b/frontend/src/scenes/frame/panels/Templates/Template.tsx index 1da14ef00..a587fd66d 100644 --- a/frontend/src/scenes/frame/panels/Templates/Template.tsx +++ b/frontend/src/scenes/frame/panels/Templates/Template.tsx @@ -10,7 +10,9 @@ import { import { DropdownMenu } from '../../../../components/DropdownMenu' import { FolderPlusIcon, + ArrowTopRightOnSquareIcon, CloudArrowDownIcon, + CloudArrowUpIcon, DocumentPlusIcon, CheckIcon, EyeIcon, @@ -24,13 +26,39 @@ import clsx from 'clsx' import { appsModel } from '../../../../models/appsModel' import { useActions, useValues } from 'kea' import { settingsLogic } from '../../../settings/settingsLogic' +import { cloudLogic } from '../../../settings/cloudLogic' +import { apiFetch } from '../../../../utils/apiFetch' import { collectSecretSettingsFromScenes, getMissingSecretSettingKeys, settingsDetails } from '../secretSettings' import { SecretSettingsModal } from '../SecretSettingsModal' import { templateRowLogic } from './templateRowLogic' +import { cloudDriveLogic } from './cloudDriveLogic' import { type FrameosTemplateDragData, setFrameosTemplateDragData } from '../../../workspace/sceneDrag' import type { CompatibilityResult } from '../../../../utils/embeddedCompatibility' import { livePreviewLogic } from '../Scenes/livePreviewLogic' import { LivePreviewModal } from '../Scenes/LivePreviewModal' +import { CURRENT_FRAMEOS_VERSION } from '../../frameDeployUtils' + +// True when a cloud scene was exported with a newer FrameOS than this install +// runs — a nudge that upgrading may be needed for it to work as published. +// CalVer (2026.7.3) compares numerically segment by segment. +export function builtOnNewerFrameos(templateVersion?: string): boolean { + if (!templateVersion || !CURRENT_FRAMEOS_VERSION || CURRENT_FRAMEOS_VERSION === 'dev') { + return false + } + const ours = CURRENT_FRAMEOS_VERSION.split('.').map(Number) + const theirs = templateVersion.split('.').map(Number) + if (theirs.some(isNaN) || ours.some(isNaN)) { + return false + } + for (let i = 0; i < Math.max(ours.length, theirs.length); i++) { + const a = theirs[i] ?? 0 + const b = ours[i] ?? 0 + if (a !== b) { + return a > b + } + } + return false +} interface TemplateProps { template: TemplateType @@ -66,6 +94,7 @@ export function TemplateRow({ onToggleFavourite, }: TemplateProps): JSX.Element { const { apps } = useValues(appsModel) + const { grantedScopes } = useValues(cloudLogic) const { settings, savedSettings, settingsChanged } = useValues(settingsLogic) const { setSettingsValue, submitSettings } = useActions(settingsLogic) const [activeSettingsKey, setActiveSettingsKey] = useState(null) @@ -108,6 +137,56 @@ export function TemplateRow({ // so there's nothing the (browser or on-frame) live preview could execute. const compiledOnly = templateScenes.length > 0 && !trySceneConfig && !canLoadRemoteScenes const showFavourite = Boolean(favouriteId && onToggleFavourite) + // Only locally saved templates can be published, and only when the cloud + // link has the store:publish feature enabled (Settings → FrameOS Cloud). + const canPublishToCloud = !repository && Boolean(template.id) && grantedScopes.includes('store:publish') + + async function publishToCloud(): Promise { + if (!template.id) { + return + } + const response = await apiFetch('/api/cloud/store/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template_id: template.id }), + }) + if (!response.ok) { + let detail = `unexpected status ${response.status}` + try { + detail = (await response.json())?.detail ?? detail + } catch { + // keep fallback detail + } + window.alert(`Could not save to cloud drive: ${detail}`) + return + } + const payload = await response.json() + const scene = payload?.scene + cloudDriveLogic.findMounted()?.actions.loadDrive() + const suffix = + scene?.visibility === 'public' + ? `It is public on the store, now at version ${scene?.version ?? '?'}.` + : 'It stays private until you make it public on FrameOS Cloud.' + if (scene?.url && window.confirm(`Saved "${template.name}" to your cloud drive. ${suffix}\n\nOpen it now?`)) { + window.open(scene.url, '_blank', 'noopener') + } + } + + // Cloud store scenes carry risk flags; installing one that can run shell + // commands on the frame deserves an explicit confirmation. + const runsShellCommands = Boolean(template.flags?.includes('shell')) + function confirmAndApply(template: TemplateType): void { + if ( + runsShellCommands && + !window.confirm( + `"${template.name}" configures apps or custom code that run shell commands on the frame. ` + + 'Only install it if you trust the publisher. Install anyway?' + ) + ) { + return + } + applyTemplate?.(template) + } return (
) : null} + {runsShellCommands ? ( + + shell + + ) : null} + {template.author || template.frameosVersion ? ( +
+ {template.author ? <>by {template.author} : null} + {template.author && template.frameosVersion ? ' · ' : null} + {template.frameosVersion ? ( + builtOnNewerFrameos(template.frameosVersion) ? ( + + FrameOS {template.frameosVersion} — newer than this install + + ) : ( + + FrameOS {template.frameosVersion} + + ) + ) : null} +
+ ) : null}
{applyTemplate ? ( @@ -176,7 +284,7 @@ export function TemplateRow({ className="!px-2 flex gap-1" size="small" color={installedTemplatesByName[template.name] ? 'secondary' : 'primary'} - onClick={() => applyTemplate(template)} + onClick={() => confirmAndApply(template)} disabled={!canInstall} title={unsupported ? unsupportedReason : 'Add scene'} > @@ -220,7 +328,7 @@ export function TemplateRow({ label: templateScenes.length ? `Add ${templateScenes.length} scene${templateScenes.length === 1 ? '' : 's'} onto frame` : 'Add onto frame', - onClick: () => applyTemplate(template), + onClick: () => confirmAndApply(template), disabled: !canInstall, title: unsupported ? unsupportedReason : undefined, icon: , @@ -236,6 +344,17 @@ export function TemplateRow({ }, ] : []), + // Store scenes (and cloud drive entries) carry their page's + // URL in the repository index. + ...(template.url + ? [ + { + label: 'View store page', + onClick: () => window.open(template.url, '_blank', 'noopener'), + icon: , + }, + ] + : []), ...(exportTemplate ? [ { @@ -245,6 +364,15 @@ export function TemplateRow({ }, ] : []), + ...(canPublishToCloud + ? [ + { + label: 'Save to cloud drive', + onClick: () => void publishToCloud(), + icon: , + }, + ] + : []), ...(editTemplate ? [ { diff --git a/frontend/src/scenes/frame/panels/Templates/Templates.tsx b/frontend/src/scenes/frame/panels/Templates/Templates.tsx index 7e28c46b7..72f30433a 100644 --- a/frontend/src/scenes/frame/panels/Templates/Templates.tsx +++ b/frontend/src/scenes/frame/panels/Templates/Templates.tsx @@ -21,6 +21,7 @@ import { appsModel } from '../../../../models/appsModel' import { templateCompatibilityForFrame, type CompatibilityResult } from '../../../../utils/embeddedCompatibility' import { settingsLogic } from '../../../settings/settingsLogic' import { templateFavouriteId } from './templateFavourites' +import { CloudDrive } from './CloudDrive' interface TemplatesProps { openInstalledSceneDrawer?: boolean @@ -73,10 +74,30 @@ export function Templates({ openInstalledSceneDrawer = false }: TemplatesProps = } = useValues(templatesLogic({ frameId })) const { togglePersonalFavouriteTemplate } = useActions(settingsLogic) const { removeRepository, refreshRepository } = useActions(repositoriesModel) + const { addUrlToFrame } = useActions(templatesLogic({ frameId })) + const { addingUrlToFrame } = useValues(templatesLogic({ frameId })) + + // A pasted URL is an install request, not a search: scene pages on + // FrameOS Cloud say "copy this link into the Templates search box". + const searchedUrl = /^https?:\/\/\S+$/i.test(search.trim()) ? search.trim() : null return (
- + + {searchedUrl && !inFrameAdminMode ? ( + +
Add scene from URL
+
{searchedUrl}
+ +
+ ) : null} {showingRemoteTemplate ? (
Add scene from URL
@@ -138,12 +159,14 @@ export function Templates({ openInstalledSceneDrawer = false }: TemplatesProps =
) : null} + {!inFrameAdminMode && } + {!inFrameAdminMode && ( -
+
toggleExpanded('')}> {isExpanded('') ? : } - My scenes + My local scenes {templates.length ? ` (${templates.length})` : ''}
([ + path(['src', 'scenes', 'frame', 'panels', 'Templates', 'cloudDriveLogic']), + connect({ + values: [cloudLogic, ['grantedScopes', 'cloudProviderUrl', 'cloudStatus']], + actions: [cloudLogic, ['loadCloudStatusSuccess']], + }), + loaders(() => ({ + driveTemplates: [ + [] as TemplateType[], + { + loadDrive: async () => { + const response = await apiFetch('/api/cloud/store/drive') + if (!response.ok) { + throw new Error('Failed to load cloud drive') + } + const payload = await response.json() + return ((payload?.templates ?? []) as TemplateType[]).map((template) => ({ + ...template, + // Preview images come through our authenticated backend proxy. + image: + typeof template.image === 'string' && template.image.startsWith('/') + ? getBasePath() + template.image + : template.image, + })) + }, + }, + ], + })), + selectors({ + hasDriveScope: [(s) => [s.grantedScopes], (grantedScopes): boolean => grantedScopes.includes('store:publish')], + cloudConnected: [(s) => [s.cloudStatus], (cloudStatus): boolean => Boolean(cloudStatus?.link)], + driveRepository: [ + (s) => [s.cloudProviderUrl], + (cloudProviderUrl): RepositoryType => ({ + id: 'cloud-drive', + name: 'My cloud drive', + url: `${(cloudProviderUrl ?? '').replace(/\/+$/, '')}/api/store/account/repository.json`, + templates: [], + }), + ], + }), + listeners(({ actions, values }) => ({ + loadCloudStatusSuccess: () => { + if (values.hasDriveScope) { + actions.loadDrive() + } + }, + })), + afterMount(({ actions, values }) => { + if (values.hasDriveScope) { + actions.loadDrive() + } + }), +]) diff --git a/frontend/src/scenes/frame/panels/Templates/templatesLogic.tsx b/frontend/src/scenes/frame/panels/Templates/templatesLogic.tsx index 3b28c772e..f64d01761 100644 --- a/frontend/src/scenes/frame/panels/Templates/templatesLogic.tsx +++ b/frontend/src/scenes/frame/panels/Templates/templatesLogic.tsx @@ -13,6 +13,7 @@ import { settingsLogic } from '../../../settings/settingsLogic' import { templateCompatibilityForFrame } from '../../../../utils/embeddedCompatibility' import { templateWithSceneOrigins } from '../../../../utils/sceneOrigin' import { templateFavouriteId, type TemplateWithFavouriteId } from './templateFavourites' +import { cloudDriveLogic } from './cloudDriveLogic' export interface TemplateLogicProps { frameId: number @@ -92,6 +93,7 @@ export const templatesLogic = kea([ actions({ saveAsTemplate: (template?: Partial) => ({ template: template ?? {} }), saveAsZip: (template?: Partial) => ({ template: template ?? {} }), + saveAsCloudTemplate: (template?: Partial) => ({ template: template ?? {} }), editLocalTemplate: (template: TemplateType) => ({ template }), hideModal: true, saveRemoteAsLocal: (repository: RepositoryType, template: TemplateType) => ({ repository, template }), @@ -107,6 +109,8 @@ export const templatesLogic = kea([ showAddRepository: true, hideAddRepository: true, setSearch: (search: string) => ({ search }), + addUrlToFrame: (url: string, openDrawer?: boolean) => ({ url, openDrawer: openDrawer ?? false }), + setAddingUrlToFrame: (adding: boolean) => ({ adding }), toggleExpanded: (url: string) => ({ url }), applyFavouriteTemplatesToFrame: (openDrawer?: boolean) => ({ openDrawer: openDrawer ?? false, @@ -139,11 +143,56 @@ export const templatesLogic = kea([ } else { // create const target = values.modalTarget + const exportScenes = formValues.exportScenes ?? [] + // The preview image should show one of the scenes being saved: use + // the frame's snapshot only when the active scene is included, + // otherwise the first selected scene's cached snapshot. + const activeSceneId = values.frame?.active_scene_id + const imageSceneId = + activeSceneId && exportScenes.includes(activeSceneId) ? undefined : exportScenes[0] + + if (target === 'cloud') { + const response = await apiFetch('/api/cloud/store/publish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: formValues.name, + description: formValues.description, + scenes: (values.frameForm.scenes || []).filter((scene) => exportScenes.includes(scene.id)), + from_frame_id: props.frameId, + ...(imageSceneId ? { image_scene_id: imageSceneId } : {}), + }), + }) + if (!response.ok) { + let detail = `unexpected status ${response.status}` + try { + detail = (await response.json())?.detail ?? detail + } catch { + // keep fallback detail + } + window.alert(`Could not save to cloud drive: ${detail}`) + throw new Error('Failed to save to cloud drive') + } + const payload = await response.json() + const scene = payload?.scene + cloudDriveLogic.findMounted()?.actions.loadDrive() + actions.hideModal() + actions.resetTemplateForm() + if ( + scene?.url && + window.confirm(`Saved "${formValues.name}" to your cloud drive (v${scene?.version ?? '?'}). Open it?`) + ) { + window.open(scene.url, '_blank', 'noopener') + } + return + } + const request: TemplateType & Record = { name: formValues.name, description: formValues.description, - scenes: (values.frameForm.scenes || []).filter((scene) => formValues.exportScenes?.includes(scene.id)), + scenes: (values.frameForm.scenes || []).filter((scene) => exportScenes.includes(scene.id)), from_frame_id: props.frameId, + ...(imageSceneId ? { image_scene_id: imageSceneId } : {}), format: target === 'zip' ? 'zip' : 'json', } const response = await apiFetch('/api/templates', { @@ -192,10 +241,22 @@ export const templatesLogic = kea([ body: JSON.stringify(request), }) if (!response.ok) { - throw new Error('Failed to update frame') + let detail = `unexpected status ${response.status}` + try { + detail = (await response.json())?.detail ?? detail + } catch { + // keep fallback detail + } + window.alert(`Could not add the scene: ${detail}`) + throw new Error('Failed to add template from URL') } actions.updateTemplate(await response.json()) actions.resetAddTemplateUrlForm() + // A pasted URL doubles as the search value ("Add scene from URL" + // quick action); clear it so the freshly added scene is visible. + if (values.search === formValues.url) { + actions.setSearch('') + } }, }, uploadTemplateForm: { @@ -245,20 +306,23 @@ export const templatesLogic = kea([ })), reducers({ search: ['', { setSearch: (_, { search }) => search }], + addingUrlToFrame: [false, { setAddingUrlToFrame: (_, { adding }) => adding }], showingModal: [ false, { saveAsZip: () => true, saveAsTemplate: () => true, + saveAsCloudTemplate: () => true, editLocalTemplate: () => true, hideModal: () => false, }, ], modalTarget: [ - 'localTemplate' as 'localTemplate' | 'zip', + 'localTemplate' as 'localTemplate' | 'zip' | 'cloud', { saveAsZip: () => 'zip', saveAsTemplate: () => 'localTemplate', + saveAsCloudTemplate: () => 'cloud', editLocalTemplate: () => 'localTemplate', }, ], @@ -271,6 +335,13 @@ export const templatesLogic = kea([ ...template, }), saveAsZip: (_, { template }) => ({ id: '', name: '', description: '', exportScenes: undefined, ...template }), + saveAsCloudTemplate: (_, { template }) => ({ + id: '', + name: '', + description: '', + exportScenes: undefined, + ...template, + }), editLocalTemplate: (_, { template }) => ({ id: template.id, name: template.name, @@ -395,6 +466,40 @@ export const templatesLogic = kea([ ], }), listeners(({ actions, values }) => ({ + // Install the scene(s) behind a pasted URL (template zip, or a scene page + // with a frameos:zip meta tag) straight onto this frame — the flow behind + // "copy this link into the Templates search box" on FrameOS Cloud. + addUrlToFrame: async ({ url, openDrawer }) => { + actions.setAddingUrlToFrame(true) + try { + const response = await apiFetch(`/api/templates`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, format: 'scenes' }), + }) + if (!response.ok) { + let detail = `unexpected status ${response.status}` + try { + detail = (await response.json())?.detail ?? detail + } catch { + // keep fallback detail + } + window.alert(`Could not add the scene: ${detail}`) + return + } + const scenes = await response.json() + if (!Array.isArray(scenes) || scenes.length === 0) { + window.alert('No scenes found at this URL.') + return + } + actions.applyTemplate({ scenes }, openDrawer) + if (values.search === url) { + actions.setSearch('') + } + } finally { + actions.setAddingUrlToFrame(false) + } + }, saveRemoteAsLocal: async ({ template, repository }) => { if ('zip' in template) { let zipPath = (template as any).zip @@ -459,6 +564,11 @@ export const templatesLogic = kea([ actions.setTemplateFormValues({ exportScenes: values.frameForm?.scenes?.map((s) => s.id) || [] }) } }, + saveAsCloudTemplate: () => { + if ((values.templateForm.exportScenes?.length ?? 0) === 0) { + actions.setTemplateFormValues({ exportScenes: values.frameForm?.scenes?.map((s) => s.id) || [] }) + } + }, showAddRepository: () => { actions.setAddRepositoryFormValues({ name: '', url: '' }) }, diff --git a/frontend/src/scenes/workspace/WorkspaceSceneDropDown.tsx b/frontend/src/scenes/workspace/WorkspaceSceneDropDown.tsx index 1afb911ae..07d131a44 100644 --- a/frontend/src/scenes/workspace/WorkspaceSceneDropDown.tsx +++ b/frontend/src/scenes/workspace/WorkspaceSceneDropDown.tsx @@ -5,6 +5,7 @@ import { ArrowPathIcon, ClipboardDocumentIcon, CloudArrowDownIcon, + CloudArrowUpIcon, DocumentDuplicateIcon, DocumentMagnifyingGlassIcon, FolderPlusIcon, @@ -20,6 +21,7 @@ import { frameLogic } from '../frame/frameLogic' import { sceneUpdatesLogic } from '../frame/panels/Scenes/sceneUpdatesLogic' import { findConnectedScenes } from '../frame/panels/Scenes/utils' import { EditTemplateModal } from '../frame/panels/Templates/EditTemplateModal' +import { cloudDriveLogic } from '../frame/panels/Templates/cloudDriveLogic' import { templatesLogic } from '../frame/panels/Templates/templatesLogic' import { openWorkspaceSceneUtility, workspaceLogic } from './workspaceLogic' @@ -49,7 +51,8 @@ export function WorkspaceSceneDropDown({ const { sceneUpdateVersions } = useValues(sceneUpdatesLogic({ frameId: frame.id })) const { updateSceneFromRepo } = useActions(sceneUpdatesLogic({ frameId: frame.id })) const { navigateToScene, openScenePreview } = useActions(workspaceLogic) - const { saveAsTemplate, saveAsZip } = useActions(templatesLogic({ frameId: frame.id })) + const { saveAsTemplate, saveAsZip, saveAsCloudTemplate } = useActions(templatesLogic({ frameId: frame.id })) + const { hasDriveScope } = useValues(cloudDriveLogic) const currentScenes = frameForm.scenes ?? frame.scenes ?? scenes const currentScene = currentScenes.find((candidate) => candidate.id === scene.id) ?? scene @@ -117,6 +120,20 @@ export function WorkspaceSceneDropDown({ }, icon: , }, + { + label: 'Save to cloud drive', + onClick: () => { + if (!hasDriveScope) { + window.alert( + 'FrameOS Cloud is not connected (or store publishing is disabled). Enable it under Settings → FrameOS Cloud.' + ) + return + } + setTemplateModalMounted(true) + saveAsCloudTemplate({ name: currentScene.name ?? '', exportScenes: connectedSceneIds() }) + }, + icon: , + }, { label: 'Download as .zip', onClick: () => { diff --git a/frontend/src/types.tsx b/frontend/src/types.tsx index bdd4a705e..d0ac1a5db 100644 --- a/frontend/src/types.tsx +++ b/frontend/src/types.tsx @@ -302,6 +302,18 @@ export interface TemplateType { image?: any imageWidth?: number imageHeight?: number + /** Publisher display name (FrameOS Cloud store scenes). */ + author?: string + /** FrameOS version the scene was exported with (FrameOS Cloud store scenes). */ + frameosVersion?: string + /** Risk flags computed by the cloud store, e.g. 'shell' for scenes that run shell commands. */ + flags?: string[] + /** Store scene uuid ("My cloud drive" entries). */ + sceneId?: string + /** Scene page on the cloud store. */ + url?: string + /** 'private' | 'public' ("My cloud drive" entries). */ + visibility?: string } export interface TemplateForm extends TemplateType {