refactor(synapse): backend updates, add icons module, relocate playbooks to data/playbooks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jon
2026-07-11 07:25:08 -05:00
parent 3ec57f9140
commit f4f77c5196
24 changed files with 1820 additions and 288 deletions
View File
+244
View File
@@ -0,0 +1,244 @@
"""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 = """\
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#b040c0"/>
<stop offset="100%" stop-color="#4a0050"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#frame)"/>
<rect x="8" y="8" width="112" height="112" rx="26" fill="#120018"/>
<rect x="16" y="16" width="96" height="96" rx="18" fill="#262626"/>
</svg>
"""
_RING_FALLBACK = """\
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<radialGradient id="ring" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#8cc63f"/>
<stop offset="100%" stop-color="#5a9020"/>
</radialGradient>
<radialGradient id="frame" cx="50%" cy="30%" r="70%">
<stop offset="0%" stop-color="#b040c0"/>
<stop offset="100%" stop-color="#4a0050"/>
</radialGradient>
</defs>
<rect x="0" y="0" width="128" height="128" rx="30" fill="url(#ring)"/>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#frame)"/>
<rect x="12" y="12" width="104" height="104" rx="22" fill="#120018"/>
<rect x="18" y="18" width="92" height="92" rx="16" fill="#262626"/>
</svg>
"""
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,
)
+140
View File
@@ -0,0 +1,140 @@
"""Resolve desktop app icon paths and scan installed applications."""
from pathlib import Path
from typing import Optional
import configparser
_HOME = Path.home()
ICON_SEARCH_DIRS = [
_HOME / ".local/share/icons",
Path("/usr/share/icons"),
Path("/usr/share/pixmaps"),
Path("/usr/local/share/icons"),
]
_THEME_ORDER = ["hicolor", "Papirus-Dark", "Papirus", "gnome", "Adwaita", "breeze"]
_ICON_EXTS = [".png", ".svg", ".xpm"]
DESKTOP_DIRS = [
_HOME / ".local/share/applications",
Path("/usr/local/share/applications"),
Path("/usr/share/applications"),
]
_NEXUS_ICONS = _HOME / ".icons" / "NexusOS"
def _icon_stem(icon_name: str) -> str:
"""Return the icon's name for theme lookup.
Only strips a real image extension (.png/.svg/.xpm). Reverse-DNS icon
names like ``com.visualstudio.code`` or ``org.gnome.Files`` must be kept
intact — Path.stem would wrongly treat the final dotted segment as an
extension and truncate it.
"""
p = Path(icon_name)
if p.suffix.lower() in _ICON_EXTS:
return p.stem
return p.name
def resolve_icon(icon_name: str, size: int = 128) -> Optional[str]:
"""Return absolute path to an icon file, or None if not found."""
if not icon_name:
return None
# Absolute path — use directly if it exists
p = Path(icon_name)
if p.is_absolute() and p.exists():
return str(p)
# Strip extension for name-based search
stem = _icon_stem(icon_name)
size_dirs = [f"{size}x{size}", f"{size}x{size}@2x", "scalable"]
for search_root in ICON_SEARCH_DIRS:
if not search_root.is_dir():
continue
for theme in _THEME_ORDER:
theme_dir = search_root / theme
if not theme_dir.is_dir():
continue
for size_dir in size_dirs:
for ctx in ("apps", "categories", "places", "status", "actions"):
ctx_dir = theme_dir / size_dir / ctx
if not ctx_dir.is_dir():
continue
for ext in _ICON_EXTS:
candidate = ctx_dir / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
# pixmaps fallback
for search_root in ICON_SEARCH_DIRS:
if not search_root.is_dir():
continue
if search_root.name == "pixmaps":
for ext in _ICON_EXTS:
candidate = search_root / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
for ext in _ICON_EXTS:
candidate = Path("/usr/share/pixmaps") / f"{stem}{ext}"
if candidate.exists():
return str(candidate)
return None
def _is_branded(icon_name: str) -> bool:
"""Return True if a NexusOS-branded PNG already exists for this icon name."""
stem = _icon_stem(icon_name)
return (_NEXUS_ICONS / "128x128" / "apps" / f"{stem}.png").exists()
def scan_apps() -> list[dict]:
"""Return list of installed apps with resolved icon paths."""
seen: dict[str, dict] = {}
for desktop_dir in DESKTOP_DIRS:
if not desktop_dir.is_dir():
continue
for desktop_file in sorted(desktop_dir.glob("*.desktop")):
cfg = configparser.ConfigParser(interpolation=None, strict=False)
try:
cfg.read(str(desktop_file), encoding="utf-8")
except Exception:
continue
if not cfg.has_section("Desktop Entry"):
continue
de = cfg["Desktop Entry"]
if de.get("NoDisplay", "false").lower() == "true":
continue
if de.get("Hidden", "false").lower() == "true":
continue
if de.get("Type", "") != "Application":
continue
name = de.get("Name", desktop_file.stem)
icon_name = de.get("Icon", "")
if not icon_name:
continue
icon_path = resolve_icon(icon_name)
already_branded = _is_branded(icon_name)
key = name.lower()
if key not in seen:
seen[key] = {
"name": name,
"icon_name": _icon_stem(icon_name),
"icon_path": icon_path,
"already_branded": already_branded,
"desktop_file": str(desktop_file),
}
return sorted(seen.values(), key=lambda x: x["name"].lower())