diff --git a/README.md b/README.md index 2dbb4f4..0663efd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ software-station ================ -Software Station is the future software manager using pkgng. +Software Station is the software manager for GhostBSD. diff --git a/pkg_info.py b/pkg_info.py new file mode 100644 index 0000000..5867dbb --- /dev/null +++ b/pkg_info.py @@ -0,0 +1,92 @@ +"""Package information loader for installed and available FreeBSD packages. + +This module defines the PkgInfo class, which loads installed and available packages +using the system's `pkg` tool, and allows basic search/filtering operations. +""" + +import subprocess +from software_station.search_index import Package, PkgSearchIndex + + +class PkgInfo: + """Handles retrieval of installed and available packages on the system.""" + + + def __init__(self): + """Initializes the package lists and loads data from the package system.""" + self.available: list[Package] = [] + self.installed_names: set[str] = set() + self.index = None + self.load() + + + def load(self): + """Loads both installed and available package data.""" + self.load_installed() + self.load_available() + + + def load_installed(self): + """Retrieves the names of currently installed packages.""" + try: + result = subprocess.run( + ['pkg', 'query', '%n'], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + text=True, + timeout=10 + ) + self.installed_names = {name.strip() for name in result.stdout.splitlines()} + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + self.installed_names = set() + + + def load_available(self): + """Retrieves all available packages with name, version, and description.""" + try: + result = subprocess.run( + ['pkg', 'query', '-a', '%n %v %e'], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + text=True, + timeout=10 + ) + self.available = [] + for line in result.stdout.splitlines(): + parts = line.strip().split(' ', 2) + if len(parts) >= 2: + name, version = parts[0], parts[1] + desc = parts[2] if len(parts) == 3 else '' + self.available.append(Package( + name=name, + version=version, + description=desc, + installed=name in self.installed_names + )) + self.index = PkgSearchIndex(self.available) + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + self.available = [] + self.index = None + + + def search(self, prefix: str) -> list[Package]: + """Searches available packages by prefix. + + Args: + prefix (str): The string to match at the beginning of package names. + + Returns: + list[Package]: Matching package list. + """ + return self.available if self.index is None else self.index.search_prefix(prefix) + + + def get_installed(self) -> list[Package]: + """Returns all packages that are currently installed. + + Returns: + list[Package]: Installed packages. + """ + return [pkg for pkg in self.available if pkg.installed] diff --git a/software-station b/software-station index 2f6bfcf..7a64355 100755 --- a/software-station +++ b/software-station @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Copyright (c) 2017-2018, GhostBSD. All rights reserved. +Copyright (c) 2017-2025, GhostBSD. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions @@ -28,20 +28,25 @@ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ -import gi -gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, GdkPixbuf, GLib, Gdk +# Standard library imports import threading -import crypt import pwd import os 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_pkg import ( search_packages, available_package_origin, @@ -58,11 +63,11 @@ from software_station_pkg import ( start_update_station, repository_is_syncing ) - from software_station_xpm import xpm_package_category __VERSION__ = '2.0' +# pylint: disable=global-at-module-level global pkg_to_install pkg_to_install = [] @@ -70,19 +75,19 @@ global pkg_to_uninstall pkg_to_uninstall = [] -class TableWindow(Gtk.Window): +class TableWindow(Gtk.Window): # pylint: disable=too-many-instance-attributes,too-many-public-methods + """Main window for the Software Station GUI.""" def __init__(self): - Gtk.Window.__init__(self) + super().__init__() self.set_title(_("Software Station")) self.connect("delete-event", Gtk.main_quit) self.set_size_request(850, 500) self.set_default_icon_name('system-software-install') # Creating the toolbar toolbar = Gtk.Toolbar() - # toolbar.set_style(Gtk.ToolbarStyle.BOTH) self.box1 = Gtk.VBox(homogeneous=False, spacing=0) - self.add(self.box1) + self.add(self.box1) # pylint: disable=no-member self.box1.show() self.box1.pack_start(toolbar, False, False, 0) self.previousbutton = Gtk.ToolButton() @@ -90,18 +95,15 @@ class TableWindow(Gtk.Window): self.previousbutton.set_is_important(True) self.previousbutton.set_icon_name("go-previous") self.previousbutton.set_sensitive(False) - #toolbar.insert(self.previousbutton, 1) self.nextbutton = Gtk.ToolButton() self.nextbutton.set_label(_("Forward")) self.nextbutton.set_is_important(True) self.nextbutton.set_icon_name("go-next") self.nextbutton.set_sensitive(False) - #toolbar.insert(self.nextbutton, 2) self.desc_is_shown = False self.desc_toggle = Gtk.ToggleToolButton() self.desc_toggle.set_property("tooltip-text", _("Show Description")) - # self.desc_toggle.set_label(_("Show Description")) self.desc_toggle.set_icon_name("view-list") self.desc_toggle.connect("toggled", self.toggle_description) self.desc_toggle.set_sensitive(False) @@ -136,34 +138,32 @@ class TableWindow(Gtk.Window): self.search_entry.connect("key-release-event", self.search_release) self.search_entry.set_property("tooltip-text", _("Search Software")) self.search_entry.set_sensitive(False) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - toolitem.add(hBox) - hBox.show() - hBox.pack_start(self.search_entry, False, False, 0) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + toolitem.add(hbox) + hbox.show() + hbox.pack_start(self.search_entry, False, False, 0) self.description_search = False 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) + hbox.pack_start(self.search_description, False, False, 0) self.apply_button = Gtk.Button() self.apply_button.set_label(_("Apply")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-apply', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-apply', 1) self.apply_button.set_image(apply_img) self.apply_button.set_property( "tooltip-text", _("Apply change on the system")) self.apply_button.set_sensitive(False) - hBox.pack_end(self.apply_button, False, False, 0) + hbox.pack_end(self.apply_button, False, False, 0) self.cancel_button = Gtk.Button() self.cancel_button.set_label(_("Cancel")) - cancel_img = Gtk.Image() - cancel_img.set_from_icon_name('gtk-cancel', 1) + cancel_img = Gtk.Image.new_from_icon_name('gtk-cancel', 1) self.cancel_button.set_image(cancel_img) self.cancel_button.set_sensitive(False) self.cancel_button.set_property("tooltip-text", _("Cancel changes")) - hBox.pack_end(self.cancel_button, False, False, 0) + hbox.pack_end(self.cancel_button, False, False, 0) self.apply_button.connect("clicked", self.confirm_packages) self.cancel_button.connect("clicked", self.cancel_change) # Creating a notebook to switch @@ -171,7 +171,7 @@ class TableWindow(Gtk.Window): self.mainstack.show() self.mainstack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT) self.box1.pack_start(self.mainstack, True, True, 0) - mainwin = self.MainBook() + mainwin = self.main_book() self.mainstack.add_named(mainwin, "mainwin") self.pkg_statistic = Gtk.Label() self.pkg_statistic.set_use_markup(True) @@ -188,7 +188,7 @@ class TableWindow(Gtk.Window): context = Gtk.StyleContext() screen = Gdk.Screen.get_default() context.add_provider_for_screen( - screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) # pylint: disable=no-member grid = Gtk.Grid() grid.set_column_spacing(10) grid.set_margin_start(10) @@ -201,38 +201,42 @@ class TableWindow(Gtk.Window): grid.attach(self.progress, 4, 0, 6, 1) grid.show() self.box1.pack_start(grid, False, False, 0) - self.show_all() + self.show_all() # pylint: disable=no-member self.initial_thread('initial') - def search_description_toggled(self, widget): + def search_description_toggled(self, widget): # pylint: disable=unused-argument + """Toggle whether to search package descriptions.""" if widget.get_active(): self.description_search = True else: self.description_search = False - def toggle_description(self, widget): + def toggle_description(self, widget): # pylint: disable=unused-argument + """Toggle the display of package descriptions in the UI.""" self.desc_is_shown = not self.desc_is_shown if self.desc_is_shown: self.table.remove(self.pkg_sw) self.table.attach(self.pkg_sw, 2, 12, 0, 8) self.table.attach(self.description_sw, 2, 12, 8, 12) self.pkg_sw.show() - self.description_sw.show_all() + self.description_sw.show_all() # pylint: disable=no-member else: self.table.remove(self.pkg_sw) self.table.remove(self.description_sw) self.table.attach(self.pkg_sw, 2, 12, 0, 12) self.pkg_sw.show() - def cancel_change(self, widget): + def cancel_change(self, widget): # pylint: disable=unused-argument + """Cancel pending package changes and reset the UI.""" + # pylint: disable=global-statement global pkg_to_uninstall global pkg_to_install pkg_to_install = [] pkg_to_uninstall = [] pkg_selection = self.pkgtreeview.get_selection() - (model, iter) = pkg_selection.get_selected() + model, selected_iter = pkg_selection.get_selected() try: - pkg_path = model.get_path(iter) + pkg_path = model.get_path(selected_iter) except KeyError: nopath = True else: @@ -249,17 +253,21 @@ class TableWindow(Gtk.Window): self.apply_button.set_sensitive(False) self.cancel_button.set_sensitive(False) - def apply_change(self, widget): + def apply_change(self, widget): # pylint: disable=unused-argument + """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): + """Stop the package change thread.""" self.apply_thr.join() def apply_package_change(self): + """Process package installation and uninstallation.""" self.progress.show() + # pylint: disable=global-statement global pkg_to_uninstall global pkg_to_install un_num = len(pkg_to_uninstall) @@ -297,8 +305,7 @@ class TableWindow(Gtk.Window): fraction += increment for pkg in pkg_to_install: - msg = _("Installing") - msg += f" {pkg}" + msg = f"{_('Installing')} {pkg}" GLib.idle_add(self.update_progress, self.progress, fraction, msg) dpkg = install_packages(pkg) while True: @@ -328,6 +335,7 @@ class TableWindow(Gtk.Window): GLib.idle_add(self.unlock_ui) def all_or_installed(self, widget, data): + """Switch between displaying all or installed packages.""" if widget.get_active(): self.available_or_installed = data if data == 'available': @@ -345,20 +353,24 @@ class TableWindow(Gtk.Window): self.update_pkg_store() def sync_available_packages(self): + """Sync available package data.""" self.available_category = available_package_origin() self.available_pkg = available_package_dictionary( self.available_category) def sync_installed_packages(self): + """Sync installed package data.""" self.installed_category = installed_package_origin() self.installed_pkg = installed_package_dictionary( self.installed_category) def update_progress(self, progress, fraction, msg): + """Update the progress bar with the given fraction and message.""" progress.set_fraction(fraction) progress.set_text(msg) def initial_sync(self): + """Perform initial synchronization of package data.""" self.pkg_statistic.set_text(_('Syncing statistic')) self.pkg_statistic.set_use_markup(True) self.network = network_stat() @@ -408,18 +420,21 @@ class TableWindow(Gtk.Window): elif self.need_upgrade is True: GLib.idle_add(self.confirm_upgrade) - def hide_window(self, button, window): + def hide_window(self, button, window): # pylint: disable=unused-argument + """Hide the specified window.""" window.hide() def confirm_offline(self): + """Show a dialog for offline or syncing repository status.""" window = Gtk.Window() window.set_title(_("Software Station")) window.connect("delete-event", Gtk.main_quit) - window.set_keep_above(True) + window.set_keep_above(True) # pylint: disable=no-member window.set_size_request(200, 80) box1 = Gtk.VBox(homogeneous=False, spacing=0) - window.add(box1) + window.add(box1) # pylint: disable=no-member box1.show() + msg = None if self.network == 'Down': msg = _('Network device is offline') elif self.online is False: @@ -428,75 +443,75 @@ class TableWindow(Gtk.Window): msg = _( "Software repositories are syncing with new software packages" ) - label = Gtk.Label(label=msg) - box1.pack_start(label, True, True, 0) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - box1.pack_end(hBox, False, False, 5) + if msg: + label = Gtk.Label(label=msg) + box1.pack_start(label, True, True, 0) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + box1.pack_end(hbox, False, False, 5) offline_button = Gtk.Button() offline_button.set_label(_("Use Offline")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-ok', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-ok', 1) offline_button.set_image(apply_img) offline_button.connect("clicked", self.hide_window, window) - hBox.pack_end(offline_button, False, False, 5) + hbox.pack_end(offline_button, False, False, 5) close_button = Gtk.Button() close_button.set_label(_("Close")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-close', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-close', 1) close_button.set_image(apply_img) close_button.connect("clicked", Gtk.main_quit) - hBox.pack_end(close_button, False, False, 5) - window.show_all() + hbox.pack_end(close_button, False, False, 5) + window.show_all() # pylint: disable=no-member - def start_upgrade(self, button): + def start_upgrade(self, button): # pylint: disable=unused-argument + """Start the system upgrade process.""" start_update_station() Gtk.main_quit() def confirm_upgrade(self): + """Show a dialog prompting for system upgrade.""" window = Gtk.Window() window.set_title(_("Upgrade Needed")) window.connect("delete-event", Gtk.main_quit) - window.set_keep_above(True) + window.set_keep_above(True) # pylint: disable=no-member window.set_size_request(200, 80) - vBox = Gtk.VBox(homogeneous=False, spacing=0) - window.add(vBox) - vBox.show() - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - vBox.pack_start(hBox, False, False, 10) + vbox = Gtk.VBox(homogeneous=False, spacing=0) + window.add(vbox) # pylint: disable=no-member + vbox.show() + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + vbox.pack_start(hbox, False, False, 10) title = _("Warning this system needs to be upgraded!") title_label = Gtk.Label(label=title) title_label.set_use_markup(True) title_label.set_justify(Gtk.Justification.CENTER) - hBox.pack_start(title_label, True, True, 10) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - vBox.pack_start(hBox, False, False, 5) + hbox.pack_start(title_label, True, True, 10) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + vbox.pack_start(hbox, False, False, 5) msg = _("Installing software without upgrading could harm this " "installation.\nWould you like to upgrade now?") msg_label = Gtk.Label(label=msg) - hBox.pack_start(msg_label, True, True, 20) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - vBox.pack_end(hBox, False, False, 5) + hbox.pack_start(msg_label, True, True, 20) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + vbox.pack_end(hbox, False, False, 5) offline_button = Gtk.Button() offline_button.set_label(_("Yes")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-yes', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-yes', 1) offline_button.set_image(apply_img) offline_button.connect("clicked", self.start_upgrade) - hBox.pack_end(offline_button, False, False, 5) + hbox.pack_end(offline_button, False, False, 5) close_button = Gtk.Button() close_button.set_label(_("No")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-no', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-no', 1) close_button.set_image(apply_img) close_button.connect("clicked", self.hide_window, window) - hBox.pack_end(close_button, False, False, 5) - window.show_all() + hbox.pack_end(close_button, False, False, 5) + window.show_all() # pylint: disable=no-member def unlock_ui(self): + """Enable UI elements.""" self.desc_toggle.set_sensitive(True) self.origin_treeview.set_sensitive(True) self.pkgtreeview.set_sensitive(True) @@ -508,6 +523,7 @@ class TableWindow(Gtk.Window): self.search_description.set_sensitive(True) def lock_ui(self): + """Disable UI elements during operations.""" self.desc_toggle.set_sensitive(False) self.search_entry.set_sensitive(False) self.search_description.set_sensitive(False) @@ -517,21 +533,25 @@ class TableWindow(Gtk.Window): self.pkgtreeview.set_sensitive(False) def stop_tread(self): + """Stop the synchronization thread.""" self.thr.join() - def initial_thread(self, sync): + def initial_thread(self, sync): # pylint: disable=unused-argument + """Start the initial synchronization thread.""" self.thr = threading.Thread(target=self.initial_sync, args=()) self.thr.start() - def selected_software(self, view, event): + def selected_software(self, view, event): # pylint: disable=unused-argument + """Handle package selection in the UI.""" selection = self.pkgtreeview.get_selection() - (model, iter) = selection.get_selected() + selection.get_selected() # No-op, kept for compatibility - def search_release(self, widget, event): + def search_release(self, widget, event): # pylint: disable=unused-argument + """Handle search input events.""" self.search = widget.get_text() try: - self.previous_search - globals()[f'stop_search_{self.previous_search}'] = True + if hasattr(self, 'previous_search'): + globals()[f'stop_search_{self.previous_search}'] = True except (AttributeError, KeyError): pass if self.search: @@ -543,6 +563,7 @@ class TableWindow(Gtk.Window): self.previous_search = self.search def update_search(self, search): + """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) @@ -558,20 +579,30 @@ class TableWindow(Gtk.Window): for pkg in search_packages(search, self.description_search): if globals()[f'stop_search_{search}'] is True: return - version = self.available_pkg['all'][pkg]['version'] - size = self.available_pkg['all'][pkg]['size'] - comment = self.available_pkg['all'][pkg]['comment'] - description = self.available_pkg['all'][pkg]['description'] - if pkg in pkg_to_install: - installed = True - elif pkg in pkg_to_uninstall: - installed = False - else: - installed = self.available_pkg['all'][pkg]['installed'] - self.pkg_store.append([pixbuf, pkg, version, size, comment, - installed, description]) + 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 def category_store_sync(self): + """Sync package categories in the UI.""" self.store.clear() self.search_entry.set_text('') if self.online is True and repository_is_syncing() is False: @@ -585,6 +616,7 @@ class TableWindow(Gtk.Window): self.origin_treeview.set_cursor(0) def selection_category(self, tree_selection): + """Handle category selection in the UI.""" (model, pathlist) = tree_selection.get_selected_rows() if pathlist: path = pathlist[0] @@ -594,6 +626,7 @@ class TableWindow(Gtk.Window): self.update_pkg_store() def update_pkg_store(self): + """Update the package store with current data.""" self.pkg_store.clear() pixbuf = Gtk.IconTheme.get_default().load_icon('package-x-generic', 42, 0) if self.available_or_installed == 'available': @@ -618,7 +651,8 @@ class TableWindow(Gtk.Window): self.pkg_store.append([pixbuf, pkg, version, size, comment, installed, description]) - def add_and_rm_pkg(self, cell, path, model): + def add_and_rm_pkg(self, cell, path, model): # pylint: disable=unused-argument + """Add or remove packages from install/uninstall lists.""" model[path][5] = not model[path][5] pkg = model[path][1] if pkg not in pkg_to_uninstall and pkg not in pkg_to_install: @@ -638,11 +672,12 @@ class TableWindow(Gtk.Window): self.apply_button.set_sensitive(True) self.cancel_button.set_sensitive(True) - def MainBook(self): + def main_book(self): + """Create the main notebook for package display.""" 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) + 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) @@ -663,11 +698,11 @@ class TableWindow(Gtk.Window): self.category_tree_selection.connect( "changed", self.selection_category) self.origin_treeview.set_sensitive(False) - category_sw.add(self.origin_treeview) + category_sw.add(self.origin_treeview) # pylint: disable=no-member category_sw.show() self.pkg_sw = Gtk.ScrolledWindow() - self.pkg_sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + 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( @@ -690,7 +725,6 @@ class TableWindow(Gtk.Window): self.pkgtreeview.append_column(pixbuf_column) name_cell = Gtk.CellRendererText() name_column = Gtk.TreeViewColumn(_('Package Name'), name_cell, text=1) - # name_column.set_sizing(Gtk.TREE_VIEW_COLUMN_AUTOSIZE) name_column.set_fixed_width(150) name_column.set_sort_column_id(1) name_column.set_resizable(True) @@ -706,7 +740,6 @@ class TableWindow(Gtk.Window): size_column.set_fixed_width(100) size_column.set_sort_column_id(3) size_column.set_resizable(True) - # self.pkgtreeview.append_column(size_column) comment_cell = Gtk.CellRendererText() comment_column = Gtk.TreeViewColumn(_('Comment'), comment_cell, text=4) comment_column.set_sort_column_id(4) @@ -715,7 +748,7 @@ class TableWindow(Gtk.Window): self.pkgtreeview.set_tooltip_column(4) self.description_sw = Gtk.ScrolledWindow() - self.description_sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + 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 @@ -723,71 +756,68 @@ class TableWindow(Gtk.Window): description_label = Gtk.Label( label=_("Click on a package to show its detailed description.") ) - self.description_sw.add_with_viewport(description_label) - + self.description_sw.add_with_viewport(description_label) # pylint: disable=no-member self.pkg_tree_selection = self.pkgtreeview.get_selection() - # self.pkg_tree_selection.set_mode(Gtk.SelectionMode.NONE) - # tree_selection.connect("clicked", self.selected_software) self.pkgtreeview.set_sensitive(False) self.pkg_tree_selection.connect("changed", self.on_selection_changed, self.description_sw, description_label) - self.pkg_sw.add(self.pkgtreeview) + self.pkg_sw.add(self.pkgtreeview) # pylint: disable=no-member self.pkg_sw.show() self.table.attach(category_sw, 0, 2, 0, 12) self.table.attach(self.pkg_sw, 2, 12, 0, 12) self.show() return self.table - def on_selection_changed(self, selection, description_sw, - description_label): + def on_selection_changed(self, selection, description_sw, description_label): # pylint: disable=unused-argument + """Update package description when selection changes.""" model, treeiter = selection.get_selected() if treeiter is not None: description_label.set_text(model[treeiter][6]) - self.description_sw.show_all() + self.description_sw.show_all() # pylint: disable=no-member - def hidewindow(self, widget): + def hidewindow(self, widget): # pylint: disable=unused-argument + """Hide the confirmation window.""" self.confirm_window.hide() self.cancel_change(None) self.unlock_ui() - def delete_event(self, widget): - # don't delete; hide instead - self.confirm_window.hide_on_delete() + def delete_event(self, widget): # pylint: disable=unused-argument + """Handle window deletion events.""" + self.confirm_window.hide_on_delete() # pylint: disable=no-member def create_bbox(self): + """Create buttons for the confirmation dialog.""" table = Gtk.Table( n_rows=1, n_columns=2, homogeneous=False, column_spacing=5) - img = Gtk.Image(icon_name='gtk-cancel') - Close_button = Gtk.Button(label=_("Cancel")) - Close_button.set_image(img) - table.attach(Close_button, 0, 1, 0, 1) - Close_button.connect("clicked", self.hidewindow) + close_button = Gtk.Button(label=_("Cancel")) + img = Gtk.Image.new_from_icon_name('gtk-cancel', 1) + close_button.set_image(img) + table.attach(close_button, 0, 1, 0, 1) + close_button.connect("clicked", self.hidewindow) confirm_button = Gtk.Button(label=_("Confirm")) table.attach(confirm_button, 1, 2, 0, 1) confirm_button.connect("clicked", self.apply_change) return table - def confirm_packages(self, widget): + def confirm_packages(self, widget): # pylint: disable=unused-argument + """Show the package confirmation dialog.""" self.apply_button.set_sensitive(False) self.cancel_button.set_sensitive(False) self.lock_ui() self.confirm_window = Gtk.Window() self.confirm_window.connect("destroy", self.delete_event) self.confirm_window.set_size_request(600, 300) - self.confirm_window.set_keep_above(True) - # self.confirm_window.set_resizable(False) + 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) - self.confirm_window.set_position(Gtk.WindowPosition.CENTER) - self.confirm_window.set_default_icon_name('system-software-install') + self.confirm_window.set_border_width(0) # 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) + self.confirm_window.add(box1) # pylint: disable=no-member box1.show() box2 = Gtk.VBox(homogeneous=False, spacing=0) box2.set_border_width(5) box1.pack_start(box2, True, True, 0) box2.show() - # Title titletext = _("Software changes to apply") titlelabel = Gtk.Label( label=f"{titletext}") @@ -795,20 +825,19 @@ class TableWindow(Gtk.Window): box2.pack_start(titlelabel, False, False, 0) self.tree_store = Gtk.TreeStore(str) sw = Gtk.ScrolledWindow() - sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(self.display(self.store_changes())) + 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) box2 = Gtk.HBox(homogeneous=False, spacing=5) box2.set_border_width(5) box1.pack_start(box2, False, False, 5) box2.show() - # Add button box2.pack_start(self.create_bbox(), True, True, 5) - self.confirm_window.show_all() + self.confirm_window.show_all() # pylint: disable=no-member def display(self, model): + """Display package changes in a tree view.""" self.view = Gtk.TreeView(model=model) self.renderer = Gtk.CellRendererText() self.column0 = Gtk.TreeViewColumn(_("Name"), self.renderer, text=0) @@ -818,9 +847,11 @@ class TableWindow(Gtk.Window): return self.view def store_changes(self): + """Store package changes for display.""" packages_dictionary = get_pkg_changes_data( pkg_to_uninstall, pkg_to_install) self.tree_store.clear() + # pylint: disable=global-variable-undefined global total_num r_num = 0 u_num = 0 @@ -828,29 +859,25 @@ class TableWindow(Gtk.Window): ri_num = 0 if bool(packages_dictionary['remove']): r_num = len(packages_dictionary['remove']) - message = _('Installed packages to be REMOVED:') - message += f' {r_num}' + message = f"{_('Installed packages to be REMOVED:')} {r_num}" r_pinter = self.tree_store.append(None, [message]) for line in packages_dictionary['remove']: self.tree_store.append(r_pinter, [line]) if bool(packages_dictionary['upgrade']): u_num = len(packages_dictionary['upgrade']) - message = _('Installed packages to be UPGRADED:') - message += f' {u_num}' + message = f"{_('Installed packages to be UPGRADED:')} {u_num}" u_pinter = self.tree_store.append(None, [message]) for line in packages_dictionary['upgrade']: self.tree_store.append(u_pinter, [line]) if bool(packages_dictionary['install']): i_num = len(packages_dictionary['install']) - message = _('New packages to be INSTALLED:') - message += f' {i_num}' + message = f"{_('New packages to be INSTALLED:')} {i_num}" i_pinter = self.tree_store.append(None, [message]) for line in packages_dictionary['install']: self.tree_store.append(i_pinter, [line]) if bool(packages_dictionary['reinstall']): ri_num = len(packages_dictionary['reinstall']) - message = _('Installed packages to be REINSTALL:') - message += f' {ri_num}' + message = f"{_('Installed packages to be REINSTALL:')} {ri_num}" ri_pinter = self.tree_store.append(None, [message]) for line in packages_dictionary['reinstall']: self.tree_store.append(ri_pinter, [line]) @@ -858,93 +885,116 @@ class TableWindow(Gtk.Window): return self.tree_store -class not_root(Gtk.Window): +class NotRoot(Gtk.Window): # pylint: disable=missing-class-docstring def __init__(self): - Gtk.Window.__init__(self) + """Show a dialog indicating root privileges are required.""" + super().__init__() self.set_title(_("Software Station")) self.connect("delete-event", Gtk.main_quit) self.set_size_request(200, 80) box1 = Gtk.VBox(homogeneous=False, spacing=0) - self.add(box1) + self.add(box1) # pylint: disable=no-member box1.show() label = Gtk.Label(label=_('You need to be root')) box1.pack_start(label, True, True, 0) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - box1.pack_end(hBox, False, False, 5) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + box1.pack_end(hbox, False, False, 5) ok_button = Gtk.Button() ok_button.set_label(_("OK")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-ok', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-ok', 1) ok_button.set_image(apply_img) ok_button.connect("clicked", Gtk.main_quit) - hBox.pack_end(ok_button, False, False, 5) - self.show_all() + hbox.pack_end(ok_button, False, False, 5) + self.show_all() # pylint: disable=no-member -class confirmation(Gtk.Window): +class Confirmation(Gtk.Window): # pylint: disable=missing-class-docstring + def confirm_passwd(self, widget, user): # pylint: disable=unused-argument,redefined-outer-name + """Verify the user's password against the stored SHA-512 hash.""" + print(f"Verifying password for user: {user}") + try: + # Read the stored hash from /etc/master.passwd + stored_hash = None + with open('/etc/master.passwd', 'r') as f: + 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 + break + if not stored_hash or stored_hash in ('x', '*'): + print(f"No valid hash found for user {user}") + self.hide() + self.wrong_password() + return - def confirm_passwd(self, widget, user): - pwd_hash = pwd.getpwnam(user).pw_passwd - password = self.passwd.get_text() - if crypt.crypt(password, pwd_hash) == pwd_hash: - self.hide() - TableWindow() - else: + # Verify the input password using SHA-512 + password = self.passwd.get_text() + hashed_input = crypt.crypt(password, stored_hash) + if hashed_input == stored_hash: + print("Password verified successfully") + self.hide() + TableWindow() + else: + print("Password verification failed: incorrect password") + self.hide() + self.wrong_password() + except Exception as e: + print(f"Password verification error: {e}") self.hide() self.wrong_password() def wrong_password(self): + """Show a dialog indicating an incorrect password.""" window = Gtk.Window() window.set_title(_("Software Station")) window.connect("delete-event", Gtk.main_quit) window.set_size_request(200, 80) box1 = Gtk.VBox(homogeneous=False, spacing=0) - window.add(box1) + window.add(box1) # pylint: disable=no-member box1.show() label = Gtk.Label(label=_('Wrong password')) box1.pack_start(label, True, True, 0) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - box1.pack_end(hBox, False, False, 5) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + box1.pack_end(hbox, False, False, 5) ok_button = Gtk.Button() ok_button.set_label(_("OK")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-ok', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-ok', 1) ok_button.set_image(apply_img) ok_button.connect("clicked", Gtk.main_quit) - hBox.pack_end(ok_button, False, False, 5) - window.show_all() + hbox.pack_end(ok_button, False, False, 5) + window.show_all() # pylint: disable=no-member - def __init__(self, user): - Gtk.Window.__init__(self) + def __init__(self, user): # pylint: disable=redefined-outer-name + """Initialize the password confirmation dialog.""" + super().__init__() self.set_title(_("Software Station")) self.connect("delete-event", Gtk.main_quit) self.set_size_request(200, 80) - vBox = Gtk.VBox(homogeneous=False, spacing=0) - self.add(vBox) - vBox.show() - label = _("Confirm password for") - label = Gtk.Label(label=label + f" {user}") - vBox.pack_start(label, True, True, 5) + vbox = Gtk.VBox(homogeneous=False, spacing=0) + self.add(vbox) # pylint: disable=no-member + vbox.show() + label = f"{_('Confirm password for')} {user}" + label_widget = Gtk.Label(label=label) + vbox.pack_start(label_widget, True, True, 5) self.passwd = Gtk.Entry() self.passwd.set_visibility(False) self.passwd.connect("activate", self.confirm_passwd, user) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - vBox.pack_start(hBox, False, False, 5) - hBox.pack_start(self.passwd, True, True, 20) - hBox = Gtk.HBox(homogeneous=False, spacing=0) - hBox.show() - vBox.pack_end(hBox, False, False, 5) + hbox = Gtk.HBox(homogeneous=False, spacing=0) + hbox.show() + vbox.pack_start(hbox, False, False, 5) + hbox.pack_start(self.passwd, True, True, 20) + hbox2 = Gtk.HBox(homogeneous=False, spacing=0) + hbox2.show() + vbox.pack_end(hbox2, False, False, 5) ok_button = Gtk.Button() ok_button.set_label(_("OK")) - apply_img = Gtk.Image() - apply_img.set_from_icon_name('gtk-ok', 1) + apply_img = Gtk.Image.new_from_icon_name('gtk-ok', 1) ok_button.set_image(apply_img) ok_button.connect("clicked", self.confirm_passwd, user) - hBox.pack_end(ok_button, False, False, 5) - self.show_all() + hbox2.pack_end(ok_button, False, False, 5) + self.show_all() # pylint: disable=no-member if os.geteuid() == 0: @@ -952,8 +1002,8 @@ if os.geteuid() == 0: if user is None: TableWindow() else: - confirmation(user) + Confirmation(user) else: - not_root() + NotRoot() Gtk.main() diff --git a/software_station/pkg_data_provider.py b/software_station/pkg_data_provider.py new file mode 100644 index 0000000..573f0d8 --- /dev/null +++ b/software_station/pkg_data_provider.py @@ -0,0 +1,106 @@ +# File: pkg_data_provider.py + +""" +Provides an abstraction layer to search FreeBSD/GhostBSD packages +using either a local SQLite repository or the 'pkg' binary. +""" + +import subprocess +import sqlite3 +from typing import List, Union + +from software_station.PkgRepoSqlReader import PkgRepoSqlReader, Package + + +class PkgBinaryWrapper: + """Fallback class to search packages using the 'pkg' binary tool.""" + + + def __init__(self): + pass + + + def search_packages(self, prefix: str) -> Union[List[Package], str]: + """ + Searches available packages using the `pkg` command-line tool. + + Args: + prefix (str): Package name prefix to match. + + Returns: + Union[List[Package], str]: List of Package objects or error message. + """ + try: + result = subprocess.run( + ['pkg', 'query', '-a', '%n %v %e'], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + check=True, + text=True, + timeout=10 + ) + output = result.stdout.strip().splitlines() + + installed = self.get_installed_packages() + packages = [] + + for line in output: + parts = line.split(' ', 2) + if len(parts) >= 2 and parts[0].startswith(prefix): + name, version = parts[0], parts[1] + desc = parts[2] if len(parts) == 3 else '' + packages.append(Package( + name=name, + version=version, + description=desc, + installed=(name in installed) + )) + return packages + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: + return f"pkg error: {e}" + + + def get_installed_packages(self) -> set[str]: + """ + Retrieves installed package names from the local SQLite database. + + Returns: + set[str]: A set of installed package names. + """ + try: + conn = sqlite3.connect("/var/db/pkg/local.sqlite") + cursor = conn.cursor() + cursor.execute("SELECT name FROM packages") + names = {row[0] for row in cursor.fetchall()} + conn.close() + return names + except sqlite3.DatabaseError: + return set() + + +class PkgDataProvider: + """ + Main interface to query package data from the repository or fall back + to the pkg binary if the SQLite method fails. + """ + + + def __init__(self, repo_name="GhostBSD"): + self.repo_reader = PkgRepoSqlReader(repo_name) + self.fallback_reader = PkgBinaryWrapper() + + + def search(self, prefix: str) -> Union[List[Package], str]: + """ + Searches for packages by prefix using repository first, then binary fallback. + + Args: + prefix (str): Package name prefix to match. + + Returns: + Union[List[Package], str]: A list of matched packages or an error string. + """ + result = self.repo_reader.search_packages(prefix) + if isinstance(result, str): + return self.fallback_reader.search_packages(prefix) + return result diff --git a/software_station/pkg_repo_sql_reader.py b/software_station/pkg_repo_sql_reader.py new file mode 100644 index 0000000..375f386 --- /dev/null +++ b/software_station/pkg_repo_sql_reader.py @@ -0,0 +1,106 @@ +# File: pkg_repo_sql_reader.py + +""" +Provides a class to query a FreeBSD-style SQLite package repository. +Used to search available packages and detect installed ones. +""" + +import sqlite3 +from typing import List, Union +from dataclasses import dataclass + + +@dataclass +class Package: + """ + Represents a software package entry. + """ + name: str + version: str + description: str = "" + installed: bool = False + + +class PkgRepoSqlReader: + """ + Reads package metadata from the FreeBSD/GhostBSD repository SQLite database. + """ + + def __init__(self, repo_name: str, base_path: str = "/var/db/pkg/repos"): + """ + Initializes the reader with paths to the repo and local package databases. + """ + self.db_path = f"{base_path}/{repo_name}/db" + self.local_db_path = "/var/db/pkg/local.sqlite" + + def is_available(self) -> bool: + """ + Checks whether the SQLite repository database is present and valid. + + Returns: + bool: True if the database is found and has a valid SQLite header. + """ + try: + with open(self.db_path, "rb") as f: + header = f.read(16) + return header.startswith(b"SQLite format") + except FileNotFoundError: + return False + + def get_installed_packages(self) -> set[str]: + """ + Loads the names of installed packages from the local package database. + + Returns: + set[str]: A set of installed package names. + """ + try: + conn = sqlite3.connect(self.local_db_path) + cursor = conn.cursor() + cursor.execute("SELECT name FROM packages") + installed = {row[0] for row in cursor.fetchall()} + conn.close() + return installed + except (sqlite3.DatabaseError, FileNotFoundError): + return set() + + def search_packages(self, prefix: str) -> Union[List[Package], str]: + """ + Searches for packages in the repository that match the given prefix. + + Args: + prefix (str): The starting string of the package name. + + Returns: + Union[List[Package], str]: A list of matched Package objects or an error message. + """ + if not self.is_available(): + return f"Database not found or invalid: {self.db_path}" + + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute( + """ + SELECT name, version, comment + FROM packages + WHERE name LIKE ? + ORDER BY name ASC + """, + (prefix + '%',) + ) + results = cursor.fetchall() + conn.close() + + installed = self.get_installed_packages() + return [ + Package( + name=row[0], + version=row[1], + description=row[2], + installed=(row[0] in installed) + ) + for row in results + ] + except sqlite3.DatabaseError as e: + return f"SQLite error: {e}" diff --git a/software_station/search_index.py b/software_station/search_index.py new file mode 100644 index 0000000..4c9a1aa --- /dev/null +++ b/software_station/search_index.py @@ -0,0 +1,37 @@ +# File: search_index.py + +import bisect +from dataclasses import dataclass +from typing import List, Optional + +@dataclass +class Package: + name: str + description: str = "" + version: str = "" + installed: bool = False + +class PkgSearchIndex: + def __init__(self, packages: List[Package], key: str = 'name'): + self.key = key + self.sorted_packages = sorted( + packages, + key=lambda p: getattr(p, key).casefold() if hasattr(p, key) else "" + ) + self.sorted_keys = [ + getattr(p, key).casefold() if hasattr(p, key) else "" + for p in self.sorted_packages + ] + + def search_exact(self, value: str) -> Optional[Package]: + folded_value = value.casefold() + index = bisect.bisect_left(self.sorted_keys, folded_value) + if index != len(self.sorted_keys) and self.sorted_keys[index] == folded_value: + return self.sorted_packages[index] + return None + + def search_prefix(self, prefix: str) -> List[Package]: + folded_prefix = prefix.casefold() + start = bisect.bisect_left(self.sorted_keys, folded_prefix) + end = bisect.bisect_right(self.sorted_keys, folded_prefix + '\uffff') + return self.sorted_packages[start:end]