Skip to content
Closed
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
17 changes: 16 additions & 1 deletion admin/backend/api/v1/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +30,21 @@ def index():
return jsonify([asdict(a) for a in apps])


@apps_bp.get("/<name>/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"])
Expand Down
1 change: 1 addition & 0 deletions admin/backend/api/v1/sites/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 50 additions & 4 deletions admin/backend/providers/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class AppInfo:
has_local_changes: bool
installed_version: str
has_update: bool
logo_url: str = ""


class AppProvider:
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Comment on lines 96 to +129

@tanmoysrt tanmoysrt Aug 15, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to not introduce any custom block in pyproject.yaml. Also, avoid any random search for logo.

In frappe apps, their are multiple ways to set app logo is by setting app_logo_url or add_to_app_screen in hooks.py . We should interpret those.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down