diff --git a/README.md b/README.md
index 50629c0..4f24c99 100644
--- a/README.md
+++ b/README.md
@@ -335,6 +335,31 @@ never have to.
Every static mount (app frontends, the landing app, `mode="static"` apps, shared assets) sends `Cache-Control: no-cache` on HTML documents — including SPA fallbacks, `304` revalidations and an `html=True` `404.html` page — so browsers revalidate a page (a cheap `304` via its `ETag`) instead of heuristically reusing an old build whose HTML still names the previous asset URLs. Non-HTML assets are untouched, and a `Cache-Control` already set on a response is never overridden. The class is `enlace.frontend.RevalidatingStaticFiles` (`html_cache_control=None` opts out).
+### App icons (tab, home screen, launcher)
+
+Every app gets one icon, served in every form a browser or phone asks for:
+`/_apps/{name}/icon` (as-is), `/_apps/{name}/icon-{32,180,192,512}.png`
+(square PNGs, needs `pip install enlace[icons]`), and a generated
+`/_apps/{name}/manifest.webmanifest`. enlace adds the matching ``,
+`` and `` to each app's HTML
+wherever the app's own `
` lacks them, so the launcher tile, the browser tab
+and "Add to Home Screen" (iOS and Android) all show the same image.
+
+The icon is picked once per app: live launcher edit > `[app_meta.apps.].icon`
+> `.png` in `[app_meta].icons_dir` > the app's own icon (from its
+``, manifest or `icon.png`) > a letter monogram. An icon from the platform
+tiers replaces the page's own icon links; an app's own icon only fills gaps.
+
+```toml
+[app_meta]
+icons_dir = "static/app_icons" # one folder of .png, owned by the platform
+manifest_display = "browser" # or "standalone"
+```
+
+Home-screen icons must be raster: an app whose icon is only an SVG, emoji or
+monogram gets a favicon but no PNG. `enlace.app_icons.home_screen_gaps(config)`
+lists those apps.
+
### Deploy manifest (`/_meta`)
enlace answers "what is actually deployed?" via an always-on, cheap manifest
diff --git a/enlace/__main__.py b/enlace/__main__.py
index 84674bd..6bb92b6 100644
--- a/enlace/__main__.py
+++ b/enlace/__main__.py
@@ -221,6 +221,13 @@ def check(
warnings: list[str] = []
for app in config.apps:
warnings.extend(validate_build(app))
+ from enlace.app_icons import home_screen_gaps
+
+ warnings.extend(
+ f"{name}: icon has no PNG form, so no home-screen icon "
+ "(add a PNG to [app_meta].icons_dir)"
+ for name in home_screen_gaps(config)
+ )
if json:
print(json_module.dumps({"errors": errors, "warnings": warnings}, indent=2))
diff --git a/enlace/app_icons.py b/enlace/app_icons.py
new file mode 100644
index 0000000..3c0c867
--- /dev/null
+++ b/enlace/app_icons.py
@@ -0,0 +1,369 @@
+"""One icon per app, served in every form a browser or phone asks for.
+
+An app's icon appears in four places: the launcher grid, the browser tab
+(favicon), the iOS home screen (``apple-touch-icon``, PNG only) and the Android
+/ Chrome home screen (the web-app manifest's ``icons``). Left to each app, those
+four drift apart — most apps declare none of them, and the few that do declare
+different subsets. So the platform owns them, once:
+
+- **Source** — :func:`icon_source` picks one icon spec per app, in tier order:
+ runtime overlay (Tier A) > ``[app_meta.apps.].icon`` (Tier B) > a file
+ named ``.`` in ``[app_meta].icons_dir`` (Tier B, by convention) >
+ the app's own declared/harvested icon (Tier C) > ``default_icon`` > monogram.
+- **Renditions** — ``/_apps/{name}/icon`` serves the source as-is;
+ ``/_apps/{name}/icon-{size}.png`` serves square PNGs (:data:`PNG_SIZES`) cut
+ from a *raster* source; ``/_apps/{name}/manifest.webmanifest`` is generated.
+- **Wiring** — :class:`AppIconLinksMiddleware` adds the ```` tags an app's
+ HTML is missing. When the icon comes from the platform tiers (A/B), the page's
+ own icon links are replaced, so the owner's choice wins everywhere; otherwise
+ the app's own links stand and only the gaps are filled.
+
+PNG renditions need Pillow and a raster source (PNG/JPEG/WebP/GIF/ICO). A
+vector-only icon (SVG, emoji glyph, monogram) still serves as the favicon, but
+has no PNG form — so no home-screen icon. Give such an app a PNG in
+``icons_dir``; :func:`home_screen_gaps` lists them.
+"""
+
+from __future__ import annotations
+
+import html
+import io
+import json
+import re
+from pathlib import Path
+from typing import Optional
+
+from enlace.html_rewrite import HEAD_CLOSE_RE, inject_into_head, rewrite_html_response
+
+# favicon (32), apple-touch-icon (180), manifest (192, 512 — Chrome's install bar).
+PNG_SIZES = (32, 180, 192, 512)
+APPLE_TOUCH_SIZE = 180
+MANIFEST_SIZES = (192, 512)
+
+_RASTER_TYPES = {
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/gif",
+ "image/x-icon",
+ "image/vnd.microsoft.icon",
+}
+_ICONS_DIR_EXTS = (".png", ".webp", ".jpg", ".jpeg", ".svg")
+
+# , ,
+_ICON_LINK_RE = re.compile(
+ rb"]*\brel\s*=\s*[\"']?(?:shortcut\s+)?(?:icon|apple-touch-icon(?:-precomposed)?)"
+ rb"[\"'\s>][^>]*>\s*",
+ re.IGNORECASE,
+)
+_MANIFEST_LINK_RE = re.compile(
+ rb"]*\brel\s*=\s*[\"']?manifest[\"'\s>]", re.IGNORECASE
+)
+_APPLE_TITLE_RE = re.compile(
+ rb"]*\bname\s*=\s*[\"']?apple-mobile-web-app-title", re.IGNORECASE
+)
+
+
+# ---------------------------------------------------------------------------
+# Source: which icon an app has
+# ---------------------------------------------------------------------------
+
+
+def icons_dir_file(icons_dir: Optional[Path], name: str) -> Optional[str]:
+ """The filename of ``.`` in the platform icons dir, if present.
+
+ Raster formats are probed before SVG, so an app with both gets PNG
+ renditions (the SVG can sit beside it as the editable source).
+ """
+ if icons_dir is None:
+ return None
+ for ext in _ICONS_DIR_EXTS:
+ if (icons_dir / f"{name}{ext}").is_file():
+ return f"{name}{ext}"
+ return None
+
+
+def icon_source(app, config, overlay: dict) -> tuple[str, Optional[Path], bool]:
+ """Return ``(spec, root, platform_owned)`` for one app's icon.
+
+ ``root`` is the directory a file spec is resolved (and contained) under.
+ ``platform_owned`` is True when the icon comes from the platform tiers
+ (overlay, ``[app_meta.apps]``, ``icons_dir``) rather than the app itself —
+ the signal that the page's own icon links should be replaced.
+ """
+ from enlace.appmeta import app_dir_of
+
+ meta = config.app_meta
+ tier_b = meta.apps.get(app.name)
+ app_dir = app_dir_of(app)
+ if overlay.get("icon"):
+ return overlay["icon"], app_dir, True
+ if tier_b and tier_b.icon:
+ return tier_b.icon, app_dir, True
+ in_dir = icons_dir_file(meta.icons_dir, app.name)
+ if in_dir:
+ return in_dir, meta.icons_dir, True
+ return (app.icon or meta.default_icon or ""), app_dir, False
+
+
+def display_name_of(app, config, overlay: dict) -> str:
+ """The app's display name across Tier A / B / C (same order as ``/_apps``)."""
+ tier_b = config.app_meta.apps.get(app.name)
+ return (
+ overlay.get("display_name")
+ or (tier_b.display_name if tier_b else None)
+ or app.display_name
+ )
+
+
+def resolve_app_icon(app, config, overlay: dict, *, token_only: bool = False):
+ """Resolve an app's icon to an :class:`~enlace.appmeta.IconResult`."""
+ from enlace.appmeta import resolve_icon
+
+ spec, root, _ = icon_source(app, config, overlay)
+ return resolve_icon(
+ spec,
+ app_name=app.name,
+ display_name=display_name_of(app, config, overlay),
+ app_dir=root,
+ token_only=token_only,
+ )
+
+
+def is_raster(icon) -> bool:
+ """Whether an icon can be cut into PNG renditions (a raster, served inline)."""
+ return not icon.redirect_url and icon.content_type in _RASTER_TYPES
+
+
+# ---------------------------------------------------------------------------
+# Renditions
+# ---------------------------------------------------------------------------
+
+
+def png_rendition(body: bytes, size: int) -> Optional[bytes]:
+ """A ``size``×``size`` PNG cut from raster ``body``, or None if impossible.
+
+ Non-square sources are center-cropped (a home-screen tile is square, and
+ padding would add a frame the artwork never had). Transparency is kept for
+ the favicon but flattened onto white for the larger sizes, because iOS
+ renders a transparent apple-touch-icon's clear pixels as black.
+ """
+ try:
+ from PIL import Image
+ except ImportError:
+ return None
+ try:
+ with Image.open(io.BytesIO(body)) as im:
+ if getattr(im, "n_frames", 1) > 1:
+ im.seek(0)
+ im = im.convert("RGBA")
+ w, h = im.size
+ side = min(w, h)
+ left, top = (w - side) // 2, (h - side) // 2
+ im = im.crop((left, top, left + side, top + side))
+ im = im.resize((size, size), Image.LANCZOS)
+ if size >= APPLE_TOUCH_SIZE:
+ flat = Image.new("RGBA", im.size, (255, 255, 255, 255))
+ flat.alpha_composite(im)
+ im = flat.convert("RGB")
+ out = io.BytesIO()
+ im.save(out, format="PNG", optimize=True)
+ return out.getvalue()
+ except Exception: # a corrupt icon file must 404, never 500
+ return None
+
+
+def web_manifest(
+ app, *, display_name: str, description: str, token: str, has_png: bool, display: str
+) -> dict:
+ """The web-app manifest for one app (what Android's "Add to Home screen" reads).
+
+ Scoped to the app's own mount so each app installs as its own shortcut.
+ """
+ base = f"/_apps/{app.name}"
+ if has_png:
+ icons = [
+ {
+ "src": f"{base}/icon-{s}.png?v={token}",
+ "sizes": f"{s}x{s}",
+ "type": "image/png",
+ "purpose": "any",
+ }
+ for s in MANIFEST_SIZES
+ ]
+ else:
+ icons = [{"src": f"{base}/icon?v={token}", "sizes": "any"}]
+ manifest = {
+ "name": display_name,
+ "short_name": display_name,
+ "start_url": f"/{app.name}/",
+ "scope": f"/{app.name}/",
+ "display": display,
+ "icons": icons,
+ }
+ if description:
+ manifest["description"] = description
+ return manifest
+
+
+def home_screen_gaps(config) -> list[str]:
+ """Names of launchable apps whose icon has no PNG form (no home-screen icon)."""
+ gaps = []
+ for app in config.apps:
+ if app.name == config.landing_app or not has_own_pages(app):
+ continue
+ icon = resolve_app_icon(app, config, {}, token_only=True)
+ if not is_raster(icon):
+ gaps.append(app.name)
+ return gaps
+
+
+def has_own_pages(app) -> bool:
+ """Whether the platform serves this app's HTML itself (so it can wire its head)."""
+ return bool(app.frontend_dir and Path(app.frontend_dir).is_dir())
+
+
+# ---------------------------------------------------------------------------
+# Wiring: the links
+# ---------------------------------------------------------------------------
+
+
+def _tag(tag: str, **attrs: str) -> str:
+ """An HTML start tag with escaped attribute values (``crossorigin`` et al.)."""
+ parts = " ".join(f'{k}="{html.escape(v, quote=True)}"' for k, v in attrs.items())
+ return f"<{tag} {parts}>"
+
+
+_HAS_ICON_RE = re.compile(rb"\brel\s*=\s*[\"']?(?:shortcut\s+)?icon[\"'\s>]", re.I)
+_HAS_APPLE_RE = re.compile(rb"apple-touch-icon", re.I)
+
+
+def head_links(
+ app, *, icon, display_name: str, protected: bool, page_head: bytes, replace: bool
+) -> tuple[bytes, bytes]:
+ """Return ``(snippet, head)``: tags to add, and the head minus replaced ones.
+
+ Adds each of favicon / apple-touch-icon / manifest / home-screen title only
+ where the page lacks it — unless ``replace``, in which case the page's own
+ icon links are dropped and the platform's take their place.
+ """
+ base = f"/_apps/{app.name}"
+ v = icon.token
+ raster = is_raster(icon)
+ head = _ICON_LINK_RE.sub(b"", page_head) if replace else page_head
+ tags: list[str] = []
+ if not _HAS_ICON_RE.search(head):
+ if raster:
+ for s in (32, 192):
+ href = f"{base}/icon-{s}.png?v={v}"
+ size = f"{s}x{s}"
+ tags.append(
+ _tag("link", rel="icon", type="image/png", sizes=size, href=href)
+ )
+ else:
+ tags.append(_tag("link", rel="icon", href=f"{base}/icon?v={v}"))
+ if raster and not _HAS_APPLE_RE.search(head):
+ href = f"{base}/icon-{APPLE_TOUCH_SIZE}.png?v={v}"
+ tags.append(_tag("link", rel="apple-touch-icon", sizes="180x180", href=href))
+ if not _MANIFEST_LINK_RE.search(head):
+ # A manifest is fetched WITHOUT cookies unless asked; a protected app's
+ # manifest route needs them to pass its access check.
+ cred = {"crossorigin": "use-credentials"} if protected else {}
+ href = f"{base}/manifest.webmanifest"
+ tags.append(_tag("link", rel="manifest", href=href, **cred))
+ if not _APPLE_TITLE_RE.search(head):
+ title = _tag("meta", name="apple-mobile-web-app-title", content=display_name)
+ tags.append(title)
+ return "".join(tags).encode("utf-8"), head
+
+
+def _split_head(body: bytes) -> tuple[bytes, bytes]:
+ """Split an HTML body at ```` (head part may be the whole doc)."""
+ m = HEAD_CLOSE_RE.search(body)
+ if not m:
+ return body, b""
+ return body[: m.start()], body[m.start() :]
+
+
+class AppIconLinksMiddleware:
+ """Pure-ASGI middleware giving every app's HTML the same icon wiring.
+
+ For ``text/html`` responses under ``/{app}/``, see :func:`head_links`. The
+ overlay (Tier A) is read per request from ``app.state.app_meta_overlay``, so
+ a live icon edit in the launcher reaches tabs and home screens with no
+ redeploy. Must sit inside compression (it edits the body).
+ """
+
+ def __init__(self, app, *, config, is_protected):
+ self.app = app
+ self._config = config
+ self._is_protected = is_protected
+ landing = config.landing_app
+ self._apps = sorted(
+ (
+ (f"/{a.name}/", a)
+ for a in config.apps
+ if a.name != landing and has_own_pages(a)
+ ),
+ key=lambda kv: -len(kv[0]),
+ )
+
+ def _app_for(self, path: str):
+ for prefix, app in self._apps:
+ if path.startswith(prefix):
+ return app
+ return None
+
+ async def __call__(self, scope, receive, send):
+ # GET only: a HEAD response has no body to rewrite, and "fixing" its
+ # Content-Length to the snippet's length would be a lie.
+ if scope["type"] != "http" or scope.get("method") != "GET":
+ await self.app(scope, receive, send)
+ return
+ app = self._app_for(scope.get("path", ""))
+ if app is None:
+ await self.app(scope, receive, send)
+ return
+
+ overlay = overlay_entry(scope, app.name)
+
+ def rewrite(body: bytes) -> bytes:
+ try:
+ icon = resolve_app_icon(app, self._config, overlay, token_only=True)
+ _, _, replace = icon_source(app, self._config, overlay)
+ head, rest = _split_head(body)
+ snippet, head = head_links(
+ app,
+ icon=icon,
+ display_name=display_name_of(app, self._config, overlay),
+ protected=self._is_protected(app),
+ page_head=head,
+ replace=replace,
+ )
+ return inject_into_head(head + rest, snippet)
+ except Exception: # icon wiring must never break a page
+ return body
+
+ await rewrite_html_response(self.app, scope, receive, send, rewrite)
+
+
+def overlay_entry(scope, name: str) -> dict:
+ """Read one app's runtime overlay record (Tier A), or ``{}`` if none.
+
+ ``app_meta_overlay`` is injected on the root app's state by the
+ ``enlace_auth`` plugin; absent it, core degrades to Tiers B/C/D.
+ """
+ parent = scope.get("app")
+ store = getattr(getattr(parent, "state", None), "app_meta_overlay", None)
+ if store is None:
+ return {}
+ try:
+ rec = store.get(name, {})
+ except Exception: # a flaky store must never break a page
+ return {}
+ return rec if isinstance(rec, dict) else {}
+
+
+def manifest_json(manifest: dict) -> bytes:
+ """Serialize a manifest (kept apart so routes and tests share one encoding)."""
+ return json.dumps(manifest, ensure_ascii=False).encode("utf-8")
diff --git a/enlace/appmeta.py b/enlace/appmeta.py
index 830c03e..2babbfe 100644
--- a/enlace/appmeta.py
+++ b/enlace/appmeta.py
@@ -84,6 +84,14 @@ class AppMetaConfig(BaseModel):
apps: dict[str, AppMetaEntry] = Field(default_factory=dict)
editors: list[str] = Field(default_factory=list)
store_path: Optional[Path] = None
+ # A platform-owned folder of app icons: ``.png`` (or .webp/.jpg/.svg)
+ # is that app's icon unless the overlay or ``apps..icon`` says otherwise.
+ # One place for the artwork, instead of a copy in every app. See app_icons.
+ icons_dir: Optional[Path] = None
+ # The ``display`` of generated web-app manifests. "browser" makes "Add to
+ # Home screen" a shortcut that opens a normal tab; "standalone" would drop
+ # the browser UI, which also hides the URL bar an auth redirect relies on.
+ manifest_display: str = "browser"
# ---------------------------------------------------------------------------
@@ -254,8 +262,8 @@ def __init__(self) -> None:
self.description = ""
self.og_description = ""
self.keywords = ""
- self.icon_href = ""
- self.apple_icon_href = ""
+ # (href, declared sizes, is_apple_touch) for every icon , in order.
+ self.icon_links: list[tuple[str, str, bool]] = []
def handle_starttag(self, tag, attrs):
if self.done:
@@ -289,10 +297,9 @@ def handle_starttag(self, tag, attrs):
href = a.get("href", "").strip()
if not href:
return
- if "apple-touch-icon" in rel:
- self.apple_icon_href = href
- elif "icon" in rel and not self.icon_href:
- self.icon_href = href
+ if "icon" in rel.split() or "apple-touch-icon" in rel:
+ sizes = a.get("sizes", "").lower()
+ self.icon_links.append((href, sizes, "apple-touch-icon" in rel))
def handle_endtag(self, tag):
if tag == "title":
@@ -327,13 +334,75 @@ def _from_html_head(app_dir: Path, frontend_dir: Optional[Path]) -> _Harvest:
)
h.description = p.description or p.og_description
h.keywords = _norm_keywords(re.split(r"[,\n]", p.keywords)) if p.keywords else []
- href = p.icon_href or p.apple_icon_href
- if href and not _looks_remote(href):
- # hrefs are relative to the html file's directory (the frontend dir).
- h.icon = _rel_to_app(app_dir, frontend_dir / href.lstrip("./"))
+ best = _best_head_icon(p.icon_links, app_dir=app_dir, frontend_dir=frontend_dir)
+ if best is not None:
+ h.icon = _rel_to_app(app_dir, best)
return h
+def _head_icon_path(href: str, *, app_dir: Path, frontend_dir: Path) -> Optional[Path]:
+ """Map a ```` href to the file it names, or None.
+
+ hrefs are relative to the html file's directory (the frontend dir). Two
+ forms that a browser resolves fine used to miss here: a cache-busting query
+ (``icon.png?v=3``) and a mount-absolute path (``/{app}/favicon.svg``), which
+ is how Vite writes ``base``-prefixed links.
+ """
+ if not href or _looks_remote(href) or href.startswith("data:"):
+ return None
+ href = href.split("#", 1)[0].split("?", 1)[0]
+ if href.startswith("/"):
+ mount = f"/{app_dir.name}/"
+ if not href.startswith(mount):
+ return None
+ href = href[len(mount) :]
+ while href.startswith("./"):
+ href = href[2:]
+ path = frontend_dir / href
+ return path if path.is_file() else None
+
+
+def _best_head_icon(links, *, app_dir: Path, frontend_dir: Path) -> Optional[Path]:
+ """The largest icon a page declares: SVG first, then by pixel width.
+
+ The launcher, the home screen and the Android manifest all show the icon
+ far larger than a tab does, so a page's 16px favicon must not win just by
+ being listed first. Width is read from the file (PNG header) when it can
+ be, else from ``sizes``, else 180 for an apple-touch-icon.
+ """
+ best, best_score = None, -1
+ for href, sizes, is_apple in links:
+ path = _head_icon_path(href, app_dir=app_dir, frontend_dir=frontend_dir)
+ if path is None:
+ continue
+ if path.suffix.lower() == ".svg" or sizes == "any":
+ score = 1 << 20
+ else:
+ fallback = 180 if is_apple else 1
+ score = _png_width(path) or _declared_width(sizes) or fallback
+ if score > best_score:
+ best, best_score = path, score
+ return best
+
+
+def _png_width(path: Path) -> int:
+ """Pixel width from a PNG header (0 if not a readable PNG)."""
+ try:
+ with path.open("rb") as f:
+ head = f.read(24)
+ except OSError:
+ return 0
+ if head[:8] != b"\x89PNG\r\n\x1a\n" or len(head) < 24:
+ return 0
+ return int.from_bytes(head[16:20], "big")
+
+
+def _declared_width(sizes: str) -> int:
+ """The largest width in a ``sizes="16x16 32x32"`` attribute (0 if none)."""
+ widths = [int(m) for m in re.findall(r"(\d+)x\d+", sizes)]
+ return max(widths, default=0)
+
+
def _from_package_json(app_dir: Path, frontend_dir: Optional[Path]) -> _Harvest:
"""Harvest name/description/keywords from a ``package.json`` (dev metadata)."""
h = _Harvest(keywords=[])
diff --git a/enlace/base.py b/enlace/base.py
index f8dda2c..b8efdc4 100644
--- a/enlace/base.py
+++ b/enlace/base.py
@@ -405,12 +405,14 @@ def _resolve(value: Any) -> Path:
for key in ("shared_assets_dir", "apps_dir", "manifest_dir"):
if key in platform_data:
platform_data[key] = _resolve(platform_data[key])
- # [app_meta].store_path is path-like too; resolve it against the TOML
- # dir for the same host-portability reason (a ~-prefixed/absolute value
- # is left as-is by _resolve).
+ # [app_meta].store_path / icons_dir are path-like too; resolve them
+ # against the TOML dir for the same host-portability reason (a
+ # ~-prefixed/absolute value is left as-is by _resolve).
app_meta = platform_data.get("app_meta")
- if isinstance(app_meta, dict) and app_meta.get("store_path"):
- app_meta["store_path"] = _resolve(app_meta["store_path"])
+ if isinstance(app_meta, dict):
+ for key in ("store_path", "icons_dir"):
+ if app_meta.get(key):
+ app_meta[key] = _resolve(app_meta[key])
# Environment variable overrides
env_apps_dirs = os.environ.get("ENLACE_APPS_DIRS", "")
diff --git a/enlace/compose.py b/enlace/compose.py
index 6d74c53..3d4269c 100644
--- a/enlace/compose.py
+++ b/enlace/compose.py
@@ -248,6 +248,16 @@ async def cascade_lifespan(app: FastAPI):
platform_manifest=platform_manifest,
)
+ # Give every app's HTML the platform's icon wiring (favicon, apple-touch-icon,
+ # web-app manifest). Rewrites the body, so it too sits inside GZip.
+ from enlace.app_icons import AppIconLinksMiddleware
+
+ parent.add_middleware(
+ AppIconLinksMiddleware,
+ config=config,
+ is_protected=lambda app: app.access.startswith("protected"),
+ )
+
# Compress sizeable text/JSON responses. Added LAST so it is the OUTERMOST
# middleware — it must wrap everything downstream (meta injection, sub-app
# responses, static files) and see final bytes.
@@ -550,19 +560,10 @@ def _app_launch(app: AppConfig) -> tuple[bool, Optional[str]]:
def _overlay_entry(request: Request, name: str) -> dict:
- """Read one app's runtime overlay record (Tier A), or ``{}`` if none.
+ """Read one app's runtime overlay record (Tier A), or ``{}`` if none."""
+ from enlace.app_icons import overlay_entry
- ``app_meta_overlay`` is injected by the ``enlace_auth`` plugin; absent it
- defaults to an empty dict, so core degrades to Tiers B/C/D with no overlay.
- """
- overlay = getattr(request.app.state, "app_meta_overlay", None)
- if overlay is None:
- return {}
- try:
- rec = overlay.get(name, {})
- except Exception: # a flaky store must never break the listing
- return {}
- return rec if isinstance(rec, dict) else {}
+ return overlay_entry(request.scope, name)
def _resolved_app_meta(app: AppConfig, config: PlatformConfig, overlay: dict) -> dict:
@@ -571,12 +572,11 @@ def _resolved_app_meta(app: AppConfig, config: PlatformConfig, overlay: dict) ->
Returns the display_name, description, keywords (+ per-tier sources), and
icon_url for one ``/_apps`` item.
"""
- from enlace import appmeta
+ from enlace import app_icons, appmeta
tier_b = config.app_meta.apps.get(app.name)
b_name = tier_b.display_name if tier_b else None
b_desc = tier_b.description if tier_b else None
- b_icon = tier_b.icon if tier_b else None
b_keywords = tier_b.keywords if tier_b else []
display_name = overlay.get("display_name") or b_name or app.display_name
@@ -588,18 +588,9 @@ def _resolved_app_meta(app: AppConfig, config: PlatformConfig, overlay: dict) ->
overlay_keywords=overlay.get("keywords", []),
)
- icon_spec = (
- overlay.get("icon") or b_icon or app.icon or config.app_meta.default_icon or ""
- )
# token_only: the listing needs only the ?v= cache token, not the icon bytes,
# so a file-backed icon is stat'd, not read (27 apps × every /_apps hit).
- icon = appmeta.resolve_icon(
- icon_spec,
- app_name=app.name,
- display_name=display_name,
- app_dir=appmeta.app_dir_of(app),
- token_only=True,
- )
+ icon = app_icons.resolve_app_icon(app, config, overlay, token_only=True)
return {
"display_name": display_name,
"description": description,
@@ -676,42 +667,29 @@ async def apps_listing(request: Request) -> dict:
"can_edit_meta": can_edit_meta,
}
- @parent.get("/_apps/{name}/icon")
- async def app_icon(name: str, request: Request) -> Response:
- from enlace import appmeta
+ def _visible_app(name: str, request: Request) -> Optional[AppConfig]:
+ """The named app if this caller may see it, else None.
+ One uniform answer for unknown-or-forbidden, so the icon routes are
+ never an oracle for apps the caller can't see.
+ """
+ app = apps_by_name.get(name)
+ if app is None or app.name == landing_name:
+ return None
user_id = getattr(request.state, "user_id", None)
user_email = getattr(request.state, "user_email", None)
- app = apps_by_name.get(name)
- # Access check FIRST, and a uniform 404 for unknown-or-forbidden — the
- # icon endpoint must not be an oracle for apps the caller can't see.
- if (
- app is None
- or app.name == landing_name
- or not _can_access(app.access, user_id, user_email, app.allowed_users)
- ):
- return Response(status_code=404)
+ if not _can_access(app.access, user_id, user_email, app.allowed_users):
+ return None
+ return app
- overlay = _overlay_entry(request, name)
- tier_b = config.app_meta.apps.get(name)
- icon_spec = (
- overlay.get("icon")
- or (tier_b.icon if tier_b else None)
- or app.icon
- or config.app_meta.default_icon
- or ""
- )
- display_name = (
- overlay.get("display_name")
- or (tier_b.display_name if tier_b else None)
- or app.display_name
- )
- icon = appmeta.resolve_icon(
- icon_spec,
- app_name=name,
- display_name=display_name,
- app_dir=appmeta.app_dir_of(app),
- )
+ @parent.get("/_apps/{name}/icon")
+ async def app_icon(name: str, request: Request) -> Response:
+ from enlace import app_icons
+
+ app = _visible_app(name, request)
+ if app is None:
+ return Response(status_code=404)
+ icon = app_icons.resolve_app_icon(app, config, _overlay_entry(request, name))
if icon.redirect_url:
# We don't control the remote bytes ⇒ never mark immutable.
return RedirectResponse(
@@ -725,19 +703,69 @@ async def app_icon(name: str, request: Request) -> Response:
# *document*; this CSP neutralises any embedded script if someone
# navigates directly to the icon URL. Inert on the grid's load,
# which never executes SVG script anyway.
- icon_csp = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
+ return _icon_response(icon.body, icon.content_type, icon.token, cache)
+
+ @parent.get("/_apps/{name}/icon-{size}.png")
+ async def app_icon_png(name: str, size: int, request: Request) -> Response:
+ """A square PNG of the app's icon — favicon, apple-touch-icon, manifest."""
+ from enlace import app_icons
+
+ app = _visible_app(name, request)
+ if app is None or size not in app_icons.PNG_SIZES:
+ return Response(status_code=404)
+ icon = app_icons.resolve_app_icon(app, config, _overlay_entry(request, name))
+ png = (
+ app_icons.png_rendition(icon.body, size)
+ if app_icons.is_raster(icon) and icon.body
+ else None
+ )
+ if png is None:
+ return Response(status_code=404)
+ cache = "public, max-age=31536000, immutable" if icon.immutable else "no-store"
+ return _icon_response(png, "image/png", f"{icon.token}-{size}", cache)
+
+ @parent.get("/_apps/{name}/manifest.webmanifest")
+ async def app_manifest(name: str, request: Request) -> Response:
+ """The app's web-app manifest (Android "Add to Home screen" reads it)."""
+ from enlace import app_icons
+
+ app = _visible_app(name, request)
+ if app is None:
+ return Response(status_code=404)
+ overlay = _overlay_entry(request, name)
+ resolved = _resolved_app_meta(app, config, overlay)
+ icon = app_icons.resolve_app_icon(app, config, overlay, token_only=True)
+ manifest = app_icons.web_manifest(
+ app,
+ display_name=resolved["display_name"],
+ description=resolved["description"],
+ token=icon.token,
+ has_png=app_icons.is_raster(icon),
+ display=config.app_meta.manifest_display,
+ )
+ # Not immutable: the name or icon can change live through the overlay.
return Response(
- content=icon.body,
- media_type=icon.content_type,
- headers={
- "ETag": f'"{icon.token}"',
- "X-Content-Type-Options": "nosniff",
- "Content-Security-Policy": icon_csp,
- "Cache-Control": cache,
- },
+ content=app_icons.manifest_json(manifest),
+ media_type="application/manifest+json",
+ headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
)
+def _icon_response(body: bytes, content_type: str, token: str, cache: str) -> Response:
+ """An icon response with the headers every icon route shares."""
+ icon_csp = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
+ return Response(
+ content=body,
+ media_type=content_type,
+ headers={
+ "ETag": f'"{token}"',
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": icon_csp,
+ "Cache-Control": cache,
+ },
+ )
+
+
def _add_index_route(parent: FastAPI, config: PlatformConfig) -> None:
"""Add a GET / route that lists all discovered apps as a simple HTML page."""
apps = config.apps
diff --git a/enlace/html_rewrite.py b/enlace/html_rewrite.py
new file mode 100644
index 0000000..f45344c
--- /dev/null
+++ b/enlace/html_rewrite.py
@@ -0,0 +1,96 @@
+"""Rewrite HTML response bodies from pure-ASGI middleware.
+
+Several platform features edit the ```` of the HTML an app serves (deploy
+```` tags, the app's icon links). Each needs the same careful dance —
+hold ``http.response.start`` until the whole body is buffered, rewrite it, fix
+``Content-Length`` — and only for ``text/html``. That dance lives here once.
+
+A middleware that rewrites bodies must sit **inside** any compression
+middleware, so it sees and edits uncompressed bytes.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Callable, Optional
+
+HEAD_CLOSE_RE = re.compile(rb"", re.IGNORECASE)
+HEAD_OPEN_RE = re.compile(rb"]*>", re.IGNORECASE)
+
+
+def inject_into_head(body: bytes, snippet: bytes) -> bytes:
+ """Insert ``snippet`` into ``body`` just before ````.
+
+ Falls back to just after an opening ```` tag, then to
+ prepending — so even malformed HTML still carries the tags.
+ """
+ m = HEAD_CLOSE_RE.search(body)
+ if m:
+ return body[: m.start()] + snippet + body[m.start() :]
+ m = HEAD_OPEN_RE.search(body)
+ if m:
+ return body[: m.end()] + snippet + body[m.end() :]
+ return snippet + body
+
+
+def header_value(headers, name: bytes) -> Optional[bytes]:
+ """Return the first matching header value (case-insensitive), or None."""
+ lname = name.lower()
+ for k, v in headers:
+ if k.lower() == lname:
+ return v
+ return None
+
+
+async def rewrite_html_response(
+ app,
+ scope,
+ receive,
+ send,
+ rewrite: Callable[[bytes], bytes],
+) -> None:
+ """Run ``app``, passing any ``text/html`` response body through ``rewrite``.
+
+ Non-HTML responses stream through untouched. For HTML, the start message is
+ held until the full body is buffered (the rewrite changes its length), then
+ sent with a corrected ``Content-Length``.
+ """
+ start_message: Optional[dict] = None
+ chunks: list[bytes] = []
+ intercepting = False
+
+ async def send_wrapper(message):
+ nonlocal start_message, intercepting
+ mtype = message["type"]
+
+ if mtype == "http.response.start":
+ content_type = header_value(message.get("headers", []), b"content-type")
+ if content_type and content_type.lower().startswith(b"text/html"):
+ intercepting = True
+ start_message = message
+ return # held until body is complete
+ await send(message)
+ return
+
+ if mtype == "http.response.body" and intercepting:
+ chunks.append(message.get("body", b""))
+ if message.get("more_body", False):
+ return # keep buffering until the last chunk
+ new_body = rewrite(b"".join(chunks))
+ assert start_message is not None
+ headers = [
+ (k, v)
+ for (k, v) in start_message.get("headers", [])
+ if k.lower() != b"content-length"
+ ]
+ headers.append((b"content-length", str(len(new_body)).encode()))
+ await send({**start_message, "headers": headers})
+ await send(
+ {"type": "http.response.body", "body": new_body, "more_body": False}
+ )
+ return
+
+ await send(message)
+
+ await app(scope, receive, send_wrapper)
+
diff --git a/enlace/manifest.py b/enlace/manifest.py
index cec1dba..752368f 100644
--- a/enlace/manifest.py
+++ b/enlace/manifest.py
@@ -32,12 +32,13 @@
import json
import logging
import os
-import re
from pathlib import Path
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, ValidationError
+from enlace.html_rewrite import inject_into_head, rewrite_html_response
+
_logger = logging.getLogger("enlace.manifest")
MANIFEST_SCHEMA_VERSION = 1
@@ -251,10 +252,6 @@ async def send_with_headers(message):
await self.app(scope, receive, send_with_headers)
-_HEAD_CLOSE_RE = re.compile(rb"", re.IGNORECASE)
-_HEAD_OPEN_RE = re.compile(rb"]*>", re.IGNORECASE)
-
-
def _meta_snippet(manifest: DeployManifest) -> Optional[bytes]:
"""Build the ```` tags for a manifest, or ``None`` if nothing to add.
@@ -276,19 +273,8 @@ def _meta_tag(name: str, content: str) -> str:
return f''
-def _inject_meta(body: bytes, snippet: bytes) -> bytes:
- """Insert ``snippet`` into ``body`` just before ````.
-
- Falls back to just after an opening ```` tag, then to
- prepending — so even malformed HTML still carries the tags.
- """
- m = _HEAD_CLOSE_RE.search(body)
- if m:
- return body[: m.start()] + snippet + body[m.start() :]
- m = _HEAD_OPEN_RE.search(body)
- if m:
- return body[: m.end()] + snippet + body[m.end() :]
- return snippet + body
+# Kept under its historical name: tests and callers import it from here.
+_inject_meta = inject_into_head
class DeployMetaTagMiddleware(_PrefixManifestMiddleware):
@@ -317,59 +303,11 @@ async def __call__(self, scope, receive, send):
await self.app(scope, receive, send)
return
- # State shared between the start and body handlers. We delay the
- # response.start until the full body is buffered, because injecting
- # changes Content-Length.
- start_message: Optional[dict] = None
- chunks: list[bytes] = []
- intercepting = False
-
- async def send_wrapper(message):
- nonlocal start_message, intercepting
- mtype = message["type"]
-
- if mtype == "http.response.start":
- content_type = _header_value(
- message.get("headers", []), b"content-type"
- )
- if content_type and content_type.lower().startswith(b"text/html"):
- intercepting = True
- start_message = message
- return # held until body is complete
- await send(message)
- return
-
- if mtype == "http.response.body" and intercepting:
- chunks.append(message.get("body", b""))
- if message.get("more_body", False):
- return # keep buffering until the last chunk
- new_body = _inject_meta(b"".join(chunks), snippet)
- assert start_message is not None
- headers = [
- (k, v)
- for (k, v) in start_message.get("headers", [])
- if k.lower() != b"content-length"
- ]
- headers.append((b"content-length", str(len(new_body)).encode()))
- await send({**start_message, "headers": headers})
- await send(
- {
- "type": "http.response.body",
- "body": new_body,
- "more_body": False,
- }
- )
- return
-
- await send(message)
-
- await self.app(scope, receive, send_wrapper)
-
+ await rewrite_html_response(
+ self.app,
+ scope,
+ receive,
+ send,
+ lambda body: inject_into_head(body, snippet),
+ )
-def _header_value(headers, name: bytes) -> Optional[bytes]:
- """Return the first matching header value (case-insensitive), or None."""
- lname = name.lower()
- for k, v in headers:
- if k.lower() == lname:
- return v
- return None
diff --git a/enlace/tests/test_app_icons.py b/enlace/tests/test_app_icons.py
new file mode 100644
index 0000000..2d539dc
--- /dev/null
+++ b/enlace/tests/test_app_icons.py
@@ -0,0 +1,224 @@
+"""Tests for enlace.app_icons: one icon per app, in every form a browser asks for.
+
+Covers the source tiers (overlay > app_meta > icons_dir > app's own), the PNG
+renditions and manifest routes (incl. their access gating), the wiring
+middleware (fill gaps vs. replace), and the harvest fixes for mount-absolute
+and cache-busted icon hrefs.
+"""
+
+import io
+
+import pytest
+from PIL import Image
+from starlette.testclient import TestClient
+
+from enlace import app_icons
+from enlace.appmeta import AppMetaConfig, AppMetaEntry
+from enlace.base import PlatformConfig
+from enlace.compose import build_backend
+from enlace.discover import ConventionDiscoverer
+
+
+def _png(size=(64, 64), color=(200, 30, 30, 255)) -> bytes:
+ out = io.BytesIO()
+ Image.new("RGBA", size, color).save(out, format="PNG")
+ return out.getvalue()
+
+
+def _app(tmp_path, name="demo", *, head="", access="public", icon=""):
+ """A discovered frontend-only app whose index.html has ``head`` in ."""
+ d = tmp_path / "apps" / name
+ fe = d / "frontend"
+ fe.mkdir(parents=True)
+ (fe / "index.html").write_text(
+ f"{name}{head}"
+ "hi",
+ encoding="utf-8",
+ )
+ cfg = ConventionDiscoverer().discover_app_dir(d)
+ cfg.access = access
+ if icon:
+ cfg.icon = icon
+ return cfg
+
+
+def _client(apps, **meta):
+ return TestClient(
+ build_backend(PlatformConfig(apps=apps, app_meta=AppMetaConfig(**meta)))
+ )
+
+
+@pytest.fixture
+def icons_dir(tmp_path):
+ d = tmp_path / "app_icons"
+ d.mkdir()
+ return d
+
+
+# ---------------------------------------------------------------------------
+# Source tiers
+# ---------------------------------------------------------------------------
+
+
+def test_icons_dir_file_beats_app_own_icon(tmp_path, icons_dir):
+ app = _app(tmp_path, icon="emoji:🎸")
+ (icons_dir / "demo.png").write_bytes(_png())
+ spec, root, owned = app_icons.icon_source(
+ app, PlatformConfig(apps=[app], app_meta=AppMetaConfig(icons_dir=icons_dir)), {}
+ )
+ assert (spec, root, owned) == ("demo.png", icons_dir, True)
+
+
+def test_platform_toml_icon_beats_icons_dir(tmp_path, icons_dir):
+ app = _app(tmp_path)
+ (icons_dir / "demo.png").write_bytes(_png())
+ meta = AppMetaConfig(
+ icons_dir=icons_dir, apps={"demo": AppMetaEntry(icon="emoji:x")}
+ )
+ spec, _, owned = app_icons.icon_source(
+ app, PlatformConfig(apps=[app], app_meta=meta), {}
+ )
+ assert spec == "emoji:x" and owned
+
+
+def test_app_own_icon_is_not_platform_owned(tmp_path):
+ app = _app(tmp_path, icon="emoji:🎸")
+ spec, _, owned = app_icons.icon_source(app, PlatformConfig(apps=[app]), {})
+ assert spec == "emoji:🎸" and not owned
+
+
+def test_png_preferred_over_svg_in_icons_dir(icons_dir):
+ (icons_dir / "demo.svg").write_text("")
+ (icons_dir / "demo.png").write_bytes(_png())
+ assert app_icons.icons_dir_file(icons_dir, "demo") == "demo.png"
+
+
+# ---------------------------------------------------------------------------
+# Renditions + manifest routes
+# ---------------------------------------------------------------------------
+
+
+def test_png_rendition_is_square_and_sized():
+ out = app_icons.png_rendition(_png((300, 200)), 180)
+ with Image.open(io.BytesIO(out)) as im:
+ assert im.size == (180, 180)
+ assert im.mode == "RGB" # flattened: iOS shows transparency as black
+
+
+def test_png_route_serves_every_size_from_icons_dir(tmp_path, icons_dir):
+ app = _app(tmp_path)
+ (icons_dir / "demo.png").write_bytes(_png((512, 512)))
+ client = _client([app], icons_dir=icons_dir)
+ for size in app_icons.PNG_SIZES:
+ r = client.get(f"/_apps/demo/icon-{size}.png")
+ assert r.status_code == 200, size
+ assert r.headers["content-type"] == "image/png"
+ with Image.open(io.BytesIO(r.content)) as im:
+ assert im.size == (size, size)
+ assert client.get("/_apps/demo/icon-77.png").status_code == 404
+
+
+def test_png_route_404s_for_vector_only_icon(tmp_path):
+ app = _app(tmp_path, icon="emoji:🎸")
+ assert _client([app]).get("/_apps/demo/icon-180.png").status_code == 404
+
+
+def test_manifest_scoped_to_app_with_png_icons(tmp_path, icons_dir):
+ app = _app(tmp_path)
+ (icons_dir / "demo.png").write_bytes(_png())
+ r = _client([app], icons_dir=icons_dir).get("/_apps/demo/manifest.webmanifest")
+ assert r.status_code == 200
+ assert r.headers["content-type"].startswith("application/manifest+json")
+ m = r.json()
+ assert m["start_url"] == "/demo/" and m["scope"] == "/demo/"
+ assert m["display"] == "browser"
+ assert [i["sizes"] for i in m["icons"]] == ["192x192", "512x512"]
+
+
+def test_manifest_and_png_hidden_for_unauthorized_protected_app(tmp_path, icons_dir):
+ app = _app(tmp_path, access="protected:user")
+ (icons_dir / "demo.png").write_bytes(_png())
+ client = _client([app], icons_dir=icons_dir)
+ assert client.get("/_apps/demo/manifest.webmanifest").status_code == 404
+ assert client.get("/_apps/demo/icon-180.png").status_code == 404
+
+
+def test_home_screen_gaps_lists_vector_only_apps(tmp_path, icons_dir):
+ a = _app(tmp_path, "vec", icon="emoji:🎸")
+ b = _app(tmp_path, "ras")
+ (icons_dir / "ras.png").write_bytes(_png())
+ cfg = PlatformConfig(apps=[a, b], app_meta=AppMetaConfig(icons_dir=icons_dir))
+ assert app_icons.home_screen_gaps(cfg) == ["vec"]
+
+
+# ---------------------------------------------------------------------------
+# wiring
+# ---------------------------------------------------------------------------
+
+
+def test_bare_page_gets_all_links(tmp_path, icons_dir):
+ app = _app(tmp_path)
+ (icons_dir / "demo.png").write_bytes(_png())
+ html = _client([app], icons_dir=icons_dir).get("/demo/").text
+ head = html.split("")[0]
+ assert 'rel="icon"' in head and "/_apps/demo/icon-32.png?v=" in head
+ assert 'rel="apple-touch-icon"' in head and "/_apps/demo/icon-180.png?v=" in head
+ assert 'rel="manifest" href="/_apps/demo/manifest.webmanifest"' in head
+ assert 'name="apple-mobile-web-app-title" content="' in head
+ assert "crossorigin" not in head # public app: no credentials needed
+
+
+def test_page_own_links_kept_when_icon_is_the_apps_own(tmp_path):
+ own = ''
+ app = _app(tmp_path, head=own)
+ (app.frontend_dir / "mine.svg").write_text("")
+ head = _client([app]).get("/demo/").text.split("")[0]
+ assert 'href="mine.svg"' in head
+ assert head.count('rel="icon"') == 1
+ assert "manifest.webmanifest" not in head # the app ships its own
+
+
+def test_platform_icon_replaces_page_own_icon_links(tmp_path, icons_dir):
+ own = ''
+ app = _app(tmp_path, head=own)
+ (icons_dir / "demo.png").write_bytes(_png())
+ head = _client([app], icons_dir=icons_dir).get("/demo/").text.split("")[0]
+ assert "old.svg" not in head and "old.png" not in head
+ assert "/_apps/demo/icon-180.png" in head
+
+
+def test_protected_app_manifest_link_sends_credentials(tmp_path):
+ app = _app(tmp_path, access="protected:shared")
+ head = _client([app]).get("/demo/").text.split("")[0]
+ assert 'crossorigin="use-credentials"' in head
+
+
+def test_non_html_untouched(tmp_path):
+ app = _app(tmp_path)
+ (app.frontend_dir / "data.json").write_text('{"a": 1}')
+ assert _client([app]).get("/demo/data.json").json() == {"a": 1}
+
+
+# ---------------------------------------------------------------------------
+# Harvest: hrefs a browser resolves but the harvester used to miss
+# ---------------------------------------------------------------------------
+
+
+def test_harvest_mount_absolute_and_query_hrefs(tmp_path):
+ head = ''
+ app = _app(tmp_path, head=head)
+ (app.frontend_dir / "favicon.svg").write_text("")
+ app = ConventionDiscoverer().discover_app_dir(tmp_path / "apps" / "demo")
+ assert app.icon == "frontend/favicon.svg"
+
+
+def test_harvest_prefers_largest_declared_icon(tmp_path):
+ head = (
+ ''
+ ''
+ )
+ app = _app(tmp_path, head=head)
+ (app.frontend_dir / "f32.png").write_bytes(_png((32, 32)))
+ (app.frontend_dir / "apple.png").write_bytes(_png((180, 180)))
+ app = ConventionDiscoverer().discover_app_dir(tmp_path / "apps" / "demo")
+ assert app.icon == "frontend/apple.png"
diff --git a/pyproject.toml b/pyproject.toml
index c3cb6ab..9c51936 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,10 +28,14 @@ enlace = "enlace.__main__:main"
[project.optional-dependencies]
process = ["httpx>=0.24.0"]
+# PNG renditions of app icons (apple-touch-icon, web-app manifest). Without it,
+# icons still serve as-is; only the home-screen PNG sizes are unavailable.
+icons = ["Pillow"]
dev = [
"pytest",
"httpx",
"pytest-asyncio",
+ "Pillow",
]
[tool.pytest.ini_options]