"""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())