diff --git a/admin/backend/api/v1/apps.py b/admin/backend/api/v1/apps.py index 193102836..cbe965028 100644 --- a/admin/backend/api/v1/apps.py +++ b/admin/backend/api/v1/apps.py @@ -3,7 +3,7 @@ from dataclasses import asdict from pathlib import Path -from flask import Blueprint, current_app, jsonify, request +from flask import Blueprint, current_app, jsonify, request, send_file from admin.backend.api.responses import accepted_task_response, error_response from admin.backend.providers.apps import AppProvider @@ -30,6 +30,21 @@ def index(): return jsonify([asdict(a) for a in apps]) +@apps_bp.get("//logo") +def app_logo(name: str): + """Serve a custom app's logo.svg/png from the cloned apps/ tree.""" + err = validate_app_name(name) + if err: + return error_response("invalid_app", err, 422) + + bench_root = Path(current_app.config["BENCH_ROOT"]) + provider = AppProvider(bench_root) + logo_path = provider.find_logo_path(bench_root / "apps" / name, name) + if not logo_path: + return error_response("logo_not_found", f"No logo found for '{name}'.", 404) + return send_file(logo_path) + + @marketplace_bp.get("/apps") def marketplace(): bench_root = Path(current_app.config["BENCH_ROOT"]) diff --git a/admin/backend/api/v1/sites/apps.py b/admin/backend/api/v1/sites/apps.py index fc4474d0e..304381629 100644 --- a/admin/backend/api/v1/sites/apps.py +++ b/admin/backend/api/v1/sites/apps.py @@ -64,6 +64,7 @@ def site_apps(name: str): "name": app_name, "title": info.title, "description": info.description, + "logo_url": info.logo_url, "branch": info.branch, "commit": info.current_commit, "version": info.installed_version, diff --git a/admin/backend/providers/apps.py b/admin/backend/providers/apps.py index 1c3905c6a..bd96b3857 100644 --- a/admin/backend/providers/apps.py +++ b/admin/backend/providers/apps.py @@ -21,6 +21,7 @@ class AppInfo: has_local_changes: bool installed_version: str has_update: bool + logo_url: str = "" class AppProvider: @@ -40,6 +41,7 @@ def get_app(self, name: str) -> AppInfo: app_path = self._bench_root / "apps" / name repo = GitRepo(app_path) title, description = self.get_pyproject_meta(app_path, name) + logo_path = self.find_logo_path(app_path, name) app_info = AppInfo( name=name, @@ -53,6 +55,7 @@ def get_app(self, name: str) -> AppInfo: has_local_changes=False, installed_version=installed_app_version(self._bench_root / "env", name), has_update=False, + logo_url=f"/api/v1/apps/{name}/logo" if logo_path else "", ) if not repo.is_cloned: return app_info @@ -69,15 +72,58 @@ def get_app(self, name: str) -> AppInfo: return app_info def get_pyproject_meta(self, app_path: Path, name: str) -> tuple[str, str]: - """Title and description from pyproject.toml, defaulting to the folder name.""" + """Title and description from pyproject.toml. + + Prefer `[tool.bench].app_title` (human label). Fall back to the folder / + package name so the UI can sentence-case it. + """ pyproject = app_path / "pyproject.toml" if not pyproject.exists(): return name, "" try: - project = tomllib.loads(pyproject.read_text()).get("project") or {} + data = tomllib.loads(pyproject.read_text()) except (tomllib.TOMLDecodeError, OSError): return name, "" - title = (project.get("name") or "").strip() or name - description = (project.get("description") or "").strip() + + project = data.get("project") or {} + bench = ((data.get("tool") or {}).get("bench") or {}) + title = (bench.get("app_title") or "").strip() or name + description = ( + (project.get("description") or "").strip() + or (bench.get("app_description") or "").strip() + ) return title, description + + def find_logo_path(self, app_path: Path, name: str) -> Path | None: + """Resolve a local app logo for Pilot marketplace / apps list.""" + pyproject = app_path / "pyproject.toml" + declared = "" + if pyproject.exists(): + try: + data = tomllib.loads(pyproject.read_text()) + declared = str((((data.get("tool") or {}).get("bench") or {}).get("app_logo") or "")).strip() + except (tomllib.TOMLDecodeError, OSError): + declared = "" + + candidates: list[Path] = [] + if declared: + # Paths in pyproject are usually relative to the repo root. + candidates.append(app_path / declared) + # Also accept module-relative paths written as rozh_fieldops/public/... + if declared.startswith(f"{name}/"): + candidates.append(app_path / declared) + candidates.extend( + [ + app_path / "logo.svg", + app_path / "logo.png", + app_path / name / "public" / "logo.svg", + app_path / name / "public" / "logo.png", + app_path / name / "public" / "images" / f"{name.replace('_', '-')}-logo.svg", + app_path / name / "public" / "images" / f"{name}-logo.svg", + ] + ) + for path in candidates: + if path.is_file(): + return path + return None diff --git a/admin/frontend/dashboard/src/composables/apps/useMarketplace.js b/admin/frontend/dashboard/src/composables/apps/useMarketplace.js index afc7bef95..67ef9a844 100644 --- a/admin/frontend/dashboard/src/composables/apps/useMarketplace.js +++ b/admin/frontend/dashboard/src/composables/apps/useMarketplace.js @@ -145,8 +145,11 @@ export function useMarketplace(initialSiteName = '') { .filter((app) => app.name !== 'frappe' && !registryNames.value.has(app.name)) .map((app) => ({ name: app.name, - title: toSentenceCase(app.title || app.name), + // Prefer explicit app_title from pyproject; only sentence-case the raw package name. + title: + app.title && app.title !== app.name ? app.title : toSentenceCase(app.name), description: app.description, + logo_url: app.logo_url || '', compatible: true, inBench: true, })),