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
-
+
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
+
+
+
+
+
+ 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)