diff --git a/qui/tray/updates.py b/qui/tray/updates.py index 65aa268..f9f87cf 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.QubesDaemonCommunicationError: + return 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) + 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/updater/intro_page.py b/qui/updater/intro_page.py index 47cd311..3e6dcb0 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 2025881..16d9cc0 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.py b/qui/updater/updater.py index 47443f3..3075ff1 100644 --- a/qui/updater/updater.py +++ b/qui/updater/updater.py @@ -152,6 +152,7 @@ def perform_setup(self, *_args, **_kwargs): self.log, refresh_callback=self.intro_page.refresh_update_list, overrides=overrides, + advanced=self.cliargs.show_advanced_settings ) headers = [(3, "intro_name"), (3, "progress_name"), (3, "summary_name"), @@ -418,6 +419,12 @@ def parse_args(args, app): 'Interaction will be required in the event ' 'of an update error.') + parser.add_argument('--show-advanced-settings', action='store_true', + help='Show setting options for filtering-out already ' + 'updated or skipped VMs from VM selection list. ' + 'These options require application restart to ' + 'take effect.') + args = parser.parse_args(args) args.non_default_select = { diff --git a/qui/updater/updater_settings.py b/qui/updater/updater_settings.py index f80a65f..e8ebcac 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, @@ -61,6 +63,7 @@ def __init__( log, refresh_callback: Callable, overrides: OverriddenSettings = OverriddenSettings(), + advanced: Optional[bool] = False ): self.qapp = qapp self.log = log @@ -78,6 +81,14 @@ def __init__( self.settings_window: Gtk.Window = self.builder.get_object( "main_window") + + # Filtering options for advanced users + self.builder.get_object("filtering_options").set_visible(advanced) + self.builder.get_object("hide_skipped").set_visible(advanced) + self.builder.get_object("hide_updated").set_visible(advanced) + self.settings_window.set_default_size( + self.settings_window.get_size().width, 640 if advanced else 550) + self.settings_window.set_transient_for(main_window) self.settings_window.connect("delete-event", self.close_without_saving) @@ -108,6 +119,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 +155,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 +195,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 +241,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() @@ -226,7 +262,7 @@ def _limit_concurrency_toggled(self, _emitter=None): def show(self): """Show a hidden window.""" self.load_settings() - self.settings_window.show_all() + self.settings_window.show() self._show_restart_exceptions() self._limit_concurrency_toggled() @@ -262,6 +298,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 1a18156..0a6a264 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 + + diff --git a/qui/utils.py b/qui/utils.py index 0a60d95..5c56f5f 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: