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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
}


Expand Down
2 changes: 2 additions & 0 deletions app/data/living_ui_template/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.ico" />
<title>{{PROJECT_NAME}}</title>
</head>
<body>
Expand Down
Binary file added app/data/living_ui_template/public/favicon.ico
Binary file not shown.
4 changes: 4 additions & 0 deletions app/data/living_ui_template/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 36 additions & 6 deletions app/living_ui/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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/<sha>)
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
Expand All @@ -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}")

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/marketplace/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
222 changes: 222 additions & 0 deletions app/marketplace/client.py
Original file line number Diff line number Diff line change
@@ -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}
Loading