From a3f61ca3425d8408093558ef9921747ba7c5d0dc Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Sun, 13 Oct 2024 23:43:13 +0330 Subject: [PATCH 1/3] Do not notify outdated & EOL VMs if `skip-update` set Allow users to set special `skip-update` feature for idividual qubes to suppress Qubes Update systray widget notifications on them. fixes: https://github.com/QubesOS/qubes-issues/issues/9029 --- qui/tray/updates.py | 84 +++++++++++++++++++++------------------------ qui/utils.py | 18 ++++++++-- 2 files changed, 55 insertions(+), 47 deletions(-) diff --git a/qui/tray/updates.py b/qui/tray/updates.py index 65aa268d..db9114df 100644 --- a/qui/tray/updates.py +++ b/qui/tray/updates.py @@ -127,43 +127,44 @@ def check_vms_needing_update(self): self.obsolete_vms.clear() for vm in self.qapp.domains: try: - updates_available = vm.features.get('updates-available', False) + updated: bool = qui.utils.check_update(vm) + supported: bool = qui.utils.check_support(vm) except exc.QubesDaemonCommunicationError: - updates_available = False - if updates_available and \ - (getattr(vm, 'updateable', False) or vm.klass == 'AdminVM'): + continue + if not updated: self.vms_needing_update.add(vm) - try: - supported = qui.utils.check_support(vm) - except exc.QubesDaemonCommunicationError: - supported = True if not supported: self.obsolete_vms.add(vm.name) def connect_events(self): self.dispatcher.add_handler('domain-feature-set:updates-available', - self.feature_set) + self.feature_change) self.dispatcher.add_handler('domain-feature-delete:updates-available', - self.feature_unset) + self.feature_change) + self.dispatcher.add_handler('domain-feature-set:skip-update', + self.feature_change) + self.dispatcher.add_handler('domain-feature-delete:skip-update', + self.feature_change) self.dispatcher.add_handler('domain-add', self.domain_added) self.dispatcher.add_handler('domain-delete', self.domain_removed) self.dispatcher.add_handler('domain-feature-set:os-eol', - self.feature_set) + self.feature_change) - def domain_added(self, _submitter, _event, vm, *_args, **_kwargs): + def domain_added(self, _submitter, _event, vmname, *_args, **_kwargs): try: - vm_object = self.qapp.domains[vm] + vm = self.qapp.domains[vmname] + updated: bool = qui.utils.check_update(vm) + supported: bool = qui.utils.check_support(vm) except exc.QubesException: # a disposableVM crashed on start return - try: - updates_available = vm_object.features.get( - 'updates-available', False) except exc.QubesDaemonCommunicationError: - updates_available = False - if updates_available and (getattr(vm_object, 'updateable', False) or - vm_object.klass == 'AdminVM'): - self.vms_needing_update.add(vm_object.name) + return + if not updated: + self.vms_needing_update.add(vm.name) + self.update_indicator_state() + if not supported: + self.obsolete_vms.add(vm) self.update_indicator_state() def domain_removed(self, _submitter, _event, vm, *_args, **_kwargs): @@ -174,34 +175,27 @@ def domain_removed(self, _submitter, _event, vm, *_args, **_kwargs): self.obsolete_vms.remove(vm) self.update_indicator_state() - def feature_unset(self, vm, event, feature, **_kwargs): + def feature_change(self, vm, event, feature, **_kwargs): # pylint: disable=unused-argument - if vm in self.vms_needing_update: - self.vms_needing_update.remove(vm) - self.update_indicator_state() + try: + updated: bool = qui.utils.check_update(vm) + supported: bool = qui.utils.check_support(vm) + except exc.QubesDaemonCommunicationError: + return - def feature_set(self, vm, event, feature, value, **_kwargs): - # pylint: disable=unused-argument - if feature == 'updates-available': - if value and vm not in self.vms_needing_update and\ - getattr(vm, 'updateable', False): - self.vms_needing_update.add(vm) + if not updated and vm not in self.vms_needing_update: + self.vms_needing_update.add(vm) + notification = Gio.Notification.new( + _("New updates are available for {}.").format(vm.name)) + notification.set_priority(Gio.NotificationPriority.NORMAL) + self.send_notification(None, notification) + elif updated and vm in self.vms_needing_update: + self.vms_needing_update.remove(vm) - notification = Gio.Notification.new( - _("New updates are available for {}.").format(vm.name)) - notification.set_priority(Gio.NotificationPriority.NORMAL) - self.send_notification(None, notification) - elif not value and vm in self.vms_needing_update: - self.vms_needing_update.remove(vm) - elif feature == 'os-eol': - try: - supported = qui.utils.check_support(vm) - except exc.QubesDaemonCommunicationError: - supported = True - if supported and vm.name in self.obsolete_vms: - self.obsolete_vms.remove(vm.name) - elif not supported and vm.name not in self.obsolete_vms: - self.obsolete_vms.add(vm.name) + if not supported and vm not in self.obsolete_vms: + self.obsolete_vms.add(vm.name) + elif supported and vm in self.obsolete_vms: + self.obsolete_vms.remove(vm.name) self.update_indicator_state() diff --git a/qui/utils.py b/qui/utils.py index 0a60d950..5c56f5fe 100644 --- a/qui/utils.py +++ b/qui/utils.py @@ -80,11 +80,25 @@ def run_asyncio_and_show_errors(loop, tasks, name, restart=True): exit_code = 1 return exit_code +def check_update(vm) -> bool: + """Return true if the given template/standalone vm is updated or not + updateable or skipped. default returns true""" + if not vm.features.get('updates-available', False): + return True + if not getattr(vm, 'updateable', False): + return True + if bool(vm.features.get('skip-update', False)): + return True + return False -def check_support(vm): +def check_support(vm) -> bool: """Return true if the given template/standalone vm is still supported, by default returns true""" - # first, check if qube itself has known eol + # first, we skip VMs with `skip-update` feature set to true + if bool(vm.features.get('skip-update', False)): + return True + + # next, check if qube itself has known eol eol_string: str = vm.features.get('os-eol', '') if not eol_string: From 986fc5dba2b349d5d624a3415e1598ff4660bfc9 Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Mon, 14 Oct 2024 02:20:52 +0330 Subject: [PATCH 2/3] Add filtering options to Qubes Update GUI fixes: https://github.com/QubesOS/qubes-issues/issues/9029 --- qui/tray/updates.py | 4 +- qui/updater/intro_page.py | 18 +++++++++ qui/updater/tests/conftest.py | 6 +++ qui/updater/updater_settings.py | 42 ++++++++++++++++++++ qui/updater_settings.glade | 68 ++++++++++++++++++++++++++++++++- 5 files changed, 135 insertions(+), 3 deletions(-) diff --git a/qui/tray/updates.py b/qui/tray/updates.py index db9114df..f9f87cf3 100644 --- a/qui/tray/updates.py +++ b/qui/tray/updates.py @@ -155,11 +155,11 @@ def domain_added(self, _submitter, _event, vmname, *_args, **_kwargs): vm = self.qapp.domains[vmname] updated: bool = qui.utils.check_update(vm) supported: bool = qui.utils.check_support(vm) + except exc.QubesDaemonCommunicationError: + return except exc.QubesException: # a disposableVM crashed on start return - except exc.QubesDaemonCommunicationError: - return if not updated: self.vms_needing_update.add(vm.name) self.update_indicator_state() diff --git a/qui/updater/intro_page.py b/qui/updater/intro_page.py index 47cd311d..3e6dcb02 100644 --- a/qui/updater/intro_page.py +++ b/qui/updater/intro_page.py @@ -94,12 +94,30 @@ def populate_vm_list(self, qapp, settings): for vm in qapp.domains: if vm.klass == 'AdminVM': try: + if settings.hide_skipped and bool(vm.features.get( \ + 'skip-update', False)): + continue state = bool(vm.features.get('updates-available', False)) except exc.QubesDaemonCommunicationError: state = False self.list_store.append_vm(vm, state) + to_update=set() + if settings.hide_updated: + cmd = ['qubes-vm-update', '--quiet', '--dry-run', + '--update-if-stale', str(settings.update_if_stale)] + to_update = self._get_stale_qubes(cmd) + for vm in qapp.domains: + try: + if settings.hide_skipped and bool(vm.features.get( \ + 'skip-update', False)): + continue + if settings.hide_updated and not vm.name in to_update: + # TODO: Make re-filtering possible without App restart + continue + except exc.QubesDaemonCommunicationError: + continue if getattr(vm, 'updateable', False) and vm.klass != 'AdminVM': self.list_store.append_vm(vm) diff --git a/qui/updater/tests/conftest.py b/qui/updater/tests/conftest.py index 2025881f..16d9cc0f 100644 --- a/qui/updater/tests/conftest.py +++ b/qui/updater/tests/conftest.py @@ -61,6 +61,9 @@ def test_qapp_impl(): add_dom0_feature(qapp, 'gui-default-allow-utf8-titles', '') add_dom0_feature(qapp, 'gui-default-trayicon-mode', '') add_dom0_feature(qapp, 'qubes-vm-update-update-if-stale', None) + add_dom0_feature(qapp, 'skip-update', None) + add_dom0_feature(qapp, 'qubes-vm-update-hide-skipped', None) + add_dom0_feature(qapp, 'qubes-vm-update-hide-updated', None) # setup labels qapp.expected_calls[('dom0', 'admin.label.List', None, None)] = \ @@ -140,6 +143,7 @@ def test_qapp_impl(): add_feature_to_all(qapp, 'servicevm', ['sys-usb', 'sys-firewall', 'sys-net']) add_feature_to_all(qapp, 'os-eol', []) + add_feature_to_all(qapp, 'skip-update', []) return qapp @@ -254,6 +258,8 @@ def __init__(self): self.restart_service_vms = True self.restart_other_vms = True self.max_concurrency = None + self.hide_skipped = True + self.hide_updated = False return MockSettings() diff --git a/qui/updater/updater_settings.py b/qui/updater/updater_settings.py index f80a65f7..612b8bbd 100644 --- a/qui/updater/updater_settings.py +++ b/qui/updater/updater_settings.py @@ -53,6 +53,8 @@ class Settings: MAX_UPDATE_IF_STALE = 99 DEFAULT_RESTART_SERVICEVMS = True DEFAULT_RESTART_OTHER_VMS = False + DEFAULT_HIDE_SKIPPED = True + DEFAULT_HIDE_UPDATED = False def __init__( self, @@ -78,6 +80,7 @@ def __init__( self.settings_window: Gtk.Window = self.builder.get_object( "main_window") + self.settings_window.set_transient_for(main_window) self.settings_window.connect("delete-event", self.close_without_saving) @@ -108,6 +111,12 @@ def __init__( self.restart_other_checkbox.connect( "toggled", self._show_restart_exceptions) + self.hide_skipped_checkbox: Gtk.CheckButton = \ + self.builder.get_object("hide_skipped") + + self.hide_updated_checkbox: Gtk.CheckButton = \ + self.builder.get_object("hide_updated") + self.available_vms = [ vm for vm in self.qapp.domains if vm.klass == 'DispVM' and not vm.auto_cleanup @@ -138,6 +147,8 @@ def __init__( self._init_restart_other_vms: Optional[bool] = None self._init_limit_concurrency: Optional[bool] = None self._init_max_concurrency: Optional[int] = None + self._init_hide_skipped: Optional[bool] = None + self._init_hide_updated: Optional[bool] = None @property def update_if_stale(self) -> int: @@ -176,6 +187,18 @@ def restart_other_vms(self) -> bool: self.vm, "qubes-vm-update-restart-other", Settings.DEFAULT_RESTART_OTHER_VMS) + @property + def hide_skipped(self) -> bool: + return get_boolean_feature( + self.vm, "qubes-vm-update-hide-skipped", + Settings.DEFAULT_HIDE_SKIPPED) + + @property + def hide_updated(self) -> bool: + return get_boolean_feature( + self.vm, "qubes-vm-update-hide-updated", + Settings.DEFAULT_HIDE_UPDATED) + @property def max_concurrency(self) -> Optional[int]: """Return the current (set by this window or manually) option value.""" @@ -210,6 +233,11 @@ def load_settings(self): if self._init_limit_concurrency: self.max_concurrency_button.set_value(self._init_max_concurrency) + self._init_hide_skipped = self.hide_skipped + self._init_hide_updated = self.hide_updated + self.hide_skipped_checkbox.set_active(self._init_hide_skipped) + self.hide_updated_checkbox.set_active(self._init_hide_updated) + def _show_restart_exceptions(self, _emitter=None): if self.restart_other_checkbox.get_active(): self.restart_exceptions_page.show_all() @@ -262,6 +290,20 @@ def save_and_close(self, _emitter): default=Settings.DEFAULT_RESTART_OTHER_VMS ) + self._save_option( + name="hide-skipped", + value=self.hide_skipped_checkbox.get_active(), + init=self._init_hide_skipped, + default=Settings.DEFAULT_HIDE_SKIPPED + ) + + self._save_option( + name="hide-updated", + value=self.hide_updated_checkbox.get_active(), + init=self._init_hide_updated, + default=Settings.DEFAULT_HIDE_UPDATED + ) + limit_concurrency = self.limit_concurrency_checkbox.get_active() if self._init_limit_concurrency or limit_concurrency: if limit_concurrency: diff --git a/qui/updater_settings.glade b/qui/updater_settings.glade index 1a181560..0a6a2648 100644 --- a/qui/updater_settings.glade +++ b/qui/updater_settings.glade @@ -9,7 +9,7 @@ Qubes OS Updater Settings False 458 - 571 + 640 @@ -495,6 +495,72 @@ 10 + + + Filtering Options + True + False + start + start + 18 + True + True + + + + False + True + 11 + + + + + Hide qubes with 'skip-update' feature from selection page. + Requires application restart to take effect. + True + True + False + start + start + 5 + True + True + True + + + + False + True + 12 + + + + + Hide already updated qubes from selection page. + Requires application restart to take effect. + True + True + False + start + start + 5 + True + False + True + + + + False + True + 13 + + From 276c164d8afc4a19c111f215f7d91893b5e08c0d Mon Sep 17 00:00:00 2001 From: Ali Mirjamali Date: Thu, 24 Oct 2024 19:53:27 +0330 Subject: [PATCH 3/3] Move catching exception inside helper functions For `check_support` and `check_update`, it is more convenient to catch exceptions within the helper functions once. --- qui/tray/updates.py | 18 ++++++------------ qui/utils.py | 44 ++++++++++++++++++++++++++------------------ 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/qui/tray/updates.py b/qui/tray/updates.py index f9f87cf3..85af9b3b 100644 --- a/qui/tray/updates.py +++ b/qui/tray/updates.py @@ -126,11 +126,8 @@ def check_vms_needing_update(self): self.vms_needing_update.clear() self.obsolete_vms.clear() for vm in self.qapp.domains: - try: - updated: bool = qui.utils.check_update(vm) - supported: bool = qui.utils.check_support(vm) - except exc.QubesDaemonCommunicationError: - continue + updated: bool = qui.utils.check_update(vm) + supported: bool = qui.utils.check_support(vm) if not updated: self.vms_needing_update.add(vm) if not supported: @@ -153,13 +150,13 @@ def connect_events(self): def domain_added(self, _submitter, _event, vmname, *_args, **_kwargs): try: vm = self.qapp.domains[vmname] - updated: bool = qui.utils.check_update(vm) - supported: bool = qui.utils.check_support(vm) except exc.QubesDaemonCommunicationError: return except exc.QubesException: # a disposableVM crashed on start return + updated: bool = qui.utils.check_update(vm) + supported: bool = qui.utils.check_support(vm) if not updated: self.vms_needing_update.add(vm.name) self.update_indicator_state() @@ -177,11 +174,8 @@ def domain_removed(self, _submitter, _event, vm, *_args, **_kwargs): def feature_change(self, vm, event, feature, **_kwargs): # pylint: disable=unused-argument - try: - updated: bool = qui.utils.check_update(vm) - supported: bool = qui.utils.check_support(vm) - except exc.QubesDaemonCommunicationError: - return + updated: bool = qui.utils.check_update(vm) + supported: bool = qui.utils.check_support(vm) if not updated and vm not in self.vms_needing_update: self.vms_needing_update.add(vm) diff --git a/qui/utils.py b/qui/utils.py index 5c56f5fe..ffbdf26b 100644 --- a/qui/utils.py +++ b/qui/utils.py @@ -25,6 +25,8 @@ import traceback from html import escape +from qubesadmin import exc + import gettext import importlib.resources @@ -83,32 +85,38 @@ def run_asyncio_and_show_errors(loop, tasks, name, restart=True): def check_update(vm) -> bool: """Return true if the given template/standalone vm is updated or not updateable or skipped. default returns true""" - if not vm.features.get('updates-available', False): - return True - if not getattr(vm, 'updateable', False): - return True - if bool(vm.features.get('skip-update', False)): + try: + if not vm.features.get('updates-available', False): + return True + if not getattr(vm, 'updateable', False): + return True + if bool(vm.features.get('skip-update', False)): + return True + except exc.QubesException: return True return False def check_support(vm) -> bool: """Return true if the given template/standalone vm is still supported, by default returns true""" - # first, we skip VMs with `skip-update` feature set to true - if bool(vm.features.get('skip-update', False)): - return True + try: + # first, we skip VMs with `skip-update` feature set to true + if bool(vm.features.get('skip-update', False)): + return True - # next, check if qube itself has known eol - eol_string: str = vm.features.get('os-eol', '') + # next, check if qube itself has known eol + eol_string: str = vm.features.get('os-eol', '') - if not eol_string: - template_name: str = vm.features.get('template-name', '') - if not template_name: - return True - for suffix in SUFFIXES: - template_name = template_name.removesuffix(suffix) - eol_string = EOL_DATES.get(template_name, None) if not eol_string: - return True + template_name: str = vm.features.get('template-name', '') + if not template_name: + return True + for suffix in SUFFIXES: + template_name = template_name.removesuffix(suffix) + eol_string = EOL_DATES.get(template_name, None) + if not eol_string: + return True + except exc.QubesException: + return True eol = datetime.strptime(eol_string, '%Y-%m-%d') return eol > datetime.now()