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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user