From 550a5c0b18858c098d6c746436d48f031f2b5632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 19:58:19 -0300 Subject: [PATCH 01/15] core: services: customization: First commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- core/services/customization/main.py | 357 +++++++++++++++++++++ core/services/customization/pyproject.toml | 20 ++ core/services/customization/storage.py | 45 +++ core/services/customization/theme.py | 124 +++++++ 4 files changed, 546 insertions(+) create mode 100755 core/services/customization/main.py create mode 100644 core/services/customization/pyproject.toml create mode 100644 core/services/customization/storage.py create mode 100644 core/services/customization/theme.py diff --git a/core/services/customization/main.py b/core/services/customization/main.py new file mode 100755 index 0000000000..64ce1ca14e --- /dev/null +++ b/core/services/customization/main.py @@ -0,0 +1,357 @@ +#! /usr/bin/env python3 + +import asyncio +import json +import logging +import re +from functools import wraps +from pathlib import Path +from typing import Any, List, Optional + +from commonwealth.utils.apis import GenericErrorHandlingRoute, PrettyJSONResponse +from commonwealth.utils.logs import InterceptHandler, init_logger +from commonwealth.utils.sentry_config import init_sentry_async +from fastapi import APIRouter, FastAPI, File, HTTPException, UploadFile, status +from fastapi_versioning import VersionedFastAPI, versioned_api_route +from loguru import logger +from pydantic import BaseModel, Field +from storage import ( + ALLOWED_IMAGE_EXTENSIONS, + ALLOWED_MODEL_EXTENSIONS, + BRANDING_DIR, + LOGO_BASENAME, + MODELS_DIR, + THEME_CONFIG_FILE, + THEME_FILE, + VEHICLE_IMAGE_BASENAME, + ensure_dirs, + find_branding_file, + remove_branding_file, + safe_join, +) +from theme import derive_palette, render_css +from uvicorn import Config, Server + +SERVICE_NAME = "customization" +PORT = 9152 +DEFAULT_PRIMARY = "#2699D0" +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") +MAX_UPLOAD_BYTES = 200 * 1024 * 1024 + +logging.basicConfig(handlers=[InterceptHandler()], level=logging.DEBUG) +init_logger(SERVICE_NAME) +logger.info("Starting Customization service") + + +class ThemeConfig(BaseModel): + primary: str = Field(..., description="Primary color in #RRGGBB format") + + +class ThemeStatus(BaseModel): + primary: str = Field(..., description="Current primary color") + palette: dict[str, str] = Field(..., description="Derived palette anchors") + css_url: str = Field(..., description="URL where the generated CSS is served") + + +class ModelEntry(BaseModel): + name: str = Field(..., description="Relative path under modeloverrides/") + size_bytes: int = Field(..., description="File size in bytes") + url: str = Field(..., description="URL where the model is served") + + +class BrandingAsset(BaseModel): + url: Optional[str] = Field(None, description="URL where the asset is served, if present") + size_bytes: Optional[int] = Field(None, description="File size in bytes, if present") + + +def to_http_exception(endpoint: Any) -> Any: + is_async = asyncio.iscoroutinefunction(endpoint) + + @wraps(endpoint) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + if is_async: + return await endpoint(*args, **kwargs) + return endpoint(*args, **kwargs) + except HTTPException as exception: + raise exception + except ValueError as exception: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exception)) from exception + except Exception as exception: + logger.exception("Customization endpoint failed") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exception)) from exception + + return wrapper + + +def load_theme_config() -> ThemeConfig: + if THEME_CONFIG_FILE.exists(): + try: + data = json.loads(THEME_CONFIG_FILE.read_text(encoding="utf-8")) + return ThemeConfig(**data) + except Exception as exception: + logger.warning(f"Failed to read theme config, falling back to default: {exception}") + return ThemeConfig(primary=DEFAULT_PRIMARY) + + +def save_theme_config(config: ThemeConfig) -> None: + ensure_dirs() + THEME_CONFIG_FILE.write_text(json.dumps(config.dict(), indent=2), encoding="utf-8") + + +def write_theme_css(primary: str) -> None: + ensure_dirs() + THEME_FILE.write_text(render_css(primary), encoding="utf-8") + + +async def save_upload(upload: UploadFile, destination: Path) -> int: + destination.parent.mkdir(parents=True, exist_ok=True) + written = 0 + chunk_size = 1024 * 1024 + with destination.open("wb") as out: + while True: + chunk = await upload.read(chunk_size) + if not chunk: + break + written += len(chunk) + if written > MAX_UPLOAD_BYTES: + out.close() + destination.unlink(missing_ok=True) + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"Upload exceeds {MAX_UPLOAD_BYTES} bytes.", + ) + out.write(chunk) + return written + + +theme_router = APIRouter( + prefix="/theme", + tags=["theme"], + route_class=versioned_api_route(1, 0), + responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}}, +) + + +@theme_router.get("", response_model=ThemeStatus, summary="Get the current theme configuration.") +@to_http_exception +async def get_theme() -> ThemeStatus: + config = load_theme_config() + palette = derive_palette(config.primary) + if not THEME_FILE.exists(): + write_theme_css(config.primary) + return ThemeStatus(primary=config.primary, palette=palette, css_url="/userdata/styles/theme_style.css") + + +@theme_router.put("", response_model=ThemeStatus, summary="Set the primary color and regenerate the theme CSS.") +@to_http_exception +async def set_theme(config: ThemeConfig) -> ThemeStatus: + if not HEX_COLOR_RE.match(config.primary): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Primary color must be in #RRGGBB format.", + ) + save_theme_config(config) + write_theme_css(config.primary) + palette = derive_palette(config.primary) + return ThemeStatus(primary=config.primary, palette=palette, css_url="/userdata/styles/theme_style.css") + + +@theme_router.delete("", status_code=status.HTTP_204_NO_CONTENT, summary="Reset the theme to defaults.") +@to_http_exception +async def reset_theme() -> None: + THEME_CONFIG_FILE.unlink(missing_ok=True) + THEME_FILE.unlink(missing_ok=True) + + +models_router = APIRouter( + prefix="/models", + tags=["models"], + route_class=versioned_api_route(1, 0), + responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}}, +) + + +def list_model_files() -> List[ModelEntry]: + if not MODELS_DIR.exists(): + return [] + entries: List[ModelEntry] = [] + for path in sorted(MODELS_DIR.rglob("*")): + if not path.is_file() or path.suffix.lower() not in ALLOWED_MODEL_EXTENSIONS: + continue + rel = path.relative_to(MODELS_DIR).as_posix() + entries.append( + ModelEntry( + name=rel, + size_bytes=path.stat().st_size, + url=f"/userdata/modeloverrides/{rel}", + ) + ) + return entries + + +@models_router.get("", response_model=List[ModelEntry], summary="List uploaded model overrides.") +@to_http_exception +async def list_models() -> List[ModelEntry]: + ensure_dirs() + return list_model_files() + + +@models_router.post("", response_model=ModelEntry, summary="Upload a model override (.glb).") +@to_http_exception +async def upload_model( + name: str, + file: UploadFile = File(...), +) -> ModelEntry: + ensure_dirs() + target = safe_join(MODELS_DIR, name) + if target.suffix.lower() not in ALLOWED_MODEL_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Only {sorted(ALLOWED_MODEL_EXTENSIONS)} files are allowed.", + ) + written = await save_upload(file, target) + rel = target.relative_to(MODELS_DIR).as_posix() + return ModelEntry(name=rel, size_bytes=written, url=f"/userdata/modeloverrides/{rel}") + + +@models_router.delete( + "/{name:path}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a model override.", +) +@to_http_exception +async def delete_model(name: str) -> None: + target = safe_join(MODELS_DIR, name) + if not target.exists(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Model not found.") + target.unlink() + + +branding_router = APIRouter( + prefix="/branding", + tags=["branding"], + route_class=versioned_api_route(1, 0), + responses={status.HTTP_404_NOT_FOUND: {"description": "Not found"}}, +) + + +def branding_asset(basename: str) -> BrandingAsset: + existing = find_branding_file(basename) + if existing is None: + return BrandingAsset() + return BrandingAsset( + url=f"/userdata/branding/{existing.name}", + size_bytes=existing.stat().st_size, + ) + + +def validate_image_extension(filename: str) -> str: + suffix = Path(filename).suffix.lower() + if suffix not in ALLOWED_IMAGE_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Only {sorted(ALLOWED_IMAGE_EXTENSIONS)} files are allowed.", + ) + return suffix + + +async def replace_branding_asset(basename: str, file: UploadFile) -> BrandingAsset: + ensure_dirs() + if not file.filename: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing filename.") + suffix = validate_image_extension(file.filename) + remove_branding_file(basename) + target = safe_join(BRANDING_DIR, f"{basename}{suffix}") + written = await save_upload(file, target) + return BrandingAsset(url=f"/userdata/branding/{target.name}", size_bytes=written) + + +@branding_router.get("/logo", response_model=BrandingAsset, summary="Get the current custom logo, if any.") +@to_http_exception +async def get_logo() -> BrandingAsset: + return branding_asset(LOGO_BASENAME) + + +@branding_router.post("/logo", response_model=BrandingAsset, summary="Upload a custom company logo.") +@to_http_exception +async def upload_logo(file: UploadFile = File(...)) -> BrandingAsset: + return await replace_branding_asset(LOGO_BASENAME, file) + + +@branding_router.delete("/logo", status_code=status.HTTP_204_NO_CONTENT, summary="Remove the custom logo.") +@to_http_exception +async def delete_logo() -> None: + remove_branding_file(LOGO_BASENAME) + + +@branding_router.get( + "/vehicle-image", + response_model=BrandingAsset, + summary="Get the current custom vehicle image, if any.", +) +@to_http_exception +async def get_vehicle_image() -> BrandingAsset: + return branding_asset(VEHICLE_IMAGE_BASENAME) + + +@branding_router.post( + "/vehicle-image", + response_model=BrandingAsset, + summary="Upload a custom vehicle image.", +) +@to_http_exception +async def upload_vehicle_image(file: UploadFile = File(...)) -> BrandingAsset: + return await replace_branding_asset(VEHICLE_IMAGE_BASENAME, file) + + +@branding_router.delete( + "/vehicle-image", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove the custom vehicle image.", +) +@to_http_exception +async def delete_vehicle_image() -> None: + remove_branding_file(VEHICLE_IMAGE_BASENAME) + + +fast_api_app = FastAPI( + title="Customization API", + description="Manage BlueOS visual customization (theme color, 3D model overrides, branding).", + default_response_class=PrettyJSONResponse, +) +fast_api_app.router.route_class = GenericErrorHandlingRoute +fast_api_app.include_router(theme_router) +fast_api_app.include_router(models_router) +fast_api_app.include_router(branding_router) + +app = VersionedFastAPI( + fast_api_app, + version="1.0.0", + prefix_format="/v{major}.{minor}", + enable_latest=True, +) + + +@app.get("/") +async def root() -> dict[str, str]: + return {"service": SERVICE_NAME} + + +async def main() -> None: + try: + await init_sentry_async(SERVICE_NAME) + ensure_dirs() + # Make sure the CSS exists on first boot so index.html's doesn't 404 + if not THEME_FILE.exists(): + write_theme_css(load_theme_config().primary) + + config = Config(app=app, host="0.0.0.0", port=PORT, log_config=None) + server = Server(config) + await server.serve() + finally: + logger.info("Customization service stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/core/services/customization/pyproject.toml b/core/services/customization/pyproject.toml new file mode 100644 index 0000000000..91b6e856b6 --- /dev/null +++ b/core/services/customization/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "customization" +version = "0.1.0" +description = "Manage BlueOS visual customization (theme color, 3D model overrides, logo, vehicle image)." +requires-python = ">=3.11" +dependencies = [ + "commonwealth==0.1.0", + "fastapi==0.105.0", + "fastapi-versioning==0.9.1", + "loguru==0.5.3", + "pydantic==1.10.12", + "python-multipart==0.0.5", + "uvicorn==0.18.0", +] + +[tool.uv] +package = false + +[tool.uv.sources] +commonwealth = { workspace = true } diff --git a/core/services/customization/storage.py b/core/services/customization/storage.py new file mode 100644 index 0000000000..5cfe67991a --- /dev/null +++ b/core/services/customization/storage.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional + +USERDATA = Path("/usr/blueos/userdata") +STYLES_DIR = USERDATA / "styles" +MODELS_DIR = USERDATA / "modeloverrides" +BRANDING_DIR = USERDATA / "branding" + +THEME_FILE = STYLES_DIR / "theme_style.css" +THEME_CONFIG_FILE = STYLES_DIR / "theme_config.json" + +LOGO_BASENAME = "logo" +VEHICLE_IMAGE_BASENAME = "vehicle_image" + +ALLOWED_MODEL_EXTENSIONS = {".glb"} +ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".gif"} + + +def ensure_dirs() -> None: + for directory in (STYLES_DIR, MODELS_DIR, BRANDING_DIR): + directory.mkdir(parents=True, exist_ok=True) + + +def safe_join(base: Path, user_path: str) -> Path: + base = base.resolve() + candidate = (base / user_path).resolve() + # Prevents path traversal: "../../etc/passwd" style attacks + # https://www.youtube.com/watch?v=RfiQYRn7fBg + candidate.relative_to(base) + return candidate + + +def find_branding_file(basename: str) -> Optional[Path]: + if not BRANDING_DIR.exists(): + return None + for entry in BRANDING_DIR.iterdir(): + if entry.is_file() and entry.stem == basename: + return entry + return None + + +def remove_branding_file(basename: str) -> None: + existing = find_branding_file(basename) + if existing is not None: + existing.unlink() diff --git a/core/services/customization/theme.py b/core/services/customization/theme.py new file mode 100644 index 0000000000..cfdf510954 --- /dev/null +++ b/core/services/customization/theme.py @@ -0,0 +1,124 @@ +import colorsys +import textwrap +from typing import Tuple + + +def parse_hex(color: str) -> Tuple[float, float, float]: + rgb_hex_digits = 6 + value = color.lstrip("#") + if len(value) != rgb_hex_digits: + raise ValueError(f"Invalid hex color: {color}") + try: + r = int(value[0:2], 16) / 255.0 + g = int(value[2:4], 16) / 255.0 + b = int(value[4:6], 16) / 255.0 + except ValueError as exception: + raise ValueError(f"Invalid hex color: {color}") from exception + return r, g, b + + +def to_hex(rgb: Tuple[float, float, float]) -> str: + r, g, b = (max(0.0, min(1.0, channel)) for channel in rgb) + return f"#{int(round(r * 255)):02X}{int(round(g * 255)):02X}{int(round(b * 255)):02X}" + + +def shift_lightness( + rgb: Tuple[float, float, float], lightness_factor: float, saturation_factor: float +) -> Tuple[float, float, float]: + """ + Shift HSL lightness/saturation by relative factors. + + `lightness_factor` and `saturation_factor` are multipliers (e.g. 0.75 means 75% of the original). + Saturation is left untouched on near-grayscale inputs to avoid tinting pure greys. + """ + r, g, b = rgb + hue, lightness, saturation = colorsys.rgb_to_hls(r, g, b) + new_lightness = max(0.0, min(1.0, lightness * lightness_factor)) + # Skip saturation shifting on near-grayscale colors so pure grey stays grey + new_saturation = saturation if saturation < 0.05 else max(0.0, min(1.0, saturation * saturation_factor)) + return colorsys.hls_to_rgb(hue, new_lightness, new_saturation) + + +def derive_palette(primary_hex: str) -> dict[str, str]: + """ + Derive a 3-anchor BlueOS-style gradient palette from a single primary color. + + Mirrors the relationship between BR_BLUE → MARINER_BLUE → BLUE_WHALE: + progressively darker and slightly more saturated. Uses relative shifts so + the gradient stays visible across the whole hue spectrum. + """ + rgb = parse_hex(primary_hex) + light = rgb + mid = shift_lightness(rgb, lightness_factor=0.55, saturation_factor=1.18) + dark = shift_lightness(rgb, lightness_factor=0.20, saturation_factor=1.45) + + # Scrollbar derivatives (Vuetify-style darken steps relative to primary) + scroll_track = shift_lightness(rgb, lightness_factor=0.65, saturation_factor=1.05) + scroll_thumb = shift_lightness(rgb, lightness_factor=0.45, saturation_factor=1.15) + + return { + "light": to_hex(light), + "mid": to_hex(mid), + "dark": to_hex(dark), + "scroll_track": to_hex(scroll_track), + "scroll_thumb": to_hex(scroll_thumb), + } + + +def render_css(primary_hex: str) -> str: + palette = derive_palette(primary_hex) + light = palette["light"] + mid = palette["mid"] + dark = palette["dark"] + scroll_track = palette["scroll_track"] + scroll_thumb = palette["scroll_thumb"] + + # 0x55 ≈ 33% alpha, 0x88 ≈ 53% alpha — same ratios used in App.vue. + # Selectors are prefixed with `html` to bump specificity above App.vue's + # bundled rules: Vite injects App.vue's CSS link AFTER /userdata/styles/ + # in , so equal-specificity rules with !important would otherwise win. + + return textwrap.dedent( + f""" + /* Auto-generated by the BlueOS customization service. Do not edit by hand. */ + /* Primary color: {primary_hex} */ + + html .light-background {{ + background-color: {light} !important; + background-image: linear-gradient(160deg, {light} 0%, {mid} 100%) !important; + }} + + html .dark-background {{ + background-color: {mid} !important; + background-image: linear-gradient(160deg, {mid} 0%, {dark} 100%) !important; + }} + + html .light-background-glass {{ + background-color: {light}55 !important; + background-image: linear-gradient(160deg, {light}88 0%, {mid}88 100%) !important; + backdrop-filter: blur(4.5px) !important; + -webkit-backdrop-filter: blur(10px) !important; + }} + + html .dark-background-glass {{ + background-color: {mid}55 !important; + background-image: linear-gradient(160deg, {mid}88 0%, {dark}88 100%) !important; + backdrop-filter: blur(4.5px) !important; + -webkit-backdrop-filter: blur(10px) !important; + }} + + html ::-webkit-scrollbar-track {{ + box-shadow: inset 0 0 1px grey; + background: {scroll_track} !important; + }} + + html ::-webkit-scrollbar-thumb {{ + background: {scroll_thumb} !important; + transition: visibility 2s; + }} + + html ::-webkit-scrollbar-thumb:hover {{ + background: {light} !important; + }} + """ + ).strip() From 582600248fa7b59f77ea552599966b24b868483a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:01:44 -0300 Subject: [PATCH 02/15] core: start-blueos-core: Add customization service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- core/start-blueos-core | 1 + 1 file changed, 1 insertion(+) diff --git a/core/start-blueos-core b/core/start-blueos-core index 4d067339eb..46877ddbef 100755 --- a/core/start-blueos-core +++ b/core/start-blueos-core @@ -145,6 +145,7 @@ SERVICES=( 'recorder',250,0,0,0,"blueos-recorder --recorder-path /usr/blueos/userdata/recorder" 'recorder_extractor',250,0,0,0,"$SERVICES_PATH/recorder_extractor/main.py" 'disk_usage',250,0,0,0,"$SERVICES_PATH/disk_usage/main.py" + 'customization',250,0,0,0,"$SERVICES_PATH/customization/main.py" ) tmux -f /etc/tmux.conf start-server From b6ab7d0fbdf7f86d130bfda7e7fdbca2a6167ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:02:17 -0300 Subject: [PATCH 03/15] core: pyproject: Add customization service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- core/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/pyproject.toml b/core/pyproject.toml index 2aa3335ba1..66a74d5204 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "bridget", "cable_guy", "commander", + "customization", "disk_usage", "nmea_injector", "pardal", @@ -56,6 +57,7 @@ beacon = { workspace = true } bridget = { workspace = true } cable_guy = { workspace = true } commander = { workspace = true } +customization = { workspace = true } disk_usage = { workspace = true } nmea_injector = { workspace = true } pardal = { workspace = true } From 870d18ac3e2a4539cc03616aba8535bf057f3a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:03:03 -0300 Subject: [PATCH 04/15] core: tools: nginx: Add customization proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- core/tools/nginx/nginx.conf | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/tools/nginx/nginx.conf b/core/tools/nginx/nginx.conf index 278c31849e..7b5650682d 100644 --- a/core/tools/nginx/nginx.conf +++ b/core/tools/nginx/nginx.conf @@ -142,6 +142,14 @@ http { proxy_buffering off; } + location /customization/ { + include cors.conf; + proxy_pass http://127.0.0.1:9152/; + # multipart .glb uploads can be large + client_max_body_size 200M; + proxy_request_buffering off; + } + location /kraken/ { include cors.conf; proxy_pass http://127.0.0.1:9134/; From 2257e6f0d5b455488b008f2343227eba0265480d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:03:34 -0300 Subject: [PATCH 05/15] core: frontend: vite.config: Add customization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- core/frontend/vite.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/frontend/vite.config.js b/core/frontend/vite.config.js index 07da1c8a4c..cf102f4f32 100644 --- a/core/frontend/vite.config.js +++ b/core/frontend/vite.config.js @@ -176,6 +176,9 @@ export default defineConfig(({ command, mode }) => { '^/commander': { target: SERVER_ADDRESS, }, + '^/customization': { + target: SERVER_ADDRESS, + }, '^/disk-usage': { target: SERVER_ADDRESS, }, From 8c2077d151851f378e8c48859b241e11babbbf56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:55:54 -0300 Subject: [PATCH 06/15] frontend: Add customization components, libraries, store and types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- .../customization/BrandingUploader.vue | 117 ++++++ .../customization/ThemeCustomization.vue | 394 ++++++++++++++++++ core/frontend/src/store/customization.ts | 304 ++++++++++++++ core/frontend/src/types/customization.ts | 16 + 4 files changed, 831 insertions(+) create mode 100644 core/frontend/src/components/customization/BrandingUploader.vue create mode 100644 core/frontend/src/components/customization/ThemeCustomization.vue create mode 100644 core/frontend/src/store/customization.ts create mode 100644 core/frontend/src/types/customization.ts diff --git a/core/frontend/src/components/customization/BrandingUploader.vue b/core/frontend/src/components/customization/BrandingUploader.vue new file mode 100644 index 0000000000..03aa37a11a --- /dev/null +++ b/core/frontend/src/components/customization/BrandingUploader.vue @@ -0,0 +1,117 @@ + + + diff --git a/core/frontend/src/components/customization/ThemeCustomization.vue b/core/frontend/src/components/customization/ThemeCustomization.vue new file mode 100644 index 0000000000..0ac6547498 --- /dev/null +++ b/core/frontend/src/components/customization/ThemeCustomization.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/core/frontend/src/store/customization.ts b/core/frontend/src/store/customization.ts new file mode 100644 index 0000000000..d8c02436a3 --- /dev/null +++ b/core/frontend/src/store/customization.ts @@ -0,0 +1,304 @@ +import { + Action, getModule, + Module, Mutation, VuexModule, +} from 'vuex-module-decorators' + +import Notifier from '@/libs/notifier' +import store from '@/store' +import { BrandingAsset, ModelEntry, ThemeStatus } from '@/types/customization' +import { customization_service } from '@/types/frontend_services' +import back_axios from '@/utils/api' + +const API_URL = '/customization/v1.0' +const notifier = new Notifier(customization_service) +const THEME_CSS_HREF_FRAGMENT = '/userdata/styles/theme_style.css' +const EMPTY_BRANDING: BrandingAsset = { url: null, size_bytes: null } + +function reloadThemeCss(): void { + // Force the browser to re-fetch the generated stylesheet so the new + // gradient/scrollbar colors take effect immediately. + const links = document.querySelectorAll('link[rel="stylesheet"]') + links.forEach((link) => { + const href = link.getAttribute('href') ?? '' + if (href.includes(THEME_CSS_HREF_FRAGMENT)) { + const url = href.split('?')[0] + link.setAttribute('href', `${url}?t=${Date.now()}`) + } + }) +} + +@Module({ dynamic: true, store, name: 'customization' }) +class CustomizationStore extends VuexModule { + themeStatus: ThemeStatus | null = null + + models: ModelEntry[] = [] + + logo: BrandingAsset = { ...EMPTY_BRANDING } + + vehicleImage: BrandingAsset = { ...EMPTY_BRANDING } + + themeSaving = false + + themeResetting = false + + modelUploading = false + + logoUploading = false + + vehicleImageUploading = false + + // Cache-buster for branding URLs. Bumped automatically by setLogo/ + // setVehicleImage so any update to a branding asset yields a new URL. + brandingVersion = Date.now() + + get logoUrl(): string | null { + if (!this.logo.url) return null + return `${this.logo.url}?v=${this.brandingVersion}` + } + + get vehicleImageUrl(): string | null { + if (!this.vehicleImage.url) return null + return `${this.vehicleImage.url}?v=${this.brandingVersion}` + } + + @Mutation + setThemeStatus(value: ThemeStatus | null): void { + this.themeStatus = value + } + + @Mutation + setModels(value: ModelEntry[]): void { + this.models = value + } + + @Mutation + setLogo(value: BrandingAsset): void { + this.logo = value + this.brandingVersion = Date.now() + } + + @Mutation + setVehicleImage(value: BrandingAsset): void { + this.vehicleImage = value + this.brandingVersion = Date.now() + } + + @Mutation + setThemeSaving(value: boolean): void { + this.themeSaving = value + } + + @Mutation + setThemeResetting(value: boolean): void { + this.themeResetting = value + } + + @Mutation + setModelUploading(value: boolean): void { + this.modelUploading = value + } + + @Mutation + setLogoUploading(value: boolean): void { + this.logoUploading = value + } + + @Mutation + setVehicleImageUploading(value: boolean): void { + this.vehicleImageUploading = value + } + + @Action + async refreshAll(): Promise { + await Promise.all([ + this.fetchTheme(), + this.fetchModels(), + this.fetchLogo(), + this.fetchVehicleImage(), + ]) + } + + @Action + async fetchTheme(): Promise { + try { + const response = await back_axios({ method: 'get', url: `${API_URL}/theme`, timeout: 10000 }) + this.setThemeStatus(response.data) + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_GET_THEME', error) + } + } + + @Action + async saveTheme(primary: string): Promise { + this.setThemeSaving(true) + try { + const response = await back_axios({ + method: 'put', + url: `${API_URL}/theme`, + data: { primary }, + timeout: 10000, + }) + this.setThemeStatus(response.data) + reloadThemeCss() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_SAVE_THEME', error) + } finally { + this.setThemeSaving(false) + } + } + + @Action + async resetTheme(): Promise { + this.setThemeResetting(true) + try { + await back_axios({ method: 'delete', url: `${API_URL}/theme`, timeout: 10000 }) + await this.fetchTheme() + reloadThemeCss() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_RESET_THEME', error) + } finally { + this.setThemeResetting(false) + } + } + + @Action + async fetchModels(): Promise { + try { + const response = await back_axios({ method: 'get', url: `${API_URL}/models`, timeout: 10000 }) + this.setModels(response.data ?? []) + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_LIST_MODELS', error) + } + } + + @Action + async uploadModel({ file, name }: { file: File; name: string }): Promise { + const form_data = new FormData() + form_data.append('file', file) + this.setModelUploading(true) + try { + await back_axios({ + method: 'post', + url: `${API_URL}/models`, + params: { name }, + headers: { 'Content-Type': 'multipart/form-data' }, + data: form_data, + timeout: 120000, + }) + await this.fetchModels() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_UPLOAD_MODEL', error) + } finally { + this.setModelUploading(false) + } + } + + @Action + async deleteModel(name: string): Promise { + try { + await back_axios({ + method: 'delete', + url: `${API_URL}/models/${encodeURIComponent(name)}`, + timeout: 10000, + }) + await this.fetchModels() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_DELETE_MODEL', error) + } + } + + @Action + async fetchLogo(): Promise { + try { + const response = await back_axios({ method: 'get', url: `${API_URL}/branding/logo`, timeout: 10000 }) + this.setLogo(response.data) + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_GET_LOGO', error) + } + } + + @Action + async uploadLogo(file: File): Promise { + const form_data = new FormData() + form_data.append('file', file) + this.setLogoUploading(true) + try { + await back_axios({ + method: 'post', + url: `${API_URL}/branding/logo`, + headers: { 'Content-Type': 'multipart/form-data' }, + data: form_data, + timeout: 60000, + }) + await this.fetchLogo() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_UPLOAD_LOGO', error) + } finally { + this.setLogoUploading(false) + } + } + + @Action + async deleteLogo(): Promise { + try { + await back_axios({ method: 'delete', url: `${API_URL}/branding/logo`, timeout: 10000 }) + await this.fetchLogo() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_DELETE_LOGO', error) + } + } + + @Action + async fetchVehicleImage(): Promise { + try { + const response = await back_axios({ + method: 'get', + url: `${API_URL}/branding/vehicle-image`, + timeout: 10000, + }) + this.setVehicleImage(response.data) + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_GET_VEHICLE_IMAGE', error) + } + } + + @Action + async uploadVehicleImage(file: File): Promise { + const form_data = new FormData() + form_data.append('file', file) + this.setVehicleImageUploading(true) + try { + await back_axios({ + method: 'post', + url: `${API_URL}/branding/vehicle-image`, + headers: { 'Content-Type': 'multipart/form-data' }, + data: form_data, + timeout: 60000, + }) + await this.fetchVehicleImage() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_UPLOAD_VEHICLE_IMAGE', error) + } finally { + this.setVehicleImageUploading(false) + } + } + + @Action + async deleteVehicleImage(): Promise { + try { + await back_axios({ + method: 'delete', + url: `${API_URL}/branding/vehicle-image`, + timeout: 10000, + }) + await this.fetchVehicleImage() + } catch (error) { + notifier.pushBackError('CUSTOMIZATION_DELETE_VEHICLE_IMAGE', error) + } + } +} + +const customization_store = getModule(CustomizationStore) + +export { CustomizationStore } +export default customization_store diff --git a/core/frontend/src/types/customization.ts b/core/frontend/src/types/customization.ts new file mode 100644 index 0000000000..a90d120279 --- /dev/null +++ b/core/frontend/src/types/customization.ts @@ -0,0 +1,16 @@ +export interface ThemeStatus { + primary: string + palette: Record + css_url: string +} + +export interface ModelEntry { + name: string + size_bytes: number + url: string +} + +export interface BrandingAsset { + url: string | null + size_bytes: number | null +} From d6cac3e764fb890b8b21227e8e3dbb160d7b548e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:58:03 -0300 Subject: [PATCH 07/15] frontend: types: frontend_services: register customization service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- core/frontend/src/types/frontend_services.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/frontend/src/types/frontend_services.ts b/core/frontend/src/types/frontend_services.ts index 52d2fa8d9c..5702ae1941 100644 --- a/core/frontend/src/types/frontend_services.ts +++ b/core/frontend/src/types/frontend_services.ts @@ -77,6 +77,13 @@ export const commander_service: Service = { version: '0.1.0', } +export const customization_service: Service = { + name: 'Customization', + description: 'Manage BlueOS visual customization (theme color, 3D model overrides, branding).', + company: 'Blue Robotics', + version: '0.1.0', +} + export const nmea_injector_service: Service = { name: 'NMEA Injector', description: 'Allows management of sockets for NMEA data injectior.', From 14a8001dbb9fead4421bdff416cab9b8d909252e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Jos=C3=A9=20Pereira?= Date: Tue, 12 May 2026 20:59:54 -0300 Subject: [PATCH 08/15] frontend: vehiclesetup: GenericViewer: Deal with model_override_path correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Patrick José Pereira --- .../src/components/vehiclesetup/viewers/GenericViewer.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/frontend/src/components/vehiclesetup/viewers/GenericViewer.vue b/core/frontend/src/components/vehiclesetup/viewers/GenericViewer.vue index 36c5dec24f..ffdb3434dc 100755 --- a/core/frontend/src/components/vehiclesetup/viewers/GenericViewer.vue +++ b/core/frontend/src/components/vehiclesetup/viewers/GenericViewer.vue @@ -1,7 +1,7 @@