diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e30ea06 --- /dev/null +++ b/__init__.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Software Station - GhostBSD package manager + +This package provides themed icon support, desktop entry indexing, +and package management utilities. +""" + +__version__ = "2.0" + +__all__ = [ + "icons", + "desktop_index", + "pkg_desktop_map", + "accessories_map", +] diff --git a/iconlist.py b/iconlist.py index afb7011..a10e5ff 100644 --- a/iconlist.py +++ b/iconlist.py @@ -1,54 +1,167 @@ -#!/usr/local/bin/python3 +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- -from gi.repository import Gtk -from gi.repository.GdkPixbuf import Pixbuf -import glob -import os +from __future__ import annotations -found = glob.glob('/usr/local/share/icons/mate/24x24/*/*png') +import sys +from typing import Optional, Callable -icons = [] +# ------------------------- +# Optional legacy fallback +# ------------------------- +# If your repository ships embedded XPM icons, keep using them as a last resort. +_legacy_get_pixbuf = None +try: + # If your legacy module exposes a "get_pixbuf(name, size)" or similar, + # adapt it here. If not, this will remain None and simply not be used. + import software_station_xpm as _xpm -for f in found: - base = os.path.basename(f) - icons.append(os.path.splitext(base)[0]) + # Try a few common names; bind the first that exists. + if hasattr(_xpm, "get_pixbuf") and callable(_xpm.get_pixbuf): + _legacy_get_pixbuf = _xpm.get_pixbuf # type: ignore + elif hasattr(_xpm, "icon_pixbuf") and callable(_xpm.icon_pixbuf): + _legacy_get_pixbuf = _xpm.icon_pixbuf # type: ignore + # Else: leave as None; we'll skip legacy fallback. +except Exception: + _xpm = None # not fatal +# -------------------------------------- +# Themed, thread-safe enhanced helpers +# -------------------------------------- +_THEMED_ICONS_AVAILABLE = False +try: + from gi.repository import Gtk, GdkPixbuf # noqa: F401 -class IconViewWindow(Gtk.Window): - def test(self, widget, path): - model = widget.get_model() - data = model[path][1] - print(data) + # Our improved, thread-safe, theme-aware implementation lives here: + from software_station.icons import ( + init_icon_runtime as _init_icon_runtime, + resolve_label_and_icon_async as _resolve_label_and_icon_async, + resolve_label_and_icon_sync as _resolve_label_and_icon_sync, + ) + from software_station.accessories_map import ACCESSORIES_MAP as _ACCESSORIES_MAP - def __init__(self): + _THEMED_ICONS_AVAILABLE = True +except Exception: + # PyGObject or the helper modules not available - themed path disabled. + _ACCESSORIES_MAP = {} # type: ignore - Gtk.Window.__init__(self) - self.set_title("%d icon%c - %s" % (len(icons), '' if len(icons) < 2 else 's', 'usr/local/share/icons/mate/24x24/')) - self.set_default_size(660, 400) - liststore = Gtk.ListStore(Pixbuf, str, str) - iconview = Gtk.IconView.new() - iconview.set_model(liststore) - iconview.set_pixbuf_column(0) - iconview.set_text_column(1) - iconview.connect("item-activated", self.test) - iconview.set_tooltip_column(2) - bsd = 0 - for icon in sorted(icons): +def init_icons_runtime() -> None: + """ + Initialize the themed icon runtime (must be called on the GTK main thread). + Safe to call even if themed icons are unavailable (becomes a no-op). + """ + if _THEMED_ICONS_AVAILABLE: + _init_icon_runtime() # type: ignore[misc] + + +def _category_uses_themed(category: str) -> bool: + # Use the themed path for all categories + return True + + +def themed_icon_and_label_async( + category: str, + pkg_name: str, + size: int, + on_ready: Callable[[str, Optional["GdkPixbuf.Pixbuf"]], None], +) -> None: + """ + Resolve a label + icon without blocking the UI. + - If themed stack is available and enabled for the category, use it. + - Otherwise, offload legacy_get_pixbuf to a worker thread. + The callback runs on the GTK main thread. + """ + if _THEMED_ICONS_AVAILABLE and _category_uses_themed(category): + _resolve_label_and_icon_async(pkg_name, _ACCESSORIES_MAP, size, on_ready) # type: ignore[misc] + return + + # Non-themed path: offload legacy_get_pixbuf to a worker thread + def worker(): + pix = None + if _legacy_get_pixbuf: try: - pixbuf = Gtk.IconTheme.get_default().load_icon(icon, 64, 0) - liststore.append([pixbuf, icon, str(bsd)]) + pix = _legacy_get_pixbuf(pkg_name, size) # type: ignore[call-arg] + except Exception: + pix = None + # Call back on main thread + from gi.repository import GLib + GLib.idle_add(on_ready, pkg_name, pix) + + import threading + threading.Thread(target=worker, daemon=True).start() + + +def themed_icon_and_label_sync( + category: str, + pkg_name: str, + size: int = 32, +): + """ + Resolve label + icon synchronously (must run on the GTK main thread). + """ + if _THEMED_ICONS_AVAILABLE and _category_uses_themed(category): + # returns (label, pixbuf) + return _resolve_label_and_icon_sync(pkg_name, _ACCESSORIES_MAP, size=size) # type: ignore[misc] + + # Legacy path: label = pkg name; icon via XPM if available. + pix = None + if _legacy_get_pixbuf: + try: + pix = _legacy_get_pixbuf(pkg_name, size) # type: ignore[call-arg] + except Exception: + pix = None + return pkg_name, pix + + +# ------------------------------------------------------- +# Optional convenience for existing callers (compat) +# ------------------------------------------------------- +def get_icon_for_package(pkg_name: str, size: int = 32): + """ + Backward-compatible helper for callers that only want an icon pixbuf + for a package name. Attempts themed resolution first (if available + and category policy allows), then falls back to XPM, then None. + """ + # Use themed sync path under 'Accessories' policy; otherwise legacy only. + if _THEMED_ICONS_AVAILABLE and _category_uses_themed("Accessories"): + # Only care about the pixbuf; ignore the label. + try: + _, pix = _resolve_label_and_icon_sync(pkg_name, _ACCESSORIES_MAP, size=size) # type: ignore[misc] + if pix is not None: + return pix + except Exception: + pass + + if _legacy_get_pixbuf: + try: + return _legacy_get_pixbuf(pkg_name, size) # type: ignore[call-arg] + except Exception: + return None + return None - except Exception as inst: - print(inst) - bsd += 1 - swnd = Gtk.ScrolledWindow() - swnd.add(iconview) - self.add(swnd) +def get_friendly_label(pkg_name: str) -> str: + """ + Best-effort friendly label for a package (falls back to pkg_name). + The themed path provides localized names via desktop index; otherwise + we just return the package name. + """ + if _THEMED_ICONS_AVAILABLE and _category_uses_themed("Accessories"): + try: + label, _ = _resolve_label_and_icon_sync(pkg_name, _ACCESSORIES_MAP, size=16) # type: ignore[misc] + return label or pkg_name + except Exception: + return pkg_name + return pkg_name -win = IconViewWindow() -win.connect("delete-event", Gtk.main_quit) -win.show_all() -Gtk.main() +__all__ = [ + # New themed API + "init_icons_runtime", + "themed_icon_and_label_async", + "themed_icon_and_label_sync", + # Optional compatibility helpers + "get_icon_for_package", + "get_friendly_label", +] diff --git a/setup.py b/setup.py index fecaa9a..3bcb98d 100644 --- a/setup.py +++ b/setup.py @@ -3,20 +3,25 @@ import os import sys -from setuptools import setup +from setuptools import setup, find_packages -import DistUtilsExtra.command.build_extra -import DistUtilsExtra.command.build_i18n -import DistUtilsExtra.command.clean_i18n +# Try to import DistUtilsExtra for i18n support, but make it optional +try: + import DistUtilsExtra.command.build_extra + import DistUtilsExtra.command.build_i18n + import DistUtilsExtra.command.clean_i18n + HAS_DISTUTILS_EXTRA = True +except ImportError: + HAS_DISTUTILS_EXTRA = False + print("Warning: DistUtilsExtra not found. i18n features will be disabled.") # to update i18n .mo files (and merge .pot file into .po files): -# ,,python setup.py build_i18n -m'' +# python setup.py build_i18n -m for line in open('software-station').readlines(): - if (line.startswith('__VERSION__')): + if line.startswith('__VERSION__'): exec(line.strip()) break -# Silence flake8, __VERSION__ is properly assigned below else: __VERSION__ = '2.0' @@ -35,21 +40,24 @@ def datafilelist(installbase, sourcebase): prefix = sys.prefix - -# '{prefix}/share/man/man1'.format(prefix=sys.prefix), glob('data/*.1')), - data_files = [ (f'{prefix}/share/applications', ['software-station.desktop']), (f'{prefix}/etc/sudoers.d', ['sudoers.d/software-station']), ] -data_files.extend(datafilelist(f'{prefix}/share/locale', 'build/mo')) +# Only add locale files if they exist +if os.path.isdir('build/mo'): + data_files.extend(datafilelist(f'{prefix}/share/locale', 'build/mo')) -cmdclass = { - "build": DistUtilsExtra.command.build_extra.build_extra, - "build_i18n": DistUtilsExtra.command.build_i18n.build_i18n, - "clean": DistUtilsExtra.command.clean_i18n.clean_i18n, -} +# Only set up i18n commands if DistUtilsExtra is available +if HAS_DISTUTILS_EXTRA: + cmdclass = { + "build": DistUtilsExtra.command.build_extra.build_extra, + "build_i18n": DistUtilsExtra.command.build_i18n.build_i18n, + "clean": DistUtilsExtra.command.clean_i18n.clean_i18n, + } +else: + cmdclass = {} setup( name="software-station", @@ -57,11 +65,22 @@ def datafilelist(installbase, sourcebase): description="GhostBSD software manager", license='BSD', author='Eric Turgeon', - url='https://github/GhostBSD/software-station/', + url='https://github.com/GhostBSD/software-station/', package_dir={'': '.'}, + packages=['software_station'], + package_data={ + 'software_station': [ + '__init__.py', + 'icons.py', + 'desktop_index.py', + 'pkg_desktop_map.py', + 'accessories_map.py', + ], + }, data_files=data_files, install_requires=['setuptools'], - py_modules=["software_station_pkg", "software_station_xpm"], + py_modules=["software_station_pkg", "software_station_xpm", "iconlist"], scripts=['software-station'], - cmdclass=cmdclass + cmdclass=cmdclass, + python_requires='>=3.11', ) diff --git a/software-station b/software-station old mode 100755 new mode 100644 index 7a64355..372c778 --- a/software-station +++ b/software-station @@ -28,6 +28,10 @@ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ +# Third-party imports +import gi +gi.require_version('Gtk', '3.0') + # Standard library imports import threading import pwd @@ -36,17 +40,10 @@ import gettext import crypt from time import sleep -# Third-party imports -import gi -gi.require_version('Gtk', '3.0') from gi.repository import Gtk, GdkPixbuf, GLib, Gdk -# Configure gettext for internationalization -gettext.bindtextdomain('software-station', '/usr/local/share/locale') -gettext.textdomain('software-station') -_ = gettext.gettext - # Local imports +from software_station_xpm import xpm_package_category from software_station_pkg import ( search_packages, available_package_origin, @@ -63,7 +60,16 @@ from software_station_pkg import ( start_update_station, repository_is_syncing ) -from software_station_xpm import xpm_package_category +from iconlist import ( + init_icons_runtime, + themed_icon_and_label_async, + themed_icon_and_label_sync, +) + +# Configure gettext for internationalization +gettext.bindtextdomain('software-station', '/usr/local/share/locale') +gettext.textdomain('software-station') +_ = gettext.gettext __VERSION__ = '2.0' @@ -75,7 +81,8 @@ global pkg_to_uninstall pkg_to_uninstall = [] -class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,too-many-public-methods +class TableWindow( + Gtk.Window): # pylint: disable=too-many-instance-attributes,too-many-public-methods """Main window for the Software Station GUI.""" def __init__(self): @@ -146,7 +153,7 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t self.search_description = Gtk.CheckButton( label=_("Search Description")) self.search_description.connect("toggled", - self.search_description_toggled) + self.search_description_toggled) self.search_description.set_sensitive(False) hbox.pack_start(self.search_description, False, False, 0) self.apply_button = Gtk.Button() @@ -201,6 +208,9 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t grid.attach(self.progress, 4, 0, 6, 1) grid.show() self.box1.pack_start(grid, False, False, 0) + # Initialize themed icon runtime (main thread) + init_icons_runtime() + self.show_all() # pylint: disable=no-member self.initial_thread('initial') @@ -257,7 +267,7 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t """Apply selected package changes.""" self.confirm_window.hide() self.apply_thr = threading.Thread(target=self.apply_package_change, - args=()) + args=()) self.apply_thr.start() def stop_apply_tread(self): @@ -566,40 +576,45 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t """Update package search results.""" if globals()[f'stop_search_{search}'] is True: return + pixbuf = Gtk.IconTheme.get_default().load_icon('package-x-generic', 42, 0) + if len(self.search) > 1: if globals()[f'stop_search_{search}'] is True: return + self.category_tree_selection.unselect_all() - if globals()[f'stop_search_{search}'] is True: - return self.pkg_store.clear() + if globals()[f'stop_search_{search}'] is True: return + for pkg in search_packages(search, self.description_search): if globals()[f'stop_search_{search}'] is True: return - if not pkg: # Skip empty package names - continue - if not self.available_pkg.get('all'): # Check if package data exists - continue - try: - pkg_data = self.available_pkg['all'].get(pkg, {}) - version = pkg_data.get('version', 'N/A') - size = pkg_data.get('size', 'N/A') - comment = pkg_data.get('comment', '') - description = pkg_data.get('description', '') - if pkg in pkg_to_install: - installed = True - elif pkg in pkg_to_uninstall: - installed = False - else: - installed = pkg_data.get('installed', False) - self.pkg_store.append([pixbuf, pkg, version, size, comment, - installed, description]) - except (KeyError, TypeError) as e: - print(f"Error processing package {pkg}: {e}") - continue + + pkg_data = self.available_pkg['all'].get(pkg, {}) + version = pkg_data.get('version', 'N/A') + size = pkg_data.get('size', 'N/A') + comment = pkg_data.get('comment', '') + description = pkg_data.get('description', '') + + if pkg in pkg_to_install: + installed = True + elif pkg in pkg_to_uninstall: + installed = False + else: + installed = pkg_data.get('installed', False) + + it = self.pkg_store.append( + [pixbuf, pkg, version, size, comment, installed, description]) + + def _on_ready(label, themed_pixbuf, it=it): + if themed_pixbuf is not None: + self.pkg_store.set(it, 0, themed_pixbuf) + + themed_icon_and_label_async( + getattr(self, 'category', ''), pkg, 42, _on_ready) def category_store_sync(self): """Sync package categories in the UI.""" @@ -648,8 +663,20 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t installed = False else: installed = pkg_d[pkg]['installed'] - self.pkg_store.append([pixbuf, pkg, version, size, comment, - installed, description]) + it = self.pkg_store.append( + [pixbuf, pkg, version, size, comment, installed, description]) + + def _on_ready(label, themed_pixbuf, it=it): + if themed_pixbuf is not None: + self.pkg_store.set(it, 0, themed_pixbuf) + themed_icon_and_label_async( + getattr( + self, + 'category', + ''), + pkg, + 42, + _on_ready) def add_and_rm_pkg(self, cell, path, model): # pylint: disable=unused-argument """Add or remove packages from install/uninstall lists.""" @@ -677,7 +704,8 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t self.table = Gtk.Table(n_rows=12, n_columns=12, homogeneous=True) self.table.show_all() category_sw = Gtk.ScrolledWindow() - category_sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member + category_sw.set_shadow_type( + Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member category_sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self.store = Gtk.ListStore(GdkPixbuf.Pixbuf, str) @@ -702,7 +730,8 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t category_sw.show() self.pkg_sw = Gtk.ScrolledWindow() - self.pkg_sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member + self.pkg_sw.set_shadow_type( + Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member self.pkg_sw.set_policy( Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self.pkg_store = Gtk.ListStore( @@ -748,7 +777,8 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t self.pkgtreeview.set_tooltip_column(4) self.description_sw = Gtk.ScrolledWindow() - self.description_sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member + self.description_sw.set_shadow_type( + Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member self.description_sw.set_policy( Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC @@ -756,7 +786,8 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t description_label = Gtk.Label( label=_("Click on a package to show its detailed description.") ) - self.description_sw.add_with_viewport(description_label) # pylint: disable=no-member + self.description_sw.add_with_viewport( + description_label) # pylint: disable=no-member self.pkg_tree_selection = self.pkgtreeview.get_selection() self.pkgtreeview.set_sensitive(False) self.pkg_tree_selection.connect("changed", self.on_selection_changed, @@ -810,7 +841,8 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t self.confirm_window.set_keep_above(True) # pylint: disable=no-member self.confirm_window.set_title(_("Confirm software changes")) self.confirm_window.set_border_width(0) # pylint: disable=no-member - self.confirm_window.set_position(Gtk.WindowPosition.CENTER) # pylint: disable=no-member + self.confirm_window.set_position( + Gtk.WindowPosition.CENTER) # pylint: disable=no-member box1 = Gtk.VBox(homogeneous=False, spacing=0) self.confirm_window.add(box1) # pylint: disable=no-member box1.show() @@ -825,7 +857,8 @@ class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,t box2.pack_start(titlelabel, False, False, 0) self.tree_store = Gtk.TreeStore(str) sw = Gtk.ScrolledWindow() - sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member + sw.set_shadow_type( + Gtk.ShadowType.ETCHED_IN) # pylint: disable=no-member sw.add(self.display(self.store_changes())) # pylint: disable=no-member sw.show() box2.pack_start(sw, True, True, 5) @@ -920,7 +953,8 @@ class Confirmation(Gtk.Window): # pylint: disable=missing-class-docstring for line in f: if line.startswith(f"{user}:"): stored_hash = line.split(':')[1] - print(f"Stored hash prefix: {stored_hash[:10]}...") # Truncate for safety + # Truncate for safety + print(f"Stored hash prefix: {stored_hash[:10]}...") break if not stored_hash or stored_hash in ('x', '*'): print(f"No valid hash found for user {user}") diff --git a/software_station/accessories_map.py b/software_station/accessories_map.py new file mode 100644 index 0000000..105121d --- /dev/null +++ b/software_station/accessories_map.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +ACCESSORIES_MAP = { + # Browsers + "chromium": {"name": "Chromium", "icon": "chromium"}, + "firefox": {"name": "Firefox", "icon": "firefox"}, + "falkon": {"name": "Falkon", "icon": "falkon"}, + + # Office + "libreoffice": {"name": "LibreOffice", "icon": "libreoffice-startcenter"}, + "libreoffice-writer": {"name": "LibreOffice Writer", "icon": "libreoffice-writer"}, + "libreoffice-calc": {"name": "LibreOffice Calc", "icon": "libreoffice-calc"}, + "libreoffice-impress":{"name": "LibreOffice Impress","icon": "libreoffice-impress"}, + + # Media + "vlc": {"name": "VLC", "icon": "vlc"}, + "mpv": {"name": "MPV", "icon": "mpv"}, + "audacity": {"name": "Audacity", "icon": "audacity"}, + + # Terminals & editors + "mate-terminal": {"name": "Terminal", "icon": "utilities-terminal"}, + "xterm": {"name": "XTerm", "icon": "utilities-terminal"}, + "vim": {"name": "Vim", "icon": "accessories-text-editor"}, + "gedit": {"name": "Text Editor", "icon": "accessories-text-editor"}, + + # Utilities + "gparted": {"name": "GParted", "icon": "gparted"}, + "baobab": {"name": "Disk Usage Analyzer","icon": "baobab"}, + "htop": {"name": "System Monitor", "icon": "utilities-system-monitor"}, + + # Archive/file + "file-roller": {"name": "Archive Manager", "icon": "file-roller"}, + "engrampa": {"name": "Archive Manager", "icon": "engrampa"}, + + # Web comms + "telegram-desktop": {"name": "Telegram", "icon": "telegram-desktop"}, + "telegram": {"name": "Telegram", "icon": "telegram-desktop"}, + "thunderbird": {"name": "Thunderbird", "icon": "thunderbird"}, + + # Graphics + "gimp": {"name": "GIMP", "icon": "gimp"}, + "inkscape": {"name": "Inkscape", "icon": "inkscape"}, +} + diff --git a/software_station/desktop_index.py b/software_station/desktop_index.py new file mode 100644 index 0000000..bb5f07c --- /dev/null +++ b/software_station/desktop_index.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Desktop entry index for quick lookups (localized names + icons). + +API +- build_index_async() +- wait_until_ready(timeout=2.0) # optional +- best_guess(token) -> dict | None +""" +from __future__ import annotations + +import os +import threading +import logging +from glob import glob +from functools import lru_cache +from typing import Optional, Dict + +from gi.repository import GLib + +logger = logging.getLogger(__name__) + +_DESKTOP_DIRS = ( + "/usr/local/share/applications", + "/usr/share/applications", + os.path.expanduser("~/.local/share/applications"), +) + +_index: Dict[str, Dict[str, str]] = {} +_ready = threading.Event() + +def _parse_localized_name(kf: GLib.KeyFile, locale: str) -> Optional[str]: + sect = "Desktop Entry" + try: + probes = [] + if locale: + probes.append(locale) + if "." in locale: + probes.append(locale.split(".", 1)[0]) # en_US + if "_" in locale: + probes.append(locale.split("_", 1)[0]) # en + for p in probes: + key = f"Name[{p}]" + if kf.has_key(sect, key): + return kf.get_string(sect, key) + if kf.has_key(sect, "Name"): + return kf.get_string(sect, "Name") + except Exception: + pass + return None + +def build_index_async(): + def work(): + try: + locale = os.environ.get("LC_ALL") or os.environ.get("LANG") or "en_US" + for base in _DESKTOP_DIRS: + if not os.path.isdir(base): + logger.debug(f"Desktop directory not found: {base}") + continue + + for path in glob(os.path.join(base, "*.desktop")): + try: + kf = GLib.KeyFile() + kf.load_from_file(path, GLib.KeyFileFlags.NONE) + if not kf.has_group("Desktop Entry"): + continue + name = _parse_localized_name(kf, locale) + icon = kf.get_string("Desktop Entry", "Icon") if kf.has_key("Desktop Entry", "Icon") else None + execv = kf.get_string("Desktop Entry", "Exec") if kf.has_key("Desktop Entry", "Exec") else "" + tryexec = kf.get_string("Desktop Entry", "TryExec") if kf.has_key("Desktop Entry", "TryExec") else "" + did = os.path.basename(path) # e.g., firefox.desktop + + tokens = {did} + if execv: + tokens.add(execv.split()[0]) + if tryexec: + tokens.add(os.path.basename(tryexec)) + for t in tokens: + _index[t] = {"name": name, "icon": icon, "desktop_id": did} + except Exception as e: + logger.debug(f"Failed to process desktop file '{path}': {e}") + continue + except Exception as e: + logger.error(f"Error during desktop index building: {e}", exc_info=True) + finally: + _ready.set() + threading.Thread(target=work, daemon=True).start() + +def wait_until_ready(timeout: float = 2.0) -> bool: + return _ready.wait(timeout) + +@lru_cache(maxsize=4096) +def best_guess(token: str) -> Optional[Dict[str, str]]: + return _index.get(token) diff --git a/software_station/icons.py b/software_station/icons.py new file mode 100644 index 0000000..78786e6 --- /dev/null +++ b/software_station/icons.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from __future__ import annotations + +import os +import threading +from functools import lru_cache +from typing import Optional, Tuple, Callable, Dict + +from gi.repository import Gtk, Gdk, GdkPixbuf, GLib + +# Optional helpers (safe to miss) +try: + from . import desktop_index +except Exception: + desktop_index = None # type: ignore +try: + from . import pkg_desktop_map +except Exception: + pkg_desktop_map = None # type: ignore + +ICON_FALLBACK = "package-x-generic" + +# --- GTK objects must only be touched on the main thread --- +_icon_theme: Gtk.IconTheme | None = None +_scale: int = 1 + +# Main-thread-only pixbuf cache: key = (icon_name or fallback, size, scale) +_pixbuf_cache: Dict[tuple[str, int, int], Optional[GdkPixbuf.Pixbuf]] = {} + +# Small worker pool: enough parallelism without hammering disks +from concurrent.futures import ThreadPoolExecutor +_executor = ThreadPoolExecutor(max_workers=3) + +def _assert_main_thread(): + if threading.current_thread() is not threading.main_thread(): + raise RuntimeError("GTK must be accessed on the main thread") + +def _compute_scale_factor() -> int: + display = Gdk.Display.get_default() + if not display: + return 1 + try: + mon = display.get_primary_monitor() + return mon.get_scale_factor() if mon else 1 + except Exception: + return 1 + +def _rebuild_scale_and_clear_cache(*_args): + global _scale + _scale = _compute_scale_factor() + _pixbuf_cache.clear() + +def _on_icon_theme_change(*_args): + _pixbuf_cache.clear() + +def init_icon_runtime(): + """ + Call ONCE at app startup (main thread). + - creates global IconTheme + - wires watchers for theme & DPI changes + - optionally kicks off indexing helpers + """ + _assert_main_thread() + + global _icon_theme + _icon_theme = Gtk.IconTheme.get_default() + + # Theme watcher + settings = Gtk.Settings.get_default() + if settings: + settings.connect("notify::gtk-icon-theme-name", _on_icon_theme_change) + + # DPI / monitor changes + display = Gdk.Display.get_default() + if display: + display.connect("monitor-added", _rebuild_scale_and_clear_cache) + display.connect("monitor-removed", _rebuild_scale_and_clear_cache) + _rebuild_scale_and_clear_cache() + + # Optional background indices + if desktop_index and hasattr(desktop_index, "build_index_async"): + desktop_index.build_index_async() + + # Optional: build pkg → .desktop map, unless explicitly disabled + if ( + pkg_desktop_map + and hasattr(pkg_desktop_map, "build_pkg_map_async") + and os.environ.get("SOFTWARE_STATION_DISABLE_PKG_MAP") != "1" + ): + pkg_desktop_map.build_pkg_map_async() + +def _load_icon_pixbuf_main(icon_name: Optional[str], size: int = 32) -> Optional[GdkPixbuf.Pixbuf]: + """ + Main-thread ONLY. HiDPI-aware with robust fallback. Cached. + """ + _assert_main_thread() + assert _icon_theme is not None + + key = ((icon_name or ICON_FALLBACK), size, _scale) + if key in _pixbuf_cache: + return _pixbuf_cache[key] + + name = icon_name or ICON_FALLBACK + pix: Optional[GdkPixbuf.Pixbuf] = None + try: + # Prefer lookup_icon_for_scale if present + if hasattr(_icon_theme, "lookup_icon_for_scale"): + info = _icon_theme.lookup_icon_for_scale(name, size, _scale, 0) + if not info: + info = _icon_theme.lookup_icon_for_scale(ICON_FALLBACK, size, _scale, 0) + if info: + pix = info.load_icon() + else: + if _icon_theme.has_icon(name): + pix = _icon_theme.load_icon(name, size, 0) + elif _icon_theme.has_icon(ICON_FALLBACK): + pix = _icon_theme.load_icon(ICON_FALLBACK, size, 0) + except Exception: + # best-effort final fallback + try: + if _icon_theme.has_icon(ICON_FALLBACK): + pix = _icon_theme.load_icon(ICON_FALLBACK, size, 0) + except Exception: + pix = None + + _pixbuf_cache[key] = pix + return pix + +@lru_cache(maxsize=4096) +def _friendly_name_guess(pkg_or_token: str) -> Optional[str]: + """ + Try desktop_index first (localized Name), else None. + """ + if not desktop_index: + return None + try: + hit = desktop_index.best_guess(pkg_or_token) + if hit and isinstance(hit, dict): + return hit.get("name") + except Exception: + return None + return None + +@lru_cache(maxsize=4096) +def _icon_name_guess(pkg_or_token: str) -> Optional[str]: + """ + Try desktop_index first (Icon=), else None. + """ + if not desktop_index: + return None + try: + hit = desktop_index.best_guess(pkg_or_token) + if hit and isinstance(hit, dict): + return hit.get("icon") + except Exception: + return None + return None + +def _resolve_label_and_icon_name_worker(pkg_name: str, curated_map: dict) -> tuple[str, Optional[str]]: + """ + Worker thread: decide (label, icon_name STRING). No GTK here. + Order: + 1) curated_map + 2) pkg → desktop (optional, via pkg_desktop_map → desktop_index) + 3) desktop_index.best_guess(pkg_name) + 4) fallback: pkg_name + None + """ + # 1) curated + info = curated_map.get(pkg_name, {}) + friendly = info.get("name") + icon_name = info.get("icon") + + # 2) pkg → desktop path → desktop_index + if (not friendly or not icon_name) and pkg_desktop_map and hasattr(pkg_desktop_map, "desktop_for_pkg"): + try: + desktop_path = pkg_desktop_map.desktop_for_pkg(pkg_name) + if desktop_path and desktop_index: + desktop_id = os.path.basename(desktop_path) + hit = desktop_index.best_guess(desktop_id) + if hit and isinstance(hit, dict): + if not friendly: + friendly = hit.get("name") + if not icon_name: + icon_name = hit.get("icon") + except Exception: + pass + + # 3) direct desktop_index lookup + if not friendly or not icon_name: + name_guess = _friendly_name_guess(pkg_name) + icon_guess = _icon_name_guess(pkg_name) + if not friendly and name_guess: + friendly = name_guess + if not icon_name and icon_guess: + icon_name = icon_guess + + # 4) fallback + if not friendly: + friendly = pkg_name + + return (friendly, icon_name) + +def resolve_label_and_icon_sync( + pkg_name: str, + curated_map: dict, + size: int = 32 +) -> Tuple[str, Optional[GdkPixbuf.Pixbuf]]: + """ + Synchronous resolution (must run on main thread). + Returns (label, pixbuf). + """ + _assert_main_thread() + label, icon_name = _resolve_label_and_icon_name_worker(pkg_name, curated_map) + pixbuf = _load_icon_pixbuf_main(icon_name, size) + return (label, pixbuf) + +def resolve_label_and_icon_async( + pkg_name: str, + curated_map: dict, + size: int, + on_ready: Callable[[str, Optional[GdkPixbuf.Pixbuf]], None] +): + """ + Asynchronous resolution: spawn worker to determine (label, icon_name), + then load pixbuf on main thread and invoke callback on main thread. + """ + def worker(): + label, icon_name = _resolve_label_and_icon_name_worker(pkg_name, curated_map) + + def main_thread_finish(): + pixbuf = _load_icon_pixbuf_main(icon_name, size) + on_ready(label, pixbuf) + + GLib.idle_add(main_thread_finish) + + _executor.submit(worker) diff --git a/software_station/pkg_desktop_map.py b/software_station/pkg_desktop_map.py new file mode 100644 index 0000000..60569fb --- /dev/null +++ b/software_station/pkg_desktop_map.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Optional: map installed pkg → one of its .desktop files. + +This version uses: + - 'pkg query %n' to enumerate installed package names + - 'pkg info -l ' to list files installed by each package + +All subprocess calls capture stdout and suppress stderr to avoid noisy logs. + +API +- build_pkg_map_async() +- desktop_for_pkg(pkgname) -> str | None +""" +from __future__ import annotations + +import subprocess +import threading +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Optional, Dict + +logger = logging.getLogger(__name__) + +_pkg_map: Dict[str, str] = {} +_ready = threading.Event() + +def _run(cmd: list[str]) -> str: + """ + Run a command, return stdout as text; swallow errors and stderr. + + Security: cmd must be a list to prevent shell injection. + """ + if not isinstance(cmd, list): + raise ValueError("Command must be a list to prevent shell injection") + + try: + cp = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + shell=False # Explicitly disable shell for security + ) + return cp.stdout or "" + except Exception as e: + logger.debug(f"Command failed: {cmd}, error: {e}") + return "" + +def _process_package(pkg: str) -> tuple[str, Optional[str]]: + """ + Process a single package to find its .desktop file. + Returns (pkg_name, desktop_path or None). + """ + # List files for this package + listing = _run(["pkg", "info", "-l", pkg]) + if not listing: + return (pkg, None) + + # Lines typically look like: "\t/usr/local/share/applications/foo.desktop" + for line in listing.splitlines(): + path = line.strip() + if path and path.endswith(".desktop"): + return (pkg, path) + + return (pkg, None) + +def build_pkg_map_async(): + """Build the pkg→desktop map in a background thread with parallel processing.""" + def work(): + try: + # All installed package names, one per line + pkgs_txt = _run(["pkg", "query", "%n"]) + if not pkgs_txt: + logger.info("No packages found or pkg command failed") + return + + pkgs = [p for p in pkgs_txt.splitlines() if p] + logger.info(f"Processing {len(pkgs)} packages for desktop files") + + # Process packages in parallel for better performance + processed = 0 + with ThreadPoolExecutor(max_workers=4) as executor: + futures = {executor.submit(_process_package, pkg): pkg for pkg in pkgs} + + for future in as_completed(futures): + try: + pkg, desktop_path = future.result() + if desktop_path: + _pkg_map[pkg] = desktop_path + processed += 1 + + # Log progress every 100 packages + if processed % 100 == 0: + logger.debug(f"Processed {processed}/{len(pkgs)} packages") + except Exception as e: + pkg = futures[future] + logger.debug(f"Failed to process package '{pkg}': {e}") + + logger.info(f"Built pkg→desktop map with {len(_pkg_map)} entries") + except Exception as e: + logger.error(f"Error building pkg→desktop map: {e}", exc_info=True) + finally: + _ready.set() + + threading.Thread(target=work, daemon=True).start() + +def desktop_for_pkg(pkg: str) -> Optional[str]: + """Return a known .desktop path for the package, if any.""" + return _pkg_map.get(pkg)