diff --git a/frontend/css/style.scss b/frontend/css/style.scss index 850cfdc7..03aa2107 100644 --- a/frontend/css/style.scss +++ b/frontend/css/style.scss @@ -3824,7 +3824,7 @@ html.picking-component * { // Theses durations are also referenced in code! transition: opacity 0.2s ease-in-out, - background-color 0.6s ease-in-out; + background-color 0.5s ease-in-out; & > * { transform: translateY(-2rem); diff --git a/rio/app.py b/rio/app.py index f9766ac1..92d17bf1 100644 --- a/rio/app.py +++ b/rio/app.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import io import os import sys import threading @@ -13,6 +14,7 @@ from pathlib import Path import fastapi import introspection import uvicorn +from PIL import Image import __main__ import rio @@ -312,6 +314,63 @@ class App: else: self._ping_pong_interval = timedelta(seconds=ping_pong_interval) + # Initialized lazily, when the icon is first requested + # + # This starts out as `None`, then either becomes a `bytes` object if + # it's successfully fetched, or an error message if fetching failed. + self._icon_as_png_blob: bytes | str | None = None + + async def fetch_icon_png_blob(self) -> bytes: + """ + Fetches the app's icon as a PNG blob. + + The result is cached. It will be loaded the first time you call this + method, and then returned immediately on subsequent calls. If fetching + the icon fails, the exception is also cached, and no further fetching + attempts will be made. + + ## Raises + + `IOError`: If the icon could not be fetched. + """ + + # Already cached? + if isinstance(self._icon_as_png_blob, bytes): + return self._icon_as_png_blob + + # Already failed? + if isinstance(self._icon_as_png_blob, str): + raise IOError(self._icon_as_png_blob) + + # Nope, get it + try: + icon_blob, _ = await self._icon.try_fetch_as_blob() + + input_buffer = io.BytesIO(icon_blob) + output_buffer = io.BytesIO() + + with Image.open(input_buffer) as image: + image.save(output_buffer, format="png") + + except Exception as err: + if isinstance(self._icon, assets.PathAsset): + message = f"Could not fetch the app's icon from {self._icon.path.resolve()}" + elif isinstance(self._icon, assets.UrlAsset): + message = ( + f"Could not fetch the app's icon from {self._icon.url}" + ) + else: + message = f"Could not fetch the app's icon from" + + self._icon_as_png_blob = message + raise IOError(message) from err + + # Cache it + self._icon_as_png_blob = output_buffer.getvalue() + + # Done! + return self._icon_as_png_blob + @functools.cached_property def _main_file_path(self) -> Path: if global_state.rio_run_app_module_path is not None: diff --git a/rio/app_server/fastapi_server.py b/rio/app_server/fastapi_server.py index b4ad736b..77c235fa 100644 --- a/rio/app_server/fastapi_server.py +++ b/rio/app_server/fastapi_server.py @@ -4,12 +4,10 @@ import asyncio import contextlib import functools import html -import io import json import logging import secrets import typing as t -import warnings import weakref from datetime import timedelta from pathlib import Path @@ -18,7 +16,6 @@ from xml.etree import ElementTree as ET import crawlerdetect import fastapi import timer_dict -from PIL import Image from uniserde import Jsonable, JsonDoc import rio @@ -234,9 +231,6 @@ class FastapiServer(fastapi.FastAPI, AbstractAppServer): self._can_create_new_sessions = asyncio.Event() self._can_create_new_sessions.set() - # Initialized lazily, when the favicon is first requested. - self._icon_as_png_blob: bytes | None = None - # The session tokens and Request object for all clients that have made a # HTTP request, but haven't yet established a websocket connection. Once # the websocket connection is created, these will be turned into @@ -617,43 +611,19 @@ Sitemap: {base_url / "/rio/sitemap"} """ Handler for serving the favicon via fastapi, if one is set. """ - # If an icon is set, make sure a cached version exists - if self._icon_as_png_blob is None and self.app._icon is not None: - try: - icon_blob, _ = await self.app._icon.try_fetch_as_blob() + # Fetch the favicon. This method is already caching, so it's fine to + # fetch every time. + try: + icon_png_blob = await self.app.fetch_icon_png_blob() + except IOError as err: + raise fastapi.HTTPException( + status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not fetch the app's icon.", + ) from err - input_buffer = io.BytesIO(icon_blob) - output_buffer = io.BytesIO() - - with Image.open(input_buffer) as image: - image.save(output_buffer, format="png") - - except Exception as err: - if isinstance(self.app._icon, assets.PathAsset): - warnings.warn( - f"Could not fetch the app's icon from {self.app._icon.path.resolve()}" - ) - elif isinstance(self.app._icon, assets.UrlAsset): - warnings.warn( - f"Could not fetch the app's icon from {self.app._icon.url}" - ) - else: - warnings.warn(f"Could not fetch the app's icon from") - - raise fastapi.HTTPException( - status_code=fastapi.status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Could not fetch the app's icon.", - ) from err - - self._icon_as_png_blob = output_buffer.getvalue() - - # No icon set or fetching failed - if self._icon_as_png_blob is None: - return fastapi.responses.Response(status_code=404) - - # There is an icon, respond + # Respond return fastapi.responses.Response( - content=self._icon_as_png_blob, + content=icon_png_blob, media_type="image/png", )