"""Composite app icons onto the NexusOS nexus underlay tile.""" from pathlib import Path import subprocess import tempfile import os import re _ROOT = Path(__file__).resolve().parents[2] UNDERLAY_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay.svg" RING_FILE = _ROOT / "assets/themes/NexusOS-icons-src/nexus-underlay-ring.svg" ICONS_OUT = Path.home() / ".icons" / "NexusOS" SIZES = [16, 22, 24, 32, 48, 64, 128] _ALLOWED_ROOTS = [ "/usr/share/icons", "/usr/share/pixmaps", "/usr/local/share/icons", "/opt", str(Path.home() / ".local/share/icons"), str(Path.home() / ".icons"), str(_ROOT / "assets"), ] _UNDERLAY_FALLBACK = """\ """ _RING_FALLBACK = """\ """ def _load_underlay(nexus_ring: bool = False) -> str: """Return SVG text for the underlay, falling back to inline if file missing.""" target = RING_FILE if nexus_ring else UNDERLAY_FILE fallback = _RING_FALLBACK if nexus_ring else _UNDERLAY_FALLBACK if target.exists(): return target.read_text() return fallback def _inkscape_render(svg_path: str, out_path: str, size: int) -> None: subprocess.run( [ "inkscape", svg_path, "--export-type=png", f"--export-filename={out_path}", f"--export-width={size}", f"--export-height={size}", ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def _composite_png(underlay_png: str, app_png: str, out_path: str, frac: float, size: int) -> None: """Composite app icon centred on the underlay at given fractional size.""" app_size = max(1, int(size * frac)) offset = (size - app_size) // 2 subprocess.run( [ "convert", underlay_png, "(", app_png, "-resize", f"{app_size}x{app_size}", ")", "-gravity", "Center", "-geometry", f"+0+0", "-composite", out_path, ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def _is_allowed_path(path: str) -> bool: p = Path(os.path.realpath(path)) return p.is_file() and any( p == root or root in p.parents for root in (Path(r).resolve() for r in _ALLOWED_ROOTS) ) def _safe_component(value: str, label: str) -> str: if not value or not re.fullmatch(r"[A-Za-z0-9._-]+", value): raise ValueError(f"Invalid icon {label}") return value def brand_icon( src_path: str, output_name: str, frac: float = 0.60, round_mask: bool = False, nexus_ring: bool = False, reload: bool = True, category: str = "apps", ) -> None: """Brand a single icon and write PNGs to the NexusOS icon theme at all sizes.""" if not _is_allowed_path(src_path): raise ValueError(f"Icon source path not in allowed roots: {src_path}") output_name = _safe_component(output_name, "name") category = _safe_component(category, "category") underlay_svg_text = _load_underlay(nexus_ring) with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) underlay_svg = tmp_path / "underlay.svg" underlay_svg.write_text(underlay_svg_text) for size in SIZES: out_dir = ICONS_OUT / f"{size}x{size}" / category out_dir.mkdir(parents=True, exist_ok=True) out_path = out_dir / f"{output_name}.png" # Break symlinks before writing; also remove any same-name .svg so # GTK doesn't prefer the old SVG over our new branded PNG. if out_path.is_symlink() or out_path.exists(): out_path.unlink() svg_path = out_dir / f"{output_name}.svg" if svg_path.exists() or svg_path.is_symlink(): svg_path.unlink() underlay_png = str(tmp_path / f"underlay_{size}.png") _inkscape_render(str(underlay_svg), underlay_png, size) # Resize app icon source to a temp PNG for compositing. # `-background none` is REQUIRED: ImageMagick rasterizes transparent # SVGs onto an opaque WHITE canvas by default, which shows up as a # white plate/border behind logos with transparent corners # (e.g. VSCode, Edge). It must precede the input to affect the SVG. app_png = str(tmp_path / f"app_{size}.png") app_size = max(1, int(size * frac)) subprocess.run( ["convert", "-background", "none", src_path, "-resize", f"{app_size}x{app_size}", app_png], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) subprocess.run( [ "convert", underlay_png, "(", app_png, ")", "-gravity", "Center", "-composite", str(out_path), ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if reload: apply_icon_cache() def apply_icon_cache() -> None: """Rebuild the GTK icon cache and reload the panel.""" import pwd uid = os.getuid() # systemd user session bus — needed for xfce4-panel -r to reach the running session dbus_addr = os.environ.get( "DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus", ) _env = { **os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0"), "DBUS_SESSION_BUS_ADDRESS": dbus_addr, "HOME": pwd.getpwuid(uid).pw_dir, } subprocess.run( ["gtk-update-icon-cache", "-f", str(ICONS_OUT)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Force every running GTK app (whisker menu, panel, Thunar) to drop its # in-memory icon cache. A panel reload alone does NOT do this — GTK only # re-resolves icons when the icon-theme NAME changes. Toggling to another # theme and back fires the "theme-changed" signal that triggers the reload. import time current = "NexusOS" try: out = subprocess.run( ["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName"], env=_env, capture_output=True, text=True, ) if out.stdout.strip(): current = out.stdout.strip() except Exception: pass alt = "Papirus-Dark" if current != "Papirus-Dark" else "Adwaita" subprocess.run( ["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", alt], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) time.sleep(0.5) subprocess.run( ["xfconf-query", "-c", "xsettings", "-p", "/Net/IconThemeName", "-s", current], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Restart Plank so it re-resolves icons from the updated theme subprocess.run(["pkill", "plank"], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.Popen( ["plank"], env=_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )