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]