diff --git a/.gitignore b/.gitignore index 7419a084..91990cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ debug_images **/*wwebjs* app/data/.usage/* app/config/settings.json +app/config/instance.json **/CONVERSATION_HISTORY.md **/EVENT_UNPROCESSED.md **/EVENT.md diff --git a/app/config.py b/app/config.py index 2f954952..98b022a0 100644 --- a/app/config.py +++ b/app/config.py @@ -128,6 +128,11 @@ def _get_default_settings() -> Dict[str, Any]: "use_omniparser": False, "omniparser_url": "http://127.0.0.1:7861", }, + # Optional override for the marketplace server. Empty = use the + # baked-in production URL (see DEFAULT_MARKETPLACE_SERVER_URL in + # app/marketplace/client.py). Set to http://127.0.0.1:3000 for + # local development. + "marketplace_server_url": "", } diff --git a/app/data/living_ui_template/index.html b/app/data/living_ui_template/index.html index 14a8f56d..2279b750 100644 --- a/app/data/living_ui_template/index.html +++ b/app/data/living_ui_template/index.html @@ -3,6 +3,8 @@ + + {{PROJECT_NAME}} diff --git a/app/data/living_ui_template/public/favicon.ico b/app/data/living_ui_template/public/favicon.ico new file mode 100644 index 00000000..03729923 Binary files /dev/null and b/app/data/living_ui_template/public/favicon.ico differ diff --git a/app/data/living_ui_template/public/favicon.svg b/app/data/living_ui_template/public/favicon.svg new file mode 100644 index 00000000..3dae1804 --- /dev/null +++ b/app/data/living_ui_template/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/living_ui/manager.py b/app/living_ui/manager.py index f19d3329..724dd9c2 100644 --- a/app/living_ui/manager.py +++ b/app/living_ui/manager.py @@ -68,6 +68,11 @@ class LivingUIProject: # Per-project display theme chosen in the UI ({"themeId": ..., "customColors": {...}}). # Persisted so the choice survives beyond one browser's localStorage. ui_theme: Optional[Dict[str, Any]] = None + # Marketplace provenance (installs only) — lets the UI detect updates by + # comparing the installed commit against the catalog's latest version. + marketplace_slug: Optional[str] = None + marketplace_version: Optional[str] = None + marketplace_commit_sha: Optional[str] = None bridge_token: str = "" # Ephemeral token for integration bridge (NOT serialized) tunnel_url: Optional[str] = None # Public tunnel URL (NOT serialized) tunnel_process: Optional[subprocess.Popen] = None # Tunnel process (NOT serialized) @@ -97,6 +102,9 @@ def to_dict(self) -> Dict[str, Any]: "appRuntime": self.app_runtime, "tunnelUrl": self.tunnel_url, "uiTheme": self.ui_theme, + "marketplaceSlug": self.marketplace_slug, + "marketplaceVersion": self.marketplace_version, + "marketplaceCommitSha": self.marketplace_commit_sha, } @@ -598,6 +606,11 @@ def _load_projects(self) -> None: project_type=project_data.get("projectType", "native"), app_runtime=project_data.get("appRuntime"), ui_theme=project_data.get("uiTheme"), + marketplace_slug=project_data.get("marketplaceSlug"), + marketplace_version=project_data.get("marketplaceVersion"), + marketplace_commit_sha=project_data.get( + "marketplaceCommitSha" + ), ) # Keep the saved tunnel URL optimistically; reachability # is verified in a background thread below so startup @@ -2577,6 +2590,10 @@ async def install_from_marketplace( custom_fields: Optional[Dict[str, str]] = None, repo_url: str = "https://github.com/CraftOS-dev/living-ui-marketplace", project_id: Optional[str] = None, + download_url: Optional[str] = None, + version: Optional[str] = None, + git_commit_sha: Optional[str] = None, + marketplace_slug: Optional[str] = None, ) -> Dict[str, Any]: """ Install a pre-built Living UI app from the marketplace. @@ -2590,6 +2607,11 @@ async def install_from_marketplace( app_name: Display name for the project app_description: App description repo_url: GitHub repo URL + download_url: Optional pinned zip URL (e.g. codeload .../zip/) + from the marketplace server; falls back to the repo's main branch + version: Marketplace version string recorded on the project + git_commit_sha: Commit the download is pinned to (update detection) + marketplace_slug: Catalog slug recorded on the project Returns: Dict with status, project info, or error @@ -2605,12 +2627,17 @@ async def install_from_marketplace( project_path = self.living_ui_dir / f"{sanitized_name}_{project_id}" try: - # Download the repo as a zip - # GitHub API: /{owner}/{repo}/zipball/main - parts = repo_url.rstrip("/").split("/") - owner = parts[-2] - repo = parts[-1] - zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/main.zip" + # Download the repo as a zip — pinned URL from the marketplace + # server when available, else the repo's main branch. + if download_url: + zip_url = download_url + else: + parts = repo_url.rstrip("/").split("/") + owner = parts[-2] + repo = parts[-1] + zip_url = ( + f"https://github.com/{owner}/{repo}/archive/refs/heads/main.zip" + ) logger.info(f"[LIVING_UI:MARKETPLACE] Downloading {app_id} from {zip_url}") @@ -2701,6 +2728,9 @@ async def install_from_marketplace( status="created", port=frontend_port, backend_port=backend_port, + marketplace_slug=marketplace_slug or app_id, + marketplace_version=version, + marketplace_commit_sha=git_commit_sha, ) self.projects[project_id] = project diff --git a/app/marketplace/__init__.py b/app/marketplace/__init__.py new file mode 100644 index 00000000..d343a78d --- /dev/null +++ b/app/marketplace/__init__.py @@ -0,0 +1,11 @@ +"""Marketplace catalog access. + +Talks to the CraftOS Marketplace server (catalog metadata: tags, stats, +ratings, featured banners) and falls back to the public GitHub +living-ui-marketplace repo when the server is unreachable or unconfigured. +""" + +from app.marketplace.client import MarketplaceClient +from app.marketplace.instance_id import get_instance_id + +__all__ = ["MarketplaceClient", "get_instance_id"] diff --git a/app/marketplace/client.py b/app/marketplace/client.py new file mode 100644 index 00000000..05e75046 --- /dev/null +++ b/app/marketplace/client.py @@ -0,0 +1,222 @@ +"""Client for the CraftOS Marketplace server. + +The server base URL is the production URL baked in below by default; a +non-empty "marketplace_server_url" in settings.json overrides it (e.g. +pointing at http://127.0.0.1:3000 for local development). Every read has +a GitHub fallback so the marketplace keeps working when the server is +down or unconfigured — responses from the fallback carry degraded=True so +the UI can hide server-only affordances (hero banners, ratings, downloads). +""" + +import asyncio +import logging +import time +from typing import Any, Dict, Optional + +import aiohttp +import certifi +import ssl + +from app.config import get_settings, get_app_version +from app.marketplace import github_fallback +from app.marketplace.instance_id import get_instance_id + +logger = logging.getLogger(__name__) + +# Production marketplace server. Shipped installs use this without any +# per-install config. TODO: set to the deployed domain (e.g. +# "https://marketplace.craftos.dev"); empty = GitHub-fallback until deployed. +DEFAULT_MARKETPLACE_SERVER_URL = "" + +REQUEST_TIMEOUT_SEC = 5 +# Circuit breaker: after this many consecutive failures, skip the server +# entirely (straight to fallback) for the cooldown period. +BREAKER_FAILURE_THRESHOLD = 2 +BREAKER_COOLDOWN_SEC = 60 + + +class MarketplaceClient: + def __init__(self) -> None: + self._consecutive_failures = 0 + self._skip_server_until = 0.0 + self._ssl_ctx = ssl.create_default_context(cafile=certifi.where()) + + @property + def base_url(self) -> str: + # settings.json override (dev/self-host) wins; else the baked default. + url = get_settings().get("marketplace_server_url") or DEFAULT_MARKETPLACE_SERVER_URL + return url.rstrip("/") + + def _server_available(self) -> bool: + if not self.base_url: + return False + return time.monotonic() >= self._skip_server_until + + def _record_failure(self) -> None: + self._consecutive_failures += 1 + if self._consecutive_failures >= BREAKER_FAILURE_THRESHOLD: + self._skip_server_until = time.monotonic() + BREAKER_COOLDOWN_SEC + logger.warning( + "[MARKETPLACE] Server unreachable %d times — using GitHub fallback " + "for the next %ds", + self._consecutive_failures, + BREAKER_COOLDOWN_SEC, + ) + + def _record_success(self) -> None: + self._consecutive_failures = 0 + + async def _get(self, path: str, params: Optional[Dict[str, str]] = None) -> Any: + """GET {base_url}{path}; raises on any failure.""" + headers = { + "X-CraftBot-Instance": get_instance_id(), + "User-Agent": f"CraftBot/{get_app_version()}", + } + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SEC) + connector = aiohttp.TCPConnector(ssl=self._ssl_ctx) + async with aiohttp.ClientSession( + timeout=timeout, connector=connector + ) as session: + async with session.get( + f"{self.base_url}{path}", params=params or {}, headers=headers + ) as resp: + if resp.status == 404: + return None + resp.raise_for_status() + return await resp.json() + + async def get_catalog( + self, + *, + product_type: str = "", + tag: str = "", + q: str = "", + featured: str = "", + sort: str = "", + page: str = "", + page_size: str = "", + ) -> Dict[str, Any]: + """Catalog list/search. Falls back to GitHub on server failure.""" + if self._server_available(): + params = { + k: v + for k, v in { + "type": product_type, + "tag": tag, + "q": q, + "featured": featured, + "sort": sort, + "page": page, + "pageSize": page_size, + }.items() + if v + } + try: + data = await self._get("/api/v1/catalog", params) + self._record_success() + if data is not None: + data.setdefault("degraded", False) + return data + except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as e: + logger.warning(f"[MARKETPLACE] Catalog fetch failed: {e}") + self._record_failure() + return await github_fallback.get_catalog_fallback( + q=q, tag=tag, product_type=product_type + ) + + async def get_product(self, slug: str) -> Optional[Dict[str, Any]]: + """Product detail. Falls back to a catalogue.json lookup.""" + if self._server_available(): + try: + data = await self._get(f"/api/v1/products/{slug}") + self._record_success() + if data is not None: + data.setdefault("degraded", False) + return data + # 404 from a healthy server is authoritative — the GitHub + # catalogue only knows living UIs that predate the server. + except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as e: + logger.warning(f"[MARKETPLACE] Product fetch failed: {e}") + self._record_failure() + return await github_fallback.get_product_fallback(slug) + else: + return data + return await github_fallback.get_product_fallback(slug) + + async def proxy_json( + self, + method: str, + path: str, + json_body: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, str]] = None, + extra_headers: Optional[Dict[str, str]] = None, + ) -> tuple: + """Forward a request to the marketplace server verbatim, attaching the + instance header. Returns (status, parsed_json). No GitHub fallback — + used for interactive reads/writes (ratings, comments) that only exist + on the server. Raises on network failure.""" + headers = { + "X-CraftBot-Instance": get_instance_id(), + "User-Agent": f"CraftBot/{get_app_version()}", + } + if extra_headers: + headers.update(extra_headers) + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SEC) + connector = aiohttp.TCPConnector(ssl=self._ssl_ctx) + try: + async with aiohttp.ClientSession( + timeout=timeout, connector=connector + ) as session: + async with session.request( + method, + f"{self.base_url}{path}", + json=json_body, + params=params or {}, + headers=headers, + ) as resp: + data = await resp.json(content_type=None) + self._record_success() + return resp.status, data + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + self._record_failure() + raise + + async def post_events(self, events: list) -> None: + """Report engagement events (view/click/install). Best-effort: + silently dropped when the server is unconfigured or unreachable.""" + if not self._server_available() or not events: + return + headers = { + "X-CraftBot-Instance": get_instance_id(), + "User-Agent": f"CraftBot/{get_app_version()}", + } + timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SEC) + connector = aiohttp.TCPConnector(ssl=self._ssl_ctx) + try: + async with aiohttp.ClientSession( + timeout=timeout, connector=connector + ) as session: + async with session.post( + f"{self.base_url}/api/v1/events", + json={"events": events[:50]}, + headers=headers, + ) as resp: + resp.raise_for_status() + self._record_success() + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + logger.debug(f"[MARKETPLACE] Event report dropped: {e}") + self._record_failure() + + async def get_banners(self) -> Dict[str, Any]: + """Hero/shelf banners. No fallback content — degraded mode has no hero.""" + if self._server_available(): + try: + data = await self._get("/api/v1/banners") + self._record_success() + if data is not None: + data.setdefault("degraded", False) + return data + except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as e: + logger.warning(f"[MARKETPLACE] Banners fetch failed: {e}") + self._record_failure() + return {"banners": [], "degraded": True} diff --git a/app/marketplace/github_fallback.py b/app/marketplace/github_fallback.py new file mode 100644 index 00000000..e361e282 --- /dev/null +++ b/app/marketplace/github_fallback.py @@ -0,0 +1,128 @@ +"""Direct-GitHub catalogue access (fallback path). + +The original marketplace source: catalogue.json in the public +living-ui-marketplace repo. Used when the marketplace server is +unconfigured or unreachable, and by the legacy WS handler so old +frontends keep working. +""" + +import asyncio +import json +import logging +import re +import ssl +import urllib.request +from typing import Any, Dict, List, Optional + +import certifi + +logger = logging.getLogger(__name__) + +MARKETPLACE_REPO = "CraftOS-dev/living-ui-marketplace" +RAW_BASE = f"https://raw.githubusercontent.com/{MARKETPLACE_REPO}/main" +CATALOGUE_URL = f"{RAW_BASE}/catalogue.json" + + +def fetch_catalogue_sync(timeout: int = 15) -> Dict[str, Any]: + """Fetch and parse catalogue.json from GitHub (blocking). + + Tolerates trailing commas (the catalogue is hand-edited JSON). + Raises on network/parse failure — callers decide how to degrade. + """ + ssl_ctx = ssl.create_default_context(cafile=certifi.where()) + req = urllib.request.Request(CATALOGUE_URL, headers={"User-Agent": "CraftBot"}) + response = urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx) + raw = response.read().decode() + raw = re.sub(r",\s*([}\]])", r"\1", raw) + return json.loads(raw) + + +async def fetch_catalogue(timeout: int = 15) -> Dict[str, Any]: + """Async wrapper: run the blocking fetch off the event loop.""" + return await asyncio.to_thread(fetch_catalogue_sync, timeout) + + +def catalogue_app_to_product(app: Dict[str, Any]) -> Dict[str, Any]: + """Map a catalogue.json entry to the marketplace product card shape. + + Mirrors the server's CardDTO so the frontend renders one shape in + both normal and degraded mode. Stats are zeroed — the GitHub path + has no metrics. + """ + folder = app.get("folder") or app.get("id", "") + return { + "slug": app.get("id") or folder, + "type": "living_ui", + "name": app.get("name", ""), + "tagline": app.get("description", ""), + "descriptionMd": app.get("description", ""), + "previewUrl": app.get("preview") + or (f"{RAW_BASE}/{folder}/thumbnail.png" if folder else None), + "screenshots": [], + "tags": app.get("tags") or [], + "approved": False, + "featured": False, + "repoPath": folder, + "customFields": app.get("customizable") or [], + "latestVersion": app.get("version"), + "creator": None, + "versions": [], + "stats": { + "views": 0, + "clicks": 0, + "downloads": 0, + "ratingAvg": 0, + "ratingCount": 0, + }, + } + + +def filter_products( + products: List[Dict[str, Any]], + *, + q: str = "", + tag: str = "", + product_type: str = "", +) -> List[Dict[str, Any]]: + """Apply catalog query params client-side (fallback has no DB).""" + result = products + if product_type: + result = [p for p in result if p["type"] == product_type] + if tag: + result = [p for p in result if tag in (p.get("tags") or [])] + if q: + needle = q.strip().lower() + result = [ + p + for p in result + if needle + in f"{p.get('name', '')} {p.get('tagline', '')} {' '.join(p.get('tags') or [])}".lower() + ] + return result + + +async def get_catalog_fallback( + *, q: str = "", tag: str = "", product_type: str = "" +) -> Dict[str, Any]: + """Catalog response built straight from GitHub, marked degraded.""" + catalogue = await fetch_catalogue() + products = [catalogue_app_to_product(a) for a in catalogue.get("apps", [])] + products = filter_products(products, q=q, tag=tag, product_type=product_type) + return { + "products": products, + "total": len(products), + "page": 1, + "pageSize": len(products), + "degraded": True, + } + + +async def get_product_fallback(slug: str) -> Optional[Dict[str, Any]]: + """Product detail from GitHub, or None if the slug isn't in the catalogue.""" + catalogue = await fetch_catalogue() + for app in catalogue.get("apps", []): + product = catalogue_app_to_product(app) + if product["slug"] == slug: + product["degraded"] = True + return product + return None diff --git a/app/marketplace/instance_id.py b/app/marketplace/instance_id.py new file mode 100644 index 00000000..ea870159 --- /dev/null +++ b/app/marketplace/instance_id.py @@ -0,0 +1,57 @@ +"""Persistent anonymous instance ID. + +A UUID4 generated once per CraftBot installation and sent to the +marketplace server as the X-CraftBot-Instance header. Pseudonymous: not +derived from and never linked to any personal data. +""" + +import json +import logging +import uuid +from datetime import datetime, timezone +from typing import Optional + +from app.config import APP_CONFIG_PATH + +logger = logging.getLogger(__name__) + +INSTANCE_FILE_PATH = APP_CONFIG_PATH / "instance.json" + +_instance_id_cache: Optional[str] = None + + +def get_instance_id() -> str: + """Return the persistent instance UUID, creating it on first call.""" + global _instance_id_cache + if _instance_id_cache: + return _instance_id_cache + + try: + if INSTANCE_FILE_PATH.exists(): + with open(INSTANCE_FILE_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + instance_id = data.get("instance_id", "") + if instance_id: + _instance_id_cache = instance_id + return instance_id + except (json.JSONDecodeError, OSError) as e: + logger.warning(f"[MARKETPLACE] Could not read instance.json, recreating: {e}") + + instance_id = str(uuid.uuid4()) + try: + INSTANCE_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(INSTANCE_FILE_PATH, "w", encoding="utf-8") as f: + json.dump( + { + "instance_id": instance_id, + "created_at": datetime.now(timezone.utc).isoformat(), + }, + f, + indent=2, + ) + except OSError as e: + # Non-fatal: a fresh ID per session is acceptable degradation. + logger.warning(f"[MARKETPLACE] Could not persist instance.json: {e}") + + _instance_id_cache = instance_id + return instance_id diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index f55dcbdf..7c4272ea 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1241,6 +1241,9 @@ async def _on_start(self) -> None: self._app.router.add_post( "/api/living-ui/import", self._living_ui_import_handler ) + self._app.router.add_get( + "/api/living-ui/{project_id}/favicon", self._living_ui_favicon_handler + ) # Loopback control endpoint for the livingui CLI (token-file guarded). # The server binds localhost, so this is unreachable from the network. self._app.router.add_post( @@ -1260,6 +1263,45 @@ async def _on_start(self) -> None: self._app.router.add_post("/api/profile/inspect", self._profile_inspect_handler) self._app.router.add_post("/api/profile/import", self._profile_import_handler) + # Marketplace proxy routes (catalog server with GitHub fallback) + from app.marketplace import MarketplaceClient + + self._marketplace_client = MarketplaceClient() + self._app.router.add_get( + "/api/marketplace/catalog", self._marketplace_catalog_handler + ) + self._app.router.add_get( + "/api/marketplace/products/{slug}", self._marketplace_product_handler + ) + self._app.router.add_get( + "/api/marketplace/banners", self._marketplace_banners_handler + ) + self._app.router.add_post( + "/api/marketplace/events", self._marketplace_events_handler + ) + self._app.router.add_get( + "/api/marketplace/products/{slug}/rating", + self._marketplace_rating_handler, + ) + self._app.router.add_put( + "/api/marketplace/products/{slug}/rating", + self._marketplace_rating_handler, + ) + self._app.router.add_get( + "/api/marketplace/products/{slug}/comments", + self._marketplace_comments_handler, + ) + self._app.router.add_post( + "/api/marketplace/products/{slug}/comments", + self._marketplace_comments_handler, + ) + self._app.router.add_post( + "/api/marketplace/skills/install", self._marketplace_skill_install_handler + ) + self._app.router.add_post( + "/api/marketplace/bundles/stage", self._marketplace_bundle_stage_handler + ) + # Integration bridge routes (Living UI → external APIs) from app.living_ui.integration_bridge import IntegrationBridge @@ -2012,10 +2054,18 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: app_name = data.get("appName", "") app_description = data.get("appDescription", "") custom_fields = data.get("customFields", {}) + # Pinned-version metadata from the marketplace server (optional — + # absent for legacy frontends and degraded/GitHub-only mode). + pinned = { + "download_url": data.get("downloadUrl"), + "version": data.get("version"), + "git_commit_sha": data.get("gitCommitSha"), + "marketplace_slug": data.get("slug"), + } # Run as background task so the WS loop stays unblocked for concurrent installs asyncio.create_task( self._handle_marketplace_install( - app_id, app_name, app_description, custom_fields + app_id, app_name, app_description, custom_fields, **pinned ) ) @@ -3069,6 +3119,114 @@ async def _handle_living_ui_delete(self, project_id: str) -> None: } ) + # Favicon files a project might ship, most-built-first. Parsed + # index.html takes precedence over this list. + _FAVICON_CANDIDATES = ( + "dist/favicon.ico", + "dist/favicon.svg", + "dist/favicon.png", + "public/favicon.ico", + "public/favicon.svg", + "public/favicon.png", + "frontend/dist/favicon.ico", + "frontend/public/favicon.ico", + "frontend/public/favicon.svg", + "favicon.ico", + "static/favicon.ico", + ) + _FAVICON_CONTENT_TYPES = { + ".ico": "image/x-icon", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + } + + def _resolve_living_ui_favicon(self, project_path: Path) -> Optional[Path]: + """Find a favicon file inside a project dir, on disk (works when the + app is stopped). Tries the in index.html first, + then a list of common paths. Returns None if none exist.""" + import re as _re + + root = project_path.resolve() + + # 1) Honor an explicit if it points at a + # local file within the project (not an absolute URL or a traversal). + for html_rel in ("dist/index.html", "index.html", "frontend/index.html"): + html_path = root / html_rel + if not html_path.is_file(): + continue + try: + html = html_path.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + for m in _re.finditer( + r']+rel=["\'][^"\']*icon[^"\']*["\'][^>]*>', html, _re.I + ): + href_m = _re.search(r'href=["\']([^"\']+)["\']', m.group(0), _re.I) + if not href_m: + continue + href = href_m.group(1) + if href.startswith(("http://", "https://", "//", "data:")): + continue + candidate = (html_path.parent / href.lstrip("/")).resolve() + try: + candidate.relative_to(root) # containment guard + except ValueError: + continue + if candidate.is_file(): + return candidate + + # 2) Fall back to conventional locations. + for rel in self._FAVICON_CANDIDATES: + candidate = (root / rel).resolve() + try: + candidate.relative_to(root) + except ValueError: + continue + if candidate.is_file(): + return candidate + return None + + async def _living_ui_favicon_handler( + self, request: "web.Request" + ) -> "web.Response": + """Serve a Living UI's favicon from its files on disk (so it shows + even when the app is stopped), falling back to a default icon when + the project ships none.""" + from aiohttp import web + from app.config import APP_DATA_PATH + + default_favicon = ( + APP_DATA_PATH / "living_ui_template" / "public" / "favicon.svg" + ) + + project_id = request.match_info["project_id"] + favicon_path = None + try: + project = self._living_ui_manager.get_project(project_id) + if project and project.path: + favicon_path = self._resolve_living_ui_favicon(Path(project.path)) + except Exception as e: + logger.debug(f"[LIVING_UI] Favicon lookup failed for {project_id}: {e}") + + served = favicon_path if favicon_path is not None else default_favicon + if not served.is_file(): + return web.Response(status=404) + + content_type = self._FAVICON_CONTENT_TYPES.get( + served.suffix.lower(), "application/octet-stream" + ) + return web.FileResponse( + served, + headers={ + "Content-Type": content_type, + # Short cache: a rebuilt app can change its favicon. + "Cache-Control": "public, max-age=300", + }, + ) + async def _living_ui_export_handler(self, request: "web.Request") -> "web.Response": """HTTP handler: download a Living UI project as a ZIP file.""" from aiohttp import web @@ -3417,6 +3575,13 @@ async def _profile_import_handler(self, request: "web.Request") -> "web.Response except Exception: pass + # Bundles staged from the marketplace carry a slug for the downloads stat. + marketplace_slug = payload.get("marketplace_slug") or "" + if marketplace_slug: + self._report_marketplace_install( + marketplace_slug, bool(result.get("success", True)) + ) + return web.json_response(result) def _write_living_ui_control_token(self) -> str: @@ -7303,26 +7468,15 @@ async def _handle_playbook_list(self) -> None: # ===================== async def _handle_marketplace_list(self) -> None: - """Fetch marketplace catalogue from GitHub.""" - import urllib.request - import json as _json - import re as _re + """Fetch marketplace catalogue from GitHub (legacy WS path). - CATALOGUE_URL = "https://raw.githubusercontent.com/CraftOS-dev/living-ui-marketplace/main/catalogue.json" + Kept for older frontends; the Marketplace page uses the + /api/marketplace/* proxy routes instead. + """ + from app.marketplace import github_fallback try: - import ssl - import certifi - - ssl_ctx = ssl.create_default_context(cafile=certifi.where()) - req = urllib.request.Request( - CATALOGUE_URL, headers={"User-Agent": "CraftBot"} - ) - response = urllib.request.urlopen(req, timeout=15, context=ssl_ctx) - raw = response.read().decode() - # Strip trailing commas before ] or } (tolerant of hand-edited JSON) - raw = _re.sub(r",\s*([}\]])", r"\1", raw) - catalogue = _json.loads(raw) + catalogue = await github_fallback.fetch_catalogue() await self._broadcast( { "type": "living_ui_marketplace_list", @@ -7337,12 +7491,230 @@ async def _handle_marketplace_list(self) -> None: } ) + async def _marketplace_catalog_handler(self, request: web.Request) -> web.Response: + """Proxy: catalog list/search from the marketplace server (or fallback).""" + from aiohttp import web + + try: + data = await self._marketplace_client.get_catalog( + product_type=request.query.get("type", ""), + tag=request.query.get("tag", ""), + q=request.query.get("q", ""), + featured=request.query.get("featured", ""), + sort=request.query.get("sort", ""), + page=request.query.get("page", ""), + page_size=request.query.get("pageSize", ""), + ) + return web.json_response(data) + except Exception as e: + logger.error(f"[MARKETPLACE] Catalog unavailable (server and fallback): {e}") + return web.json_response( + {"error": str(e), "products": [], "total": 0, "degraded": True}, + status=502, + ) + + async def _marketplace_product_handler(self, request: web.Request) -> web.Response: + """Proxy: single product detail.""" + from aiohttp import web + + slug = request.match_info["slug"] + try: + data = await self._marketplace_client.get_product(slug) + if data is None: + return web.json_response({"error": "Product not found"}, status=404) + return web.json_response(data) + except Exception as e: + logger.error(f"[MARKETPLACE] Product '{slug}' unavailable: {e}") + return web.json_response({"error": str(e)}, status=502) + + async def _marketplace_banners_handler(self, request: web.Request) -> web.Response: + """Proxy: hero/shelf banners (empty in degraded mode).""" + from aiohttp import web + + try: + data = await self._marketplace_client.get_banners() + return web.json_response(data) + except Exception as e: + logger.error(f"[MARKETPLACE] Banners unavailable: {e}") + return web.json_response({"banners": [], "degraded": True}, status=200) + + async def _marketplace_events_handler(self, request: web.Request) -> web.Response: + """Proxy: engagement events (view/click/install). Fire-and-forget — + respond immediately and forward in the background; events are + best-effort and dropped when the server is unreachable.""" + from aiohttp import web + from app.config import get_settings + + if not get_settings().get("marketplace_telemetry_enabled", True): + return web.json_response({"ok": True, "disabled": True}, status=202) + + try: + body = await request.json() + events = body.get("events", []) + if isinstance(events, list) and events: + asyncio.create_task(self._marketplace_client.post_events(events)) + except Exception as e: + logger.debug(f"[MARKETPLACE] Bad events payload: {e}") + return web.json_response({"ok": True}, status=202) + + async def _marketplace_proxy_passthrough( + self, request: web.Request, path: str + ) -> web.Response: + """Forward an interactive marketplace request (ratings/comments). + These are server-only features — 503 in degraded/offline mode. + + On hosted (CraftBot Live) instances the browser sends the + dashboard-minted cb_access cookie with every request; forwarding it + lets the marketplace server verify the user with the dashboard. + OSS/local installs have no such cookie → writes come back 401 and + the UI shows the read-only state.""" + from aiohttp import web + + try: + body = None + if request.method in ("POST", "PUT"): + body = await request.json() + extra_headers = None + cb_access = request.cookies.get("cb_access") + if cb_access: + extra_headers = {"X-CB-Access": cb_access} + status, data = await self._marketplace_client.proxy_json( + request.method, + path, + json_body=body, + params=dict(request.query), + extra_headers=extra_headers, + ) + return web.json_response(data, status=status) + except Exception as e: + logger.warning(f"[MARKETPLACE] {request.method} {path} failed: {e}") + return web.json_response( + {"error": "Marketplace server unavailable"}, status=503 + ) + + def _report_marketplace_install(self, slug: str, success: bool) -> None: + """Best-effort install telemetry (downloads stat), fire-and-forget.""" + from app.config import get_settings + + if not slug or not get_settings().get("marketplace_telemetry_enabled", True): + return + outcome = "install" if success else "install_failed" + asyncio.create_task( + self._marketplace_client.post_events([{"slug": slug, "type": outcome}]) + ) + + async def _marketplace_skill_install_handler( + self, request: web.Request + ) -> web.Response: + """Install a skill product: git-clone via the existing skill installer.""" + from aiohttp import web + from app.ui_layer.settings.skill_settings import install_skill_from_git + + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + git_url = (payload.get("gitUrl") or "").strip() + slug = payload.get("slug") or "" + if not git_url.startswith(("https://", "http://", "git@")): + return web.json_response( + {"error": "A git URL is required to install this skill"}, status=400 + ) + + # install_skill_from_git shells out to git clone — keep it off the loop. + success, message = await asyncio.to_thread(install_skill_from_git, git_url) + self._report_marketplace_install(slug, success) + return web.json_response( + {"success": success, "message": message}, + status=200 if success else 422, + ) + + async def _marketplace_bundle_stage_handler( + self, request: web.Request + ) -> web.Response: + """Download a .craftbot bundle from the marketplace and stage it for + import. Returns the same inspect payload + bundle_token as + /api/profile/inspect, so the existing /api/profile/import applies it.""" + from aiohttp import web + from app.ui_layer.settings.profile_bundle import inspect_bundle + + try: + payload = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + url = (payload.get("downloadUrl") or "").strip() + if not url.startswith(("https://", "http://")): + return web.json_response( + {"error": "This bundle has no download URL"}, status=400 + ) + + MAX_BUNDLE_BYTES = 100 * 1024 * 1024 + + def download() -> bytes: + import ssl + import urllib.request + + import certifi + + ssl_ctx = ssl.create_default_context(cafile=certifi.where()) + req = urllib.request.Request(url, headers={"User-Agent": "CraftBot"}) + with urllib.request.urlopen(req, timeout=60, context=ssl_ctx) as resp: + data = resp.read(MAX_BUNDLE_BYTES + 1) + if len(data) > MAX_BUNDLE_BYTES: + raise ValueError("Bundle exceeds the 100MB size limit") + return data + + import tempfile + + tmp_path = None + try: + bundle_bytes = await asyncio.to_thread(download) + with tempfile.NamedTemporaryFile( + suffix=".craftbot", prefix="craftbot_mkt_bundle_", delete=False + ) as tmp: + tmp.write(bundle_bytes) + tmp_path = tmp.name + result = inspect_bundle(tmp_path) + if not result.get("success", False): + return web.json_response(result, status=422) + token = str(uuid.uuid4()) + self._staged_bundles[token] = bundle_bytes + result["bundle_token"] = token + return web.json_response(result) + except Exception as exc: + logger.error(f"[MARKETPLACE] Bundle stage failed: {exc}") + return web.json_response({"error": str(exc)}, status=502) + finally: + if tmp_path: + try: + Path(tmp_path).unlink(missing_ok=True) + except Exception: + pass + + async def _marketplace_rating_handler(self, request: web.Request) -> web.Response: + slug = request.match_info["slug"] + return await self._marketplace_proxy_passthrough( + request, f"/api/v1/products/{slug}/rating" + ) + + async def _marketplace_comments_handler(self, request: web.Request) -> web.Response: + slug = request.match_info["slug"] + return await self._marketplace_proxy_passthrough( + request, f"/api/v1/products/{slug}/comments" + ) + async def _handle_marketplace_install( self, app_id: str, app_name: str, app_description: str, custom_fields: dict = None, + download_url: str = None, + version: str = None, + git_commit_sha: str = None, + marketplace_slug: str = None, ) -> None: """Install a marketplace app.""" if not app_id or not app_name: @@ -7385,6 +7757,18 @@ async def _handle_marketplace_install( app_description=app_description, custom_fields=custom_fields, project_id=project_id, + download_url=download_url, + version=version, + git_commit_sha=git_commit_sha, + marketplace_slug=marketplace_slug, + ) + + # Report the install outcome to the marketplace server (downloads + # stat). Backend-side because installs outlive the marketplace page: + # the user usually navigates to the new tab before the install + # finishes, so a frontend listener would miss the result. + self._report_marketplace_install( + marketplace_slug or app_id, result.get("status") == "success" ) if result.get("status") == "success": diff --git a/app/ui_layer/browser/frontend/src/App.tsx b/app/ui_layer/browser/frontend/src/App.tsx index 8ca19433..8c1e6027 100644 --- a/app/ui_layer/browser/frontend/src/App.tsx +++ b/app/ui_layer/browser/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { WorkspacePage } from './pages/Workspace' import { SettingsPage } from './pages/Settings' import { OnboardingPage } from './pages/Onboarding' import { LivingUIPage } from './pages/LivingUI' +import { MarketplacePage, ProductDetailPage } from './pages/Marketplace' import { useWebSocket } from './contexts/WebSocketContext' // Forces LivingUIPage to remount per-project so useState initializers @@ -71,6 +72,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.module.css b/app/ui_layer/browser/frontend/src/components/layout/NavBar.module.css index d4743582..74cd6f37 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.module.css +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.module.css @@ -47,7 +47,9 @@ background: transparent; color: var(--text-tertiary); cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast); + transition: + background var(--transition-fast), + color var(--transition-fast); } .collapseButton:hover { @@ -73,23 +75,23 @@ padding-left: 0; } +.collapsed .navItem, +.collapsed .livingUITab, +.collapsed .navRight { + padding-left: 0; + padding-right: 0; +} + +/* Collapsed rail is icon-only: hide labels, center every icon */ .collapsed .label, -.collapsed .livingUITabLabel, -.collapsed .addLivingUILabel { +.collapsed .livingUITabLabel { display: none; } .collapsed .navItem, -.collapsed .livingUITab, -.collapsed .addLivingUIButton { +.collapsed .livingUITab { justify-content: center; gap: 0; - padding: var(--space-2) 0; -} - -.collapsed .navRight { - padding-left: 0; - padding-right: 0; } /* Scroll area wrapper holds the scrollable content + fade overlays */ @@ -165,12 +167,20 @@ .fadeLeft { top: 0; - background: linear-gradient(to bottom, var(--sidebar-bg) 0%, var(--sidebar-bg-transparent) 100%); + background: linear-gradient( + to bottom, + var(--sidebar-bg) 0%, + var(--sidebar-bg-transparent) 100% + ); } .fadeRight { bottom: 0; - background: linear-gradient(to top, var(--sidebar-bg) 0%, var(--sidebar-bg-transparent) 100%); + background: linear-gradient( + to top, + var(--sidebar-bg) 0%, + var(--sidebar-bg-transparent) 100% + ); } .fadeVisible { @@ -240,7 +250,9 @@ font-weight: var(--font-medium); cursor: pointer; white-space: nowrap; - transition: background var(--transition-fast), color var(--transition-fast); + transition: + background var(--transition-fast), + color var(--transition-fast); flex-shrink: 0; width: 100%; text-align: left; @@ -267,6 +279,13 @@ flex-shrink: 0; } +.livingUITabFavicon { + width: 14px; + height: 14px; + object-fit: contain; + border-radius: 3px; +} + .livingUITabLabel { flex: 1 1 auto; overflow: hidden; @@ -275,44 +294,87 @@ } /* Add Living UI button — subtle, looks like a muted nav item */ -.addLivingUIButton { +/* Fixed block above the Living UI scroll region */ +.topNav { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--space-1); + flex-shrink: 0; +} + +/* Marketplace: brand-accent button pinned above the bottom toolbar */ +.marketplaceButtonWrapper { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + margin: auto; +} + +.marketplaceButton { display: flex; align-items: center; + justify-content: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); + margin: var(--space-2) var(--space-2); border: none; border-radius: var(--radius-md); - background: transparent; - color: var(--text-tertiary); + background: var(--color-primary); + color: #fff; font-size: var(--text-sm); font-weight: var(--font-medium); cursor: pointer; white-space: nowrap; - transition: background var(--transition-fast), color var(--transition-fast); + transition: filter var(--transition-fast); flex-shrink: 0; - width: 100%; + width: 80%; + align-self: stretch; text-align: left; } -.addLivingUIButton:hover { - color: var(--text-secondary); - background: var(--bg-tertiary); +.marketplaceButton:hover { + filter: brightness(1.08); +} + +.marketplaceButtonActive { + filter: brightness(0.92); +} + +.marketplaceButtonInternal { + display: flex; + align-items: center; + gap: var(--space-2); + flex: 1 1 auto; + min-width: 0; } -.addLivingUIIcon { +.marketplaceButtonInternal .icon { flex-shrink: 0; - transition: transform 0.3s ease; } -.addLivingUIButton:hover .addLivingUIIcon { - transform: rotate(20deg) scale(1.15); +/* Collapsed: icon-only orange square, centered */ +.collapsed .marketplaceButtonWrapper { + margin: 0; } -.addLivingUILabel { - white-space: nowrap; - flex: 1 1 auto; - overflow: hidden; - text-overflow: ellipsis; +.collapsed .marketplaceButton { + width: auto; + align-self: center; + justify-content: center; + padding: var(--space-2); + margin: var(--space-2) 0; +} + +.collapsed .marketplaceButtonInternal { + flex: 0 0 auto; + gap: 0; + justify-content: center; +} + +.collapsed .marketplaceButton .label { + display: none; } /* Spinner animation */ diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx index f9912c44..55ad6578 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx @@ -1,100 +1,128 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { MessageSquare, ListTodo, LayoutDashboard, FolderOpen, Settings, - Sparkles, Box, Loader2, PanelLeftClose, - PanelLeftOpen -} from 'lucide-react' -import { useWebSocket } from '../../contexts/WebSocketContext' -import { useTheme } from '../../contexts/ThemeContext' -import { CreateLivingUIModal } from '../ui/CreateLivingUIModal' -import type { LivingUICreateRequest } from '../../types' -import { TopBar } from './TopBar' -import styles from './NavBar.module.css' + PanelLeftOpen, + Store, +} from "lucide-react"; +import { useWebSocket } from "../../contexts/WebSocketContext"; +import { useTheme } from "../../contexts/ThemeContext"; +import { TopBar } from "./TopBar"; +import type { LivingUIProject } from "../../types"; +import styles from "./NavBar.module.css"; + +/** + * Sidebar tab icon for a Living UI: the app's own favicon, served by the + * backend from the project's files on disk (so it shows even when the app + * is stopped). The backend falls back to a default icon when the project + * ships none; the box icon here is only a last resort if the request fails. + */ +function LivingUITabFavicon({ project }: { project: LivingUIProject }) { + const [failed, setFailed] = useState(false); + if (failed) return ; + return ( + setFailed(true)} + /> + ); +} interface NavItem { - id: string - label: string - icon: React.ReactNode - path: string + id: string; + label: string; + icon: React.ReactNode; + path: string; } const leftNavItems: NavItem[] = [ - { id: 'chat', label: 'Chat', icon: , path: '/' }, - { id: 'tasks', label: 'Tasks', icon: , path: '/tasks' }, - { id: 'dashboard', label: 'Dashboard', icon: , path: '/dashboard' }, - { id: 'workspace', label: 'Workspace', icon: , path: '/workspace' }, -] - -const settingsItem: NavItem = { id: 'settings', label: 'Settings', icon: , path: '/settings' } + { id: "chat", label: "Chat", icon: , path: "/" }, + { id: "tasks", label: "Tasks", icon: , path: "/tasks" }, + { + id: "dashboard", + label: "Dashboard", + icon: , + path: "/dashboard", + }, + { + id: "workspace", + label: "Workspace", + icon: , + path: "/workspace", + }, +]; + +const settingsItem: NavItem = { + id: "settings", + label: "Settings", + icon: , + path: "/settings", +}; interface NavBarProps { - collapsed?: boolean - onToggleCollapsed?: () => void + collapsed?: boolean; + onToggleCollapsed?: () => void; } export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { - const location = useLocation() - const navigate = useNavigate() - const { livingUIProjects, createLivingUI } = useWebSocket() - const { theme } = useTheme() - const [showCreateModal, setShowCreateModal] = useState(false) + const location = useLocation(); + const navigate = useNavigate(); + const { livingUIProjects } = useWebSocket(); + const { theme } = useTheme(); - const logoSrc = theme === 'light' - ? '/craftbot_logo_text_no_border_light.png' - : '/craftbot_logo_text_no_border_dark.png' + const logoSrc = + theme === "light" + ? "/craftbot_logo_text_no_border_light.png" + : "/craftbot_logo_text_no_border_dark.png"; - const scrollRef = useRef(null) + const scrollRef = useRef(null); - const [canScrollUp, setCanScrollUp] = useState(false) - const [canScrollDown, setCanScrollDown] = useState(false) + const [canScrollUp, setCanScrollUp] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(false); const isActive = (path: string) => { - if (path === '/') { - return location.pathname === '/' + if (path === "/") { + return location.pathname === "/"; } - return location.pathname.startsWith(path) - } - - const handleCreateSubmit = (data: LivingUICreateRequest) => { - createLivingUI(data) - setShowCreateModal(false) - } + return location.pathname.startsWith(path); + }; const updateOverflow = () => { - const el = scrollRef.current - if (!el) return - const maxScroll = el.scrollHeight - el.clientHeight - setCanScrollUp(el.scrollTop > 1) - setCanScrollDown(el.scrollTop < maxScroll - 1) - } + const el = scrollRef.current; + if (!el) return; + const maxScroll = el.scrollHeight - el.clientHeight; + setCanScrollUp(el.scrollTop > 1); + setCanScrollDown(el.scrollTop < maxScroll - 1); + }; useLayoutEffect(() => { - updateOverflow() - }, [livingUIProjects.length]) + updateOverflow(); + }, [livingUIProjects.length]); useEffect(() => { - const el = scrollRef.current - if (!el || typeof ResizeObserver === 'undefined') return - const ro = new ResizeObserver(updateOverflow) - ro.observe(el) - window.addEventListener('resize', updateOverflow) + const el = scrollRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(updateOverflow); + ro.observe(el); + window.addEventListener("resize", updateOverflow); return () => { - ro.disconnect() - window.removeEventListener('resize', updateOverflow) - } - }, []) + ro.disconnect(); + window.removeEventListener("resize", updateOverflow); + }; + }, []); return ( <> -