diff --git a/qubes_config/global_config.glade b/qubes_config/global_config.glade index d0311c7a..36db4729 100644 --- a/qubes_config/global_config.glade +++ b/qubes_config/global_config.glade @@ -976,7 +976,7 @@ False natural - + True False @@ -1069,7 +1069,7 @@ 0 - 9 + 10 2 @@ -1085,7 +1085,7 @@ 0 - 12 + 13 @@ -1101,7 +1101,7 @@ 1 - 12 + 13 @@ -1114,7 +1114,7 @@ 1 - 13 + 14 @@ -1127,7 +1127,7 @@ 1 - 14 + 15 @@ -1238,7 +1238,7 @@ 0 - 16 + 17 2 @@ -1255,7 +1255,7 @@ 0 - 17 + 18 2 @@ -1271,7 +1271,7 @@ 0 - 21 + 22 2 @@ -1283,7 +1283,7 @@ 1 - 23 + 24 @@ -1295,7 +1295,7 @@ 0 - 24 + 25 2 @@ -1312,7 +1312,7 @@ 0 - 22 + 23 2 @@ -1462,7 +1462,7 @@ 0 - 10 + 11 2 @@ -1509,7 +1509,7 @@ 0 - 11 + 12 2 @@ -1554,7 +1554,7 @@ 0 - 13 + 14 @@ -1578,7 +1578,7 @@ 0 - 8 + 9 @@ -1590,7 +1590,7 @@ 0 - 15 + 16 @@ -1602,7 +1602,7 @@ 0 - 20 + 21 @@ -1633,7 +1633,7 @@ 0 - 14 + 15 @@ -1648,7 +1648,7 @@ 0 - 23 + 24 @@ -1663,7 +1663,7 @@ 0 - 18 + 19 @@ -1678,7 +1678,7 @@ 0 - 19 + 20 @@ -1718,7 +1718,7 @@ 1 - 18 + 19 @@ -1758,7 +1758,131 @@ 1 - 19 + 20 + + + + + True + False + vertical + + + True + False + Preload disposable qubes: + True + 0 + + + + False + True + 0 + + + + + True + False + vertical + + + True + False + Maximum number of disposable qubes (created from the default disposable template) to queue in the background. They are available immediately, but each one reserves memory. This setting takes precedence over the default disposable template's preload setting. + True + 0 + + + + False + True + 1 + + + + + False + True + 1 + + + + + 0 + 8 + + + + + True + False + start + + + True + True + False + 5 + 10 + True + + + True + False + vertical + + + True + False + True + 0 + + + + False + True + 0 + + + + + + + False + True + 2 + + + + + True + True + start + center + 5 + 5 + 5 + True + 4 + + + False + True + 3 + + + + + 1 + 8 diff --git a/qubes_config/global_config/basics_handler.py b/qubes_config/global_config/basics_handler.py index 50026d03..bc2cfe92 100644 --- a/qubes_config/global_config/basics_handler.py +++ b/qubes_config/global_config/basics_handler.py @@ -162,7 +162,7 @@ def update_current_value(self): new_value = self.model.get_selected() setattr(self.trait_holder, self.trait_name, new_value) - def get_model(self) -> TraitSelector: + def get_model(self) -> VMListModeler: return self.model @@ -208,6 +208,127 @@ def get_model(self) -> TraitSelector: return self.model +class PreloadDispvmHandler(AbstractTraitHolder): + """Handler for preloaded disposables. Requires SpinButton widgets: + 'basics_preload_dispvm'""" + + def __init__( + self, + qapp: qubesadmin.Qubes, + gtk_builder: Gtk.Builder, + defdispvm_model: VMListModeler, + ): + self.qapp = qapp + self.defdispvm_model = defdispvm_model + self.preload_dispvm_spin: Gtk.SpinButton = gtk_builder.get_object( + "basics_preload_dispvm" + ) + self.preload_dispvm_check: Gtk.CheckButton = gtk_builder.get_object( + "basics_preload_dispvm_check" + ) + + self.defdispvm_model.connect_change_callback(self.on_defdispvm_changed) + self.preload_dispvm_check.connect("toggled", self.on_check_changed) + self.preload_dispvm_spin.props.numeric = True + self.preload_dispvm_spin_adjustment = Gtk.Adjustment() + self.preload_dispvm_spin_adjustment.configure(0, 0, 9999, 1, 5, 0) + self.preload_dispvm_spin.configure(self.preload_dispvm_spin_adjustment, 0.1, 0) + + self.on_defdispvm_changed() + self.on_check_changed() + self.initial_preload_dispvm_spin_sensitive = ( + self.preload_dispvm_spin.is_sensitive() + ) + + def on_defdispvm_changed(self): + defdispvm = self.defdispvm_model.get_selected() + if defdispvm: + self.preload_dispvm_check.set_sensitive(True) + self.preload_dispvm_check.set_active(self.get_feat_value() is not None) + else: + self.preload_dispvm_check.set_sensitive(False) + self.preload_dispvm_check.set_active(False) + + def on_check_changed(self, *args): # pylint: disable=unused-argument + defdispvm = self.defdispvm_model.get_selected() + preloadcheck = self.preload_dispvm_check.get_active() + if defdispvm and preloadcheck: + if self.get_feat_value() is None: + value = 1 + else: + value = self.get_current_value() + self.preload_dispvm_spin.set_value(value) + self.preload_dispvm_spin.set_sensitive(True) + else: + self.preload_dispvm_spin.set_value(0) + self.preload_dispvm_spin.set_sensitive(False) + + @staticmethod + def get_readable_description() -> str: # pylint: disable=arguments-differ + """Get human-readable description of the widget""" + # the pylint: disable above is because pylint does not understand + # static methods + return _("Number of preloaded disposables from default dispvm") + + def get_feat_value(self): + """Get current system value as is""" + return get_feature(self.qapp.domains["dom0"], "preload-dispvm-max") + + def get_current_value(self): + """Get current system value of the handled feature""" + return int(self.get_feat_value() or 0) + + def is_changed(self) -> bool: + """Has the user selected something different from the initial value?""" + if ( + self.initial_preload_dispvm_spin_sensitive + != self.preload_dispvm_spin.is_sensitive() + ): + return True + if self.preload_dispvm_spin.get_value_as_int() != self.get_current_value(): + return True + return False + + def get_unsaved(self): + """Get human-readable description of unsaved changes, or + empty string if none were found.""" + if self.is_changed(): + return self.get_readable_description() + return "" + + def save(self): + """Save changes: update system value and mark it as new initial value""" + if not self.is_changed(): + return + if self.preload_dispvm_spin.is_sensitive(): + value = str(self.preload_dispvm_spin.get_value_as_int()) + else: + value = None + apply_feature_change( + self.qapp.domains["dom0"], + "preload-dispvm-max", + value, + ) + if value is None: + self.initial_preload_dispvm_spin_sensitive = False + else: + self.initial_preload_dispvm_spin_sensitive = True + + def reset(self): + """Reset selection to the initial value.""" + if not self.preload_dispvm_spin.is_sensitive(): + return + self.preload_dispvm_spin.set_value(self.get_current_value()) + + def update_current_value(self): + """This should never be called.""" + raise NotImplementedError + + def get_model(self) -> TraitSelector: + """This should never be called.""" + raise NotImplementedError + + class QMemManHelper: """Helper class to handle the ugliness of managing qmemman config.""" @@ -509,6 +630,14 @@ def __init__(self, gtk_builder: Gtk.Builder, qapp: qubesadmin.Qubes): additional_options=NONE_CATEGORY, ) ) + defdispvm_model: VMListModeler = self.handlers[-1].get_model() # type: ignore + self.handlers.append( + PreloadDispvmHandler( + qapp=self.qapp, + gtk_builder=gtk_builder, + defdispvm_model=defdispvm_model, + ) + ) self.handlers.append( FeatureHandler( trait_holder=self.vm, diff --git a/qubes_config/tests/test_basics_handler.py b/qubes_config/tests/test_basics_handler.py index fc81773a..1e2f45e8 100644 --- a/qubes_config/tests/test_basics_handler.py +++ b/qubes_config/tests/test_basics_handler.py @@ -338,27 +338,161 @@ def test_kernels(test_qapp): assert handler.get_unsaved() == "" +# when dealing with features, we need to be always using helper methods +@patch("qubes_config.global_config.basics_handler.get_feature") +@patch("qubes_config.global_config.basics_handler.apply_feature_change") +def test_preload_handler( + mock_apply, mock_get, real_builder, test_qapp +): # pylint: disable=unused-argument + description = "Number of preloaded disposables from default dispvm" + mock_get.return_value = None + basics_handler = BasicSettingsHandler(real_builder, test_qapp) + # Actual calls includes extraneous calls for this test, focus on the + # calls made to each save. + test_qapp.actual_calls = [] + assert basics_handler.get_unsaved() == "" + + defdispvm_combo: Gtk.ComboBox = real_builder.get_object("basics_defdispvm_combo") + preload_dispvm_spin: Gtk.SpinButton = real_builder.get_object( + "basics_preload_dispvm" + ) + preload_dispvm_spin_check: Gtk.CheckButton = real_builder.get_object( + "basics_preload_dispvm_check" + ) + initial_default_dispvm = defdispvm_combo.get_active_id() + initial_preload_dispvm = preload_dispvm_spin.get_value_as_int() + + # Start available but unchecked. + assert preload_dispvm_spin_check.is_sensitive() + assert not preload_dispvm_spin.is_sensitive() + + # Check to allow spin. + preload_dispvm_spin_check.set_active(True) + assert preload_dispvm_spin.is_sensitive() + + # Assert that with no default_dispvm, even if changing preload spin button, + # doesn't save the preload feature. + preload_dispvm_spin.set_value(initial_preload_dispvm + 1) + assert basics_handler.get_unsaved() == description + defdispvm_combo.set_active_id("(none)") + assert not preload_dispvm_spin_check.is_sensitive() + assert not preload_dispvm_spin.is_sensitive() + assert preload_dispvm_spin.get_value_as_int() == initial_preload_dispvm + expected_calls = [("dom0", "admin.property.Set", "default_dispvm", b"")] + for call in expected_calls: + test_qapp.expected_calls[call] = b"0\x00" + assert call not in test_qapp.actual_calls + basics_handler.save() + mock_apply.assert_not_called() + for call in expected_calls: + assert call in test_qapp.actual_calls + test_qapp.actual_calls = [] + + # Assert that reset ignores insensitive. + initial_preload_dispvm = preload_dispvm_spin.get_value_as_int() + assert not preload_dispvm_spin.is_sensitive() + mock_get_count = mock_get.call_count + basics_handler.reset() + assert mock_get_count == mock_get.call_count + + # Assert when changing from no default_dispvm, requires checking the box. + defdispvm_combo.set_active_id(initial_default_dispvm) + assert not preload_dispvm_spin.is_sensitive() + + # Assert save. + preload_dispvm_spin_check.set_active(True) + new_preload_value = initial_preload_dispvm + 2 + preload_dispvm_spin.set_value(new_preload_value) + expected_calls = [ + ( + "dom0", + "admin.property.Set", + "default_dispvm", + initial_default_dispvm.encode(), + ), + ] + for call in expected_calls: + test_qapp.expected_calls[call] = b"0\x00" + assert call not in test_qapp.actual_calls + basics_handler.save() + mock_apply.assert_called_once_with( + test_qapp.domains["dom0"], "preload-dispvm-max", str(new_preload_value) + ) + for call in expected_calls: + assert call in test_qapp.actual_calls + test_qapp.actual_calls = [] + + # Assert that saving '0' set the value '0' instead of feature deletion. + mock_get.return_value = new_preload_value + preload_dispvm_spin.set_value(0) + basics_handler.save() + assert mock_apply.call_args[0] == ( + test_qapp.domains["dom0"], + "preload-dispvm-max", + str(0), + ) + + # Assert that deselecting the check box deletes the feature. + mock_get.return_value = 0 + preload_dispvm_spin.set_value(1) + preload_dispvm_spin_check.set_active(False) + basics_handler.save() + assert mock_apply.call_args[0] == ( + test_qapp.domains["dom0"], + "preload-dispvm-max", + None, + ) + assert not preload_dispvm_spin.is_sensitive() + + # Assert that reset sets the current value. + preload_dispvm_spin_check.set_active(True) + assert preload_dispvm_spin.is_sensitive() + mock_get_count = mock_get.call_count + mock_get.return_value = 1 + basics_handler.reset() + assert mock_get.call_count == mock_get_count + 1 + assert preload_dispvm_spin.get_value_as_int() == 1 + + # Assert that value is 1 when feature is None, get current value otherwise. + mock_get.return_value = None + preload_dispvm_spin_check.set_active(False) + preload_dispvm_spin_check.set_active(True) + assert preload_dispvm_spin.get_value_as_int() == 1 + + mock_get.return_value = 0 + preload_dispvm_spin_check.set_active(False) + preload_dispvm_spin_check.set_active(True) + assert preload_dispvm_spin.get_value_as_int() == 0 + + mock_get.return_value = "" + preload_dispvm_spin_check.set_active(False) + preload_dispvm_spin_check.set_active(True) + assert preload_dispvm_spin.get_value_as_int() == 0 + + mock_get.return_value = 1 + preload_dispvm_spin_check.set_active(False) + preload_dispvm_spin_check.set_active(True) + assert preload_dispvm_spin.get_value_as_int() == 1 + + def test_basics_handler(real_builder, test_qapp): basics_handler = BasicSettingsHandler(real_builder, test_qapp) assert basics_handler.get_unsaved() == "" - # all handlers are tested above, so now just use one as example - # change clockvm + # All handlers are tested above, so now just use one as example. clockvm_combo: Gtk.ComboBox = real_builder.get_object("basics_clockvm_combo") initial_clockvm = clockvm_combo.get_active_id() assert initial_clockvm != "test-blue" - clockvm_combo.set_active_id("test-blue") + clockvm_combo.set_active_id("test-blue") assert basics_handler.get_unsaved() == "Clock qube" basics_handler.reset() - assert clockvm_combo.get_active_id() == initial_clockvm assert basics_handler.get_unsaved() == "" clockvm_combo.set_active_id("test-blue") - test_qapp.expected_calls[ ("dom0", "admin.property.Set", "clockvm", b"test-blue") ] = b"0\x00" diff --git a/qui/tray/domains.py b/qui/tray/domains.py index e9d2b696..167be237 100644 --- a/qui/tray/domains.py +++ b/qui/tray/domains.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # pylint: disable=wrong-import-position,import-error,superfluous-parens -""" A menu listing domains """ +"""A menu listing domains""" # Must be imported before creating threads from .gtk3_xwayland_menu_dismisser import ( @@ -765,6 +765,9 @@ def register_events(self): self.dispatcher.add_handler("domain-pre-shutdown", self.emit_notification) self.dispatcher.add_handler("domain-shutdown", self.emit_notification) self.dispatcher.add_handler("domain-shutdown-failed", self.emit_notification) + self.dispatcher.add_handler( + "domain-preload-dispvm-used", self.emit_notification + ) self.dispatcher.add_handler("domain-start", self.check_pause_notify) self.dispatcher.add_handler("domain-paused", self.check_pause_notify) @@ -816,7 +819,11 @@ def show_menu(self, _unused, event): self.tray_menu.popup_at_pointer(event) # None means current event def emit_notification(self, vm, event, **kwargs): - notification = Gio.Notification.new(_("Qube Status: {}").format(vm.name)) + if event == "domain-preload-dispvm-used": + vm_name = kwargs["dispvm"] + else: + vm_name = vm.name + notification = Gio.Notification.new(_("Qube Status: {}").format(vm_name)) notification.set_priority(Gio.NotificationPriority.NORMAL) if event == "domain-start-failed": @@ -829,6 +836,12 @@ def emit_notification(self, vm, event, **kwargs): notification.set_body(_("Qube {} is starting.").format(vm.name)) elif event == "domain-start": notification.set_body(_("Qube {} has started.").format(vm.name)) + elif event == "domain-preload-dispvm-used": + notification.set_body( + _("Qube {} was preloaded and is now being used.").format( + kwargs["dispvm"] + ) + ) elif event == "domain-pre-shutdown": notification.set_body( _("Qube {} is attempting to shut down.").format(vm.name) @@ -1106,6 +1119,9 @@ def _disconnect_signals(self, _event): self.dispatcher.remove_handler("domain-pre-shutdown", self.emit_notification) self.dispatcher.remove_handler("domain-shutdown", self.emit_notification) self.dispatcher.remove_handler("domain-shutdown-failed", self.emit_notification) + self.dispatcher.remove_handler( + "domain-preload-dispvm-used", self.emit_notification + ) self.dispatcher.remove_handler("domain-start", self.check_pause_notify) self.dispatcher.remove_handler("domain-paused", self.check_pause_notify)