From b0e92d4a05cadbfa097f6641cfc1d8e2b00036fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Tue, 25 Mar 2025 12:28:09 +0100 Subject: [PATCH 1/8] Changed VM flowbox for improved UX Less clicks, more clarity, better accessibility. Also minor changes to icon loading. --- icons/scalable/qubes-icon-add.svg | 1 + icons/scalable/qubes-icon-edit.svg | 5 + icons/scalable/qubes-icon-remove.svg | 4 + qubes_config/global_config.glade | 653 +++++++------------ qubes_config/global_config/global_config.py | 7 +- qubes_config/global_config/vm_flowbox.py | 24 +- qubes_config/tests/test.glade | 28 +- qubes_config/tests/test_usb_devices.py | 5 +- qubes_config/tests/test_vm_flowbox.py | 32 +- qui/updater_settings.glade | 154 ++--- rpm_spec/qubes-desktop-linux-manager.spec.in | 3 + 11 files changed, 306 insertions(+), 610 deletions(-) create mode 100755 icons/scalable/qubes-icon-add.svg create mode 100755 icons/scalable/qubes-icon-edit.svg create mode 100755 icons/scalable/qubes-icon-remove.svg diff --git a/icons/scalable/qubes-icon-add.svg b/icons/scalable/qubes-icon-add.svg new file mode 100755 index 00000000..169467ef --- /dev/null +++ b/icons/scalable/qubes-icon-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/scalable/qubes-icon-edit.svg b/icons/scalable/qubes-icon-edit.svg new file mode 100755 index 00000000..95b1bcbd --- /dev/null +++ b/icons/scalable/qubes-icon-edit.svg @@ -0,0 +1,5 @@ + + + + diff --git a/icons/scalable/qubes-icon-remove.svg b/icons/scalable/qubes-icon-remove.svg new file mode 100755 index 00000000..51e5570e --- /dev/null +++ b/icons/scalable/qubes-icon-remove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/qubes_config/global_config.glade b/qubes_config/global_config.glade index 10069344..7d1a5224 100644 --- a/qubes_config/global_config.glade +++ b/qubes_config/global_config.glade @@ -1425,7 +1425,6 @@ True False start - 30 <b>List of qubes that can use the U2F Proxy:</b> True @@ -1439,6 +1438,7 @@ True False + 10 40 + + + False + True + 1 + + + + + False True - 3 - 1 + 2 @@ -1494,7 +1518,7 @@ True - True + False @@ -1504,47 +1528,6 @@ 2 - - - CANCEL - True - True - True - none - - - - False - True - 3 - - - - - ADD - True - True - True - none - - - - False - True - 4 - - - False @@ -1553,49 +1536,16 @@ - + True - True - True - start - none - - - True - False - 10 - - - True - False - qubes-add - - - False - True - 0 - - - - - True - False - Add Qube - - - - False - True - 1 - - - - + False + 10 + If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). + True + True + 0 @@ -1769,6 +1719,7 @@ True False + 10 40 + + + False + True + 1 + + + + @@ -1853,28 +1809,6 @@ 3 - - - ADD - True - True - True - none - - - - False - True - 4 - - - False @@ -1883,49 +1817,16 @@ - + True - True - True - start - none - - - True - False - 10 - - - True - False - qubes-add - - - False - True - 0 - - - - - True - False - Add Qube - - - - False - True - 1 - - - - + False + 10 + If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). + True + True + 0 @@ -2053,6 +1954,7 @@ True False + 10 40 + + + False + True + 1 + + + + @@ -2137,28 +2044,6 @@ 3 - - - ADD - True - True - True - none - - - - False - True - 4 - - - False @@ -2167,49 +2052,15 @@ - + True - True - True - start - none - - - True - False - 10 - - - True - False - qubes-add - - - False - True - 0 - - - - - True - False - Add Qube - - - - False - True - 1 - - - - + False + 10 + If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). + True + 0 @@ -2817,9 +2668,6 @@ 15 - - - True @@ -2868,7 +2716,7 @@ False True - 17 + 16 @@ -2894,24 +2742,10 @@ + True False - 25 + 20 10 - - - True - False - Add exception: - - - - - - False - True - 0 - - True @@ -2933,15 +2767,50 @@ - - CANCEL + True True True + start + 35 none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + 5 + Add exception + + + + False + True + 1 + + + + @@ -2951,28 +2820,6 @@ 2 - - - ADD - True - True - True - none - - - - False - True - 3 - - - False @@ -2980,60 +2827,6 @@ 1 - - - True - True - True - start - 35 - none - - - True - False - 10 - - - True - False - qubes-add - - - False - True - 0 - - - - - True - False - 5 - Add Exception - - - - False - True - 1 - - - - - - - - False - True - 2 - - False @@ -3404,6 +3197,19 @@ 27 + + + anchor + True + True + True + + + False + True + 29 + + True @@ -3420,7 +3226,7 @@ True False - qubes-add + qubes-icon-add False @@ -3456,19 +3262,6 @@ 29 - - - anchor - True - True - True - - - False - True - 30 - - True @@ -3483,7 +3276,7 @@ False True - 31 + 30 @@ -3500,7 +3293,7 @@ False True - 32 + 31 @@ -3690,7 +3483,7 @@ False True - 33 + 32 + + + False + True + 0 + + + + + True + False + 5 + qube + + + + False + True + 1 + + + False @@ -285,45 +325,25 @@ - - Cancel + True - True - False - none - + False + True + + + True + + False True - 2 + 1 - - ADD - True - True - True - none - - - - False - True - 3 - + - False @@ -344,61 +364,6 @@ 2 - - - True - True - True - start - none - - - True - False - 28 - - - True - False - +ADD - - - - False - True - 0 - - - - - True - False - 5 - qube - - - - False - True - 1 - - - - - - - - False - True - 3 - - False @@ -497,13 +462,13 @@ - Filtering Options True False start start 18 True + Filtering Options True + + + False + True + 0 + + + + + _Cancel + True + True + True + none + True + + + + False + True + 1 + + + + + False + False + 0 + + + + + True + False + vertical + + + True + False + start + Qube + + + + False + True + 0 + + + + + True + False + 10 + + + True + False + center + True + Select qube: + 0 + + + + False + True + 0 + + + + + True + False + center + 5 + 5 + True + True + + + True + 24 + + + + + + False + True + 1 + + + + + False + False + 1 + + + + + True + False + center + True + Select a qube to be able to select device types to block. + True + True + 0 + + + + False + True + 2 + + + + + True + False + vertical + + + True + False + start + Block devices + + + + False + True + 0 + + + + + True + False + center + True + You will not be able to manually attach any of the selected device types to this qube. This does <b>NOT</b> apply to automatic attachments. + True + True + 0 + + + + False + True + 1 + + + + + True + False + none + + + + False + True + 2 + + + + + False + True + 3 + + + + + + False + True + 1 + + + + + + + False + True + dialog + + + False + vertical + 2 + + + False + end + + + _Save and Close + True + True + True + none + True + + + + False + True + 0 + + + + + _Cancel + True + True + True + none + True + + + + False + True + 1 + + + + + False + False + 0 + + + + + True + False + vertical + + + True + False + start + Device + + + + False + True + 0 + + + + + True + False + 10 + + + True + False + center + True + Device: + 0 + + + + False + True + 0 + + + + + True + False + + + + False + True + 2 + + + + + False + False + 1 + + + + + True + False + center + 20 + 20 + + + True + False + start + center + 48 + qubes-unplug + + + False + True + 0 + + + + + True + True + start + This device is not currently connected. The assignment cannot be modified. + True + True + + + False + True + 1 + + + + + + False + False + 2 + + + + + True + False + center + True + Apply rule to devices matching: + 0 + + + + False + True + 3 + + + + + True + False + + + Backend qube: + True + False + True + False + Backend qube cannot be changed dynamically. Select a device from desired backend qube. + True + True + + + + False + True + 0 + + + + + + + + False + True + 4 + + + + + True + False + + + Device class: + True + False + True + False + Device class cannot be set independently. Select a device from desired class. + True + True + + + + False + True + 0 + + + + + True + False + center + True + + 0 + + + + False + True + 1 + + + + + False + True + 5 + + + + + True + False + + + Device identity: + True + True + False + start + start + True + + + + False + True + 0 + + + + + True + False + start + 2 + True + 0 + + + + False + True + 2 + + + + + False + True + 6 + + + + + True + False + + + Port: + True + True + False + True + + + + False + True + 0 + + + + + True + False + center + True + 0 + + + + False + True + 1 + + + + + False + True + 7 + + + + + True + False + If you select matching only the port and not device identity, the rule will be applied to any device of this class connected to that port. + True + 0 + + + + False + True + 8 + + + + + True + False + start + Action + + + + False + True + 9 + + + + + True + False + center + True + Select what happens when the device becomes available. + 0 + + + + False + True + 10 + + + + + True + True + False + True + True + + + True + False + vertical + + + True + False + center + True + Automatically attach the device to the selected qube + 0 + + + + False + True + 0 + + + + + True + False + center + True + If multiple qubes are selected and running when the device is connected, you will be asked to choose one of them. + True + 0 + + + + False + True + 1 + + + + + + + False + True + 11 + + + + + Ask to attach to one of the selected qubes + True + True + False + True + edit_device_auto_radio + + + + False + True + 12 + + + + + Read-only (only for block devices) + True + True + False + 10 + True + + + False + True + 13 + + + + + True + False + start + Qubes + + + + False + True + 14 + + + + + True + False + vertical + + + True + False + 40 + + + + False + True + 0 + + + + + True + False + 5 + 10 + + + True + False + center + 5 + 5 + True + + + False + + + + + + False + True + 0 + + + + + True + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + 5 + Add qube + + + + False + True + 1 + + + + + + + + False + True + 1 + + + + + False + True + 1 + + + + + False + True + 15 + + + + + False + A device cannot be attached to its own backend qube. + True + True + 0 + + + + False + True + 16 + + + + + + False + True + 1 + + + + + False Qubes OS Global Config @@ -1127,85 +2029,528 @@ - + + + + + False + True + 5 + + + + + anchor + True + True + True + + + False + True + 6 + + + + + True + False + U2F devices + 0 + + + + False + True + 7 + + + + + True + False + Qubes U2F Proxy allows you to use Universal 2nd Factor (U2F) two-factor authentication (2FA) devices without exposing the whole device and all of its contents to every qube. <a href="https://www.qubes-os.org/doc/u2f-proxy/">Learn more.</a> + +<b>Note:</b> In order to use the U2F proxy in a qube, its template must have the <tt>qubes-u2f</tt> package installed. By default, a qube can use only keys that it has registered itself. + True + True + 0 + + + + False + True + 8 + + + + + u2f_usb_qube_box + True + False + 50 + + + True + False + vertical + + + True + False + start + <b>USB qube</b> + True + + + + False + True + 0 + + + + + True + False + Qube to which the USB controller is connected. + True + 0 + + + + False + True + 1 + + + + + False + True + 0 + + + + + u2f_usb_combo + True + False + start + center + 5 + 50 + 5 + False + True + + + True + 24 + + + + + + False + True + 2 + + + + + False + True + 9 + + + + + False + vertical + + + False + Some policy rules could not be parsed. They are correct but are too complicated for this tool to handle. They will be discarded on save. The following rules were affected: + True + + + False + True + 0 + + + + + False + none + False + + + + False + True + 1 + + False True - 5 + 10 - - anchor - True - True - True + + False + center + vertical + + + False + center + The Qubes U2F Proxy service is not installed in the USB qube. If you wish to use this service, install the <tt>qubes-u2f</tt> package in the template on which the USB qube is based. + True + True + + + False + True + 0 + + + False True - 6 + 11 - - True + False - U2F devices - 0 + vertical + + + False + Custom policy changes found. The changes below may be overwritten by existing custom policy changes. View the files listed below to verify the existing policy. + True + + + False + True + 0 + + + + + False + none + False + + + + False + True + 1 + + False True - 7 + 12 - + True - False - Qubes U2F Proxy allows you to use Universal 2nd Factor (U2F) two-factor authentication (2FA) devices without exposing the whole device and all of its contents to every qube. <a href="https://www.qubes-os.org/doc/u2f-proxy/">Learn more.</a> - -<b>Note:</b> In order to use the U2F proxy in a qube, its template must have the <tt>qubes-u2f</tt> package installed. By default, a qube can use only keys that it has registered itself. - True - True - 0 - + True + False + 10 + 20 + True + + + True + False + start + <b>Enable the Qubes U2F Proxy</b> service + True + + False True - 8 + 14 - - u2f_usb_qube_box + True False - 50 + 30 + vertical + + + True + False + 35 + vertical + + + True + False + start + <b>List of qubes that can use the U2F Proxy:</b> + True + + + False + True + 0 + + + + + True + False + 10 + 40 + + + + False + True + 1 + + + + + True + False + 10 + 40 + 5 + 5 + 10 + + + True + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + Add qube + + + + False + True + 1 + + + + + + + + False + True + 2 + + + + + True + False + True + + + False + + + + + False + True + 2 + + + + + False + True + 2 + + + + + True + False + 10 + If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). + True + True + 0 + + + + False + True + 3 + + + + + False + True + 0 + + - + True False - vertical + center + 10 + 60 + True + + + False + True + 1 + + + + + True + True + False + True True False start - <b>USB qube</b> + Enable <b>registering new keys</b> with the U2F Proxy service True + True + + + + + False + True + 2 + + + + + True + False + 30 + vertical + + + True + True + False + True + True + + + True + False + + + True + False + <b>All qubes</b> + True + + + False + True + 0 + + + + + True + False + selected above can register new keys + True + + + + False + True + 1 + + + + @@ -1215,227 +2560,300 @@ - + True - False - Qube to which the USB controller is connected. - True - 0 + True + False + True + usb_u2f_register_all_radio + + + True + False + + + True + False + <b>Only selected qubes</b> + True + + + False + True + 0 + + + + + True + False + can register new keys + True + + + + False + True + 1 + + + + False True - 1 + 1 + + + + + True + False + 30 + vertical + + + True + False + 10 + 40 + + + + False + True + 0 + + + + + True + False + 10 + 40 + 5 + 5 + 10 + + + True + False + True + + + True + + + + + False + True + 2 + + + + + True + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + Add qube + + + + False + True + 1 + + + + + + + + False + True + 3 + + + + + False + True + 1 + + + + + True + False + 10 + If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). + True + True + 0 + + + + False + True + 2 + + + + + False + True + 2 False True - 0 + 3 - - u2f_usb_combo + True False - start center - 5 - 50 - 5 - False - True - - - True - 24 - - - - - - False - True - 2 - - - - - False - True - 9 - - - - - False - vertical - - - False - Some policy rules could not be parsed. They are correct but are too complicated for this tool to handle. They will be discarded on save. The following rules were affected: - True - - - False - True - 0 - - - - - False - none - False - - - - False - True - 1 - - - - - - False - True - 10 - - - - - False - center - vertical - - - False - center - The Qubes U2F Proxy service is not installed in the USB qube. If you wish to use this service, install the <tt>qubes-u2f</tt> package in the template on which the USB qube is based. - True - True - - - False - True - 0 - - - - - - False - True - 11 - - - - - False - vertical - - - False - Custom policy changes found. The changes below may be overwritten by existing custom policy changes. View the files listed below to verify the existing policy. - True + 10 + 60 + True False True - 0 + 4 - - False - none - False - + + True + True + False + True + + + True + False + + + True + False + Allow some qubes to access <b>ALL</b> keys stored on your U2F device + True + True + + + False + True + 0 + + + + False True - 1 + 5 - - - - False - True - 12 - - - - - True - True - False - 10 - 20 - True - - - True - False - start - <b>Enable the Qubes U2F Proxy</b> service - True - - - - - False - True - 14 - - - - - True - False - 30 - vertical - + True False - 35 + 30 vertical - + True False - start - <b>List of qubes that can use the U2F Proxy:</b> - True + center + 20 + + + True + False + start + center + 48 + qubes-info + + + False + True + 0 + + + + + True + False + start + Caution: +<b>All</b> keys, including those registered by other qubes and those registered previously, will be available to the qubes selected below. + True + True + + + False + True + 1 + + + False - True + False 0 - + True False 10 @@ -1451,16 +2869,33 @@ - + True False 10 - 40 + 30 5 5 10 - + + True + False + True + + + True + + + + + False + True + 2 + + + + True True True @@ -1508,67 +2943,291 @@ False True - 2 + 3 + + + + + False + True + 2 + + + + + True + False + 10 + If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). + True + 0 + + + + False + True + 3 + + + + + False + True + 6 + + + + + False + True + 15 + + + + + + + + + + 1 + + + + + True + False + + + True + False + usb-dark + + + False + True + 0 + + + + + True + False + USB Devices + + + + False + True + 1 + + + + + 1 + False + + + + + attachments + True + True + in + + + True + False + + + True + False + vertical + + + anchor + True + True + True + + + False + True + 0 + + + + + True + False + Device Attachment Policy + 0 + + + + False + True + 1 + + + + + True + False + You can block the ability to manually attach some (or all) device types or devices to selected qubes. This does <b>not</b> apply to automatic attachment set below. + True + True + 0 + + + + False + True + 2 + + + + + True + False + False + + + True + False + No policy rules defined + + + + + + + False + True + 3 + + + + + True + False + 10 + + + True + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + 5 + Add new rule + + + + False + True + 1 + + + + + + + + False + True + 0 + + + + + True + False + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 - + True False - True - - - False - - + 5 + Edit selected rule + False True - 2 + 1 - - False - True - 2 - - - - - True - False - 10 - If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). - True - True - 0 - - - - False - True - 3 - - - - False - True - 0 - - - - - True - False - center - 10 - 60 - True + False @@ -1577,21 +3236,52 @@ - + True + False True - False - True + True + start + none - + True False - start - Enable <b>registering new keys</b> with the U2F Proxy service - True - True + 10 + + + True + False + qubes-icon-remove + + + False + True + 0 + + + + + True + False + 5 + Remove selected rule + + + + False + True + 1 + + + False @@ -1599,131 +3289,166 @@ 2 - - + + + False + True + 4 + + + + + anchor + True + True + True + + + False + True + 5 + + + + + True + False + Device Assignment + 0 + + + + False + True + 6 + + + + + True + False + Devices can be assigned to selected qubes. The device will be attached as soon as the qube is running and the device is available (e.g. plugged in). + True + True + 0 + + + + False + True + 7 + + + + + 600 + True + False + start + False + False + + True False - 30 - vertical - - - True - True - False - True - True - - - True - False - - - True - False - <b>All qubes</b> - True - - - False - True - 0 - - - - - True - False - selected above can register new keys - True - - - - False - True - 1 - - - - - - - - False - True - 0 - - + No automatic assignments defined + + + + + + + False + True + 8 + + + + + True + False + 10 + + + True + True + True + start + none - + True - True - False - True - usb_u2f_register_all_radio + False + 10 - + True False - - - True - False - <b>Only selected qubes</b> - True - - - False - True - 0 - - - - - True - False - can register new keys - True - - - - False - True - 1 - - + qubes-icon-add + + False + True + 0 + + + + + True + False + 5 + Add new rule + + + + False + True + 1 + - - - False - True - 1 - + + + + False + True + 0 + + + + + True + False + True + True + start + none - + True False - 30 - vertical + 10 - + True False - 10 - 40 - + qubes-icon-add False @@ -1732,83 +3457,14 @@ - + True False - 10 - 40 - 5 - 5 - 10 - - - True - False - True - - - True - - - - - False - True - 2 - - - - - True - True - True - start - none - - - True - False - 10 - - - True - False - qubes-icon-add - - - False - True - 0 - - - - - True - False - Add qube - - - - False - True - 1 - - - - - - - - False - True - 3 - - + 5 + Edit selected rule + False @@ -1816,71 +3472,176 @@ 1 + + + + + + False + True + 1 + + + + + True + False + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-remove + + + False + True + 0 + + True False - 10 - If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). - True - True - 0 + 5 + Remove selected rule False True - 2 + 1 - - False - True - 2 - + False True - 3 + 2 - - + + + False + True + 9 + + + + + anchor + True + True + True + + + False + True + 10 + + + + + True + False + Required Devices + 0 + + + + False + True + 11 + + + + + True + False + A qube can require a device to start. This option is supported only for block and PCI devices, not for USB devices. + True + True + 0 + + + + False + True + 12 + + + + + True + False + False + + True False - center - 10 - 60 - True + No required assignments defined + - - False - True - 4 - + + + + False + True + 13 + + + + + True + False + 10 - + True True - False - True + True + start + none True False + 10 - + True False - Allow some qubes to access <b>ALL</b> keys stored on your U2F device - True - True + qubes-icon-add False @@ -1888,35 +3649,53 @@ 0 + + + True + False + 5 + Add new rule + + + + False + True + 1 + + + False True - 5 + 0 - + True - False - 30 - vertical + False + True + True + start + none True False - center - 20 + 10 True False - start - center - 48 - qubes-info + qubes-icon-add False @@ -1925,14 +3704,14 @@ - + True False - start - Caution: -<b>All</b> keys, including those registered by other qubes and those registered previously, will be available to the qubes selected below. - True - True + 5 + Edit selected rule + False @@ -1940,147 +3719,78 @@ 1 - - - - False - False - 0 - - - - - True - False - 10 - 40 - - - False - True - 1 - + + + + False + True + 1 + + + + + True + False + True + True + start + none - + True False - 10 - 30 - 5 - 5 10 - + True False - True - - - True - - + qubes-icon-remove False True - 2 + 0 - + True - True - True - start - none - - - True - False - 10 - - - True - False - qubes-icon-add - - - False - True - 0 - - - - - True - False - Add qube - - - - False - True - 1 - - - - + False + 5 + Remove selected rule False True - 3 + 1 - - - - False - True - 2 - - - - - True - False - 10 - If you don't see the desired qube here, install the <tt>qubes-u2f</tt> package in the template on which it is based (or, if it's a standalone, in the qube itself). - True - 0 - - - - False - True - 3 - + + + False True - 6 + 2 False True - 15 + 14 @@ -2128,7 +3839,7 @@ - 1 + 2 False @@ -3189,6 +4900,7 @@ @@ -3495,7 +5207,7 @@ - 2 + 3 @@ -3531,7 +5243,7 @@ - 2 + 3 False @@ -3889,6 +5601,7 @@ @@ -4097,6 +5810,7 @@ @@ -4323,7 +6037,7 @@ - 3 + 4 @@ -4359,7 +6073,7 @@ - 3 + 4 False @@ -4878,11 +6592,13 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, No exceptions @@ -5104,7 +6820,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, - 4 + 5 @@ -5140,7 +6856,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, - 4 + 5 False @@ -5536,11 +7252,13 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, No exceptions @@ -6064,6 +7782,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, @@ -6280,7 +7999,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, - 5 + 6 @@ -6316,7 +8035,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, - 5 + 6 False @@ -6641,6 +8360,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, @@ -6857,7 +8577,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, - 6 + 7 @@ -6893,7 +8613,7 @@ Inter-qube copy and paste actions are performed via special keyboard shortcuts, - 6 + 7 False @@ -7361,18 +9081,275 @@ to global clipboard - 7 + 8 + + + + + True + False + + + True + False + usb-dark + + + False + True + 0 + + + + + True + False + This Device + + + + False + True + 1 + + + + + 8 + False + + + + + + logo_grid + True + False + start + center + 10 + 30 + 10 + + + + 0 + 0 + + + + + True + False + center + 10 + Global Config + center + + + + 1 + 0 + + + + + False + + + + + False + True + 0 + + + + + True + False + end + 20 + 20 + 20 + 20 + 10 + True + + + _Apply Changes and Close + True + True + True + True + + + + False + True + 1 + + + + + _Apply Changes + True + True + True + True + + + + False + True + 2 + + + + + _Cancel + True + True + True + True + + + + False + True + 2 + + + + + False + True + 1 + + + + + + + False + True + dialog + + + False + vertical + 2 + + + False + end + + + _Save and Close + True + True + True + none + True + + + + False + True + 0 + + + + + _Cancel + True + True + True + none + True + + + + False + True + 1 + + + + + False + False + 0 + + + + + True + False + vertical + + + True + False + start + Device + + + + False + True + 0 - + True False + 10 - + True False - usb-dark + center + True + Device: + 0 + False @@ -7381,148 +9358,349 @@ to global clipboard - + True False - This Device False True - 1 + 2 - 7 - False + False + False + 1 - - - - logo_grid + + True False - start - center - 10 - 30 - 10 + Select available PCI or block device. + True + 0 + + + + False + True + 2 + + + + + True + False + True + center + 20 + 20 - - - False - True - 0 - - - - - True - False - end - 20 - 20 - 20 - 20 - 10 - True - - _Apply Changes and Close + + True + False + start + 2 + True + Device class: +Other description: + 0 + + + + False + True + 4 + + + + + True + False + start + Options + + + + False + True + 5 + + + + + True + False + <b>No strict reset</b> and <b>permissive</b> options should only be used in case of compatibility problems. They can lead to security issues. + True + True + 0 + + + + False + True + 6 + + + + + No strict reset True True - True - True + False + True False True - 1 + 7 - - _Apply Changes + + Permissive True True - True - True + False + True False True - 2 + 8 - - _Cancel + + Read-only True True - True - True + False + True False True - 2 + 9 + + + + + True + False + start + Qubes + + + + False + True + 10 + + + + + True + False + vertical + + + True + False + 40 + + + + False + True + 0 + + + + + True + False + 5 + 10 + + + True + False + center + 5 + 5 + True + + + True + + + + + + False + True + 0 + + + + + True + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + 5 + Add qube + + + + False + True + 1 + + + + + + + + False + True + 1 + + + + + False + True + 1 + + + + + False + True + 11 + + + + + False + A device cannot be attached to its own backend qube. + True + True + 0 + + + + False + True + 12 + False diff --git a/qubes_config/global_config/device_attachments.py b/qubes_config/global_config/device_attachments.py new file mode 100644 index 00000000..09f3e867 --- /dev/null +++ b/qubes_config/global_config/device_attachments.py @@ -0,0 +1,1107 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2022 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . +""" +Device attachment functionality. +""" +from typing import List, Optional, Tuple, Iterator, Callable + +from qubesadmin.device_protocol import ( + DeviceAssignment, + AssignmentMode, + DeviceInfo, + Port, + DeviceInterface, +) + +from ..widgets.gtk_widgets import TokenName +from ..widgets.gtk_utils import show_error +from .device_widgets import ( + DevPolicyRow, + DevPolicyDialogHandler, + HeaderComboModeler, + DevicePolicyHandler, +) +from .page_handler import PageHandler +from .vm_flowbox import VMFlowboxHandler +from .device_blocks import DeviceBlockHandler + +import gi + +import qubesadmin +import qubesadmin.vm +import qubesadmin.exc + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Pango + +import gettext + +t = gettext.translation("desktop-linux-manager", fallback=True) +_ = t.gettext + +DEVICE_CLASSES = { + "block": _("Block device"), + "mic": _("Microphone"), + "pci": _("PCI device"), + "usb": _("USB device"), +} + + +class AutoDeviceDialog(DevPolicyDialogHandler): + """ + The handler for an Edit Device Rule dialog window. + """ + + DEV_CLASSES = { + "mic": _("Microphone"), + "usb": _("USB Devices"), + "block": _("Block Devices"), + } + + def __init__( + self, + builder: Gtk.Builder, + qapp, + parent_window: Gtk.Window, + device_manager: "DeviceManager", + ): + """ + Dialog for editing AutoAttachment options. + """ + super().__init__(builder, qapp, "edit_device", parent_window) + self.device_manager = device_manager + + self.dev_combo: Gtk.ComboBox = builder.get_object( + "edit_device_device_combo" + ) + + self.backend_check: Gtk.CheckButton = builder.get_object( + "edit_device_backend_check" + ) + self.devclass_check: Gtk.CheckButton = builder.get_object( + "edit_device_devclass_check" + ) + self.devident_check: Gtk.CheckButton = builder.get_object( + "edit_device_devident_check" + ) + self.port_check: Gtk.CheckButton = builder.get_object( + "edit_device_port_check" + ) + + self.devident_label: Gtk.Label = builder.get_object( + "edit_device_devident_label" + ) + + self.backend_box: Gtk.Box = builder.get_object( + "edit_device_backend_box" + ) + self.backend_child: Optional[Gtk.Widget] = None # formatted qube name + + self.auto_radio: Gtk.RadioButton = builder.get_object( + "edit_device_auto_radio" + ) + self.ask_radio: Gtk.RadioButton = builder.get_object( + "edit_device_ask_radio" + ) + + self.unknown_box: Gtk.Box = builder.get_object( + "edit_device_unknown_box" + ) + self.err_label: Gtk.Label = builder.get_object("edit_device_err_label") + self.read_only_check: Gtk.CheckButton = builder.get_object( + "edit_device_read_only" + ) + + self.qube_handler = VMFlowboxHandler( + builder, + self.qapp, + "edit_device", + [], + filter_function=lambda vm: vm.klass != "AdminVM", + ) + + self.backend_vm: Optional[qubesadmin.vm.QubesVM] = None + dev_list = {} + for class_id, class_name in self.DEV_CLASSES.items(): + devices = list( + self.device_manager.get_available_devices([class_id]) + ) + if devices: + dev_list[class_name] = (class_name, None) + for dev in devices: + dw = DeviceWrapper.new_from_device_info(dev) + dev_list[dw.device_id] = (dw.long_name, dw) + + self.dev_modeler = HeaderComboModeler(self.dev_combo, dev_list) + + self.devident_check.connect("toggled", self.validate) + self.port_check.connect("toggled", self.validate) + self.qube_handler.connect_change_callback(self.validate) + + self.dev_combo.connect("changed", self._combo_changed) + self.dev_combo.connect("changed", self.validate) + + def fill_checkboxes( + self, + backend_domain: Optional[qubesadmin.vm.QubesVM], + devclass: str, + dev_identity: str, + port: str, + ident_checked: bool, + port_checked: bool, + read_only: Optional[bool] = False, + ): + """Fill all checkboxes with appropriate values""" + if self.backend_child: + self.backend_box.remove(self.backend_child) + self.backend_child = None + if backend_domain: + self.backend_child = TokenName(str(backend_domain), self.qapp) + self.backend_box.pack_start(self.backend_child, False, False, 5) + self.backend_box.show_all() + self.backend_vm = backend_domain + + self.devclass_check.set_label(_("Device class: ") + devclass) + + # dev identity + self.devident_label.set_text(dev_identity) + self.devident_check.set_sensitive(True) + self.devident_check.set_active(ident_checked) + + # port + self.port_check.set_label("Port: " + port) + self.port_check.set_sensitive(True) + self.port_check.set_active(port_checked) + + if devclass == "block": + self.read_only_check.set_sensitive(True) + self.read_only_check.set_active(read_only) + else: + self.read_only_check.set_sensitive(False) + self.read_only_check.set_active(False) + + def fill_checkboxes_from_device(self, device: "DeviceWrapper"): + """Fill checkboxes based on a DeviceInfo""" + self.fill_checkboxes( + backend_domain=device.backend_domain, + devclass=device.devclass, + dev_identity=device.identity_description, + port=str(device.port), + ident_checked=True, + port_checked=False, + ) + self.devident_check.set_sensitive(True) + self.port_check.set_sensitive(True) + + def fill_checkboxes_from_assignment_wrapper( + self, assignment_wrapper: "AssignmentWrapper" + ): + """Fill checkboxes based on a set of assignments""" + if not assignment_wrapper.device.device: + self.fill_checkboxes_from_none() + return + self.fill_checkboxes( + backend_domain=assignment_wrapper.device.backend_domain, + devclass=assignment_wrapper.device.devclass, + dev_identity=assignment_wrapper.device_identity_description(), + port=str(assignment_wrapper.device.device.port), + ident_checked=assignment_wrapper.device_identity_required, + port_checked=assignment_wrapper.port_required, + read_only=assignment_wrapper.read_only, + ) + self.devident_check.set_sensitive(True) + self.port_check.set_sensitive(True) + + def fill_checkboxes_from_none(self): + """Fill checkboxes for an empty device""" + if self.backend_child: + self.backend_box.remove(self.backend_child) + self.backend_child = None + self.backend_vm = None + + self.devclass_check.set_label(_("Device class:")) + + # dev identity + self.devident_label.set_text("") + self.devident_check.set_active(False) + self.devident_check.set_sensitive(False) + + # port + self.port_check.set_label("Port: ") + self.port_check.set_active(False) + self.port_check.set_sensitive(False) + + self.read_only_check.set_sensitive(False) + self.read_only_check.set_active(False) + + def check_validity(self): + """Check if dialog can be saved/ok-ed""" + if not self.qube_handler.selected_vms: + return False + if ( + not self.port_check.get_active() + and not self.devident_check.get_active() + ): + return False + for vm in self.qube_handler.selected_vms: + if vm == self.backend_vm: + self.err_label.set_visible(True) + return False + self.err_label.set_visible(False) + return True + + def _combo_changed(self, *_args): + device = self.dev_modeler.get_selected() + if device: + self.fill_checkboxes_from_device(device) + else: + self.fill_checkboxes_from_none() + + def _save_changes(self, *_args): + if not self.current_row: + self._cancel() + return + assignment_wrapper = self.current_row.assignment_wrapper + + # we need to always save currently selected device, to avoid the hell of + # finding it again + if self.dev_modeler.get_selected(): + if assignment_wrapper.device != self.dev_modeler.get_selected(): + assignment_wrapper.changed = True + assignment_wrapper.device = self.dev_modeler.get_selected() + + if ( + assignment_wrapper.device_identity_required + != self.devident_check.get_active() + ): + assignment_wrapper.changed = True + assignment_wrapper.device_identity_required = ( + self.devident_check.get_active() + ) + + if assignment_wrapper.port_required != self.port_check.get_active(): + assignment_wrapper.changed = True + assignment_wrapper.port_required = self.port_check.get_active() + + if self.port_check.get_active(): + if assignment_wrapper.port != self.dev_modeler.get_selected().port: + assignment_wrapper.changed = True + assignment_wrapper.port = self.dev_modeler.get_selected().port + if ( + self.auto_radio.get_active() + and assignment_wrapper.mode != AssignmentMode.AUTO + ): + assignment_wrapper.changed = True + assignment_wrapper.mode = AssignmentMode.AUTO + elif ( + self.ask_radio.get_active() + and assignment_wrapper.mode != AssignmentMode.ASK + ): + assignment_wrapper.changed = True + assignment_wrapper.mode = AssignmentMode.ASK + + if assignment_wrapper.read_only != self.read_only_check.get_active(): + assignment_wrapper.changed = True + assignment_wrapper.read_only = self.read_only_check.get_active() + + if sorted(assignment_wrapper.frontends) != sorted( + self.qube_handler.selected_vms + ): + assignment_wrapper.changed = True + assignment_wrapper.frontends = self.qube_handler.selected_vms.copy() + + super()._save_changes() + + def run_for_new(self, new_row_function: Callable) -> DevPolicyRow: + """Open dialog for a new assignment""" + self.dialog.set_title(_("Create New Device Assignment")) + self.fill_checkboxes_from_none() + self.dev_combo.set_active_id(None) + self.qube_handler.reset() + self.unknown_box.set_visible(False) + + self.auto_radio.set_active(True) + self.validate() + + new_row = super().run_for_new(new_row_function) + return new_row + + def run_for_existing(self, row: DevPolicyRow): + """Open dialog for an existing assignment""" + self.dialog.set_title(_("Edit Device Assignment")) + + # load data + self.dev_modeler.select_value(row.assignment_wrapper.device) + self.fill_checkboxes_from_assignment_wrapper(row.assignment_wrapper) + + if row.assignment_wrapper.mode == AssignmentMode.AUTO: + self.auto_radio.set_active(True) + else: + self.ask_radio.set_active(True) + + # fill qubes + self.qube_handler.reset() + for qube in row.assignment_wrapper.frontends: + self.qube_handler.add_selected_vm(qube) + + if not row.assignment_wrapper.device.device.is_device_id_set: + if row.assignment_wrapper.device_identity_required: + self.unknown_box.set_visible(True) + else: + # it looks weird to show "device unavailable" box for unknown + # device when device is not shown + self.unknown_box.set_visible(False) + self.dev_combo.set_sensitive(False) + self.port_check.set_sensitive(False) + self.devident_check.set_sensitive(False) + self.auto_radio.set_sensitive(False) + self.ask_radio.set_sensitive(False) + self.qube_handler.set_sensitive(False) + else: + self.unknown_box.set_visible(False) + self.dev_combo.set_sensitive(True) + self.port_check.set_sensitive(True) + self.devident_check.set_sensitive(True) + self.auto_radio.set_sensitive(True) + self.ask_radio.set_sensitive(True) + self.qube_handler.set_sensitive(True) + + super().run_for_existing(row) + + +class RequiredDeviceDialog(DevPolicyDialogHandler): + DEV_CLASSES = {"pci": _("PCI Devices"), "block": _("Block Devices")} + + def __init__( + self, + builder: Gtk.Builder, + qapp, + parent_window: Gtk.Window, + device_manager: "DeviceManager", + ): + """ + The handler for a Required Device Rule dialog window. + """ + super().__init__(builder, qapp, "required_device", parent_window) + self.device_manager = device_manager + + self.dev_combo: Gtk.ComboBox = builder.get_object( + "required_device_device_combo" + ) + self.devident_label: Gtk.Label = builder.get_object( + "required_device_devident" + ) + + self.no_strict_check: Gtk.CheckButton = builder.get_object( + "required_device_nostrict_check" + ) + self.permissive_check: Gtk.CheckButton = builder.get_object( + "required_device_permissive_check" + ) + self.readonly_check: Gtk.CheckButton = builder.get_object( + "required_device_readonly_check" + ) + + self.unknown_box: Gtk.Box = builder.get_object( + "required_device_unknown_box" + ) + self.err_label: Gtk.Label = builder.get_object( + "required_device_err_label" + ) + + self.qube_handler = VMFlowboxHandler( + builder, + self.qapp, + "required_device", + [], + filter_function=lambda vm: vm.klass != "AdminVM", + ) + + dev_list = {} + for class_id, class_name in self.DEV_CLASSES.items(): + devices = list( + self.device_manager.get_available_devices([class_id]) + ) + if devices: + dev_list[class_name] = (class_name, None) + for dev in devices: + dw = DeviceWrapper.new_from_device_info(dev) + dev_list[dw.device_id] = (dw.long_name, dw) + + self.dev_modeler = HeaderComboModeler(self.dev_combo, dev_list) + + self.qube_handler.connect_change_callback(self.validate) + + self.dev_combo.connect("changed", self._combo_changed) + self.dev_combo.connect("changed", self.validate) + + def check_validity(self): + """Check if dialog can be saved/ok-ed""" + if not self.qube_handler.selected_vms: + return False + if not self.dev_modeler.get_selected(): + return False + backend_vm = self.dev_modeler.get_selected().backend_domain + for vm in self.qube_handler.selected_vms: + if vm == backend_vm: + self.err_label.set_visible(True) + return False + self.err_label.set_visible(False) + return True + + def _combo_changed(self, *_args): + device = self.dev_modeler.get_selected() + if device: + self.devident_label.set_text(device.identity_description) + if device.devclass == "pci": + self.readonly_check.set_sensitive(False) + self.readonly_check.set_active(False) + self.readonly_check.set_tooltip_text( + _("Option not available for PCI devices") + ) + self.permissive_check.set_sensitive(True) + self.permissive_check.set_tooltip_text("") + self.no_strict_check.set_sensitive(True) + self.no_strict_check.set_tooltip_text("") + elif device.devclass == "block": + self.readonly_check.set_sensitive(True) + self.readonly_check.set_tooltip_text("") + self.permissive_check.set_sensitive(False) + self.permissive_check.set_active(False) + self.permissive_check.set_tooltip_text( + _("Option not available for block devices.") + ) + self.no_strict_check.set_sensitive(False) + self.no_strict_check.set_active(False) + self.no_strict_check.set_tooltip_text( + _("Option not available for block devices.") + ) + else: + self.devident_label.set_text("No device selected.") + + def _save_changes(self, *_args): + if not self.current_row: + self._cancel() + return + assignment_wrapper = self.current_row.assignment_wrapper + + if ( + assignment_wrapper.no_strict_reset + != self.no_strict_check.get_active() + ): + assignment_wrapper.changed = True + assignment_wrapper.no_strict_reset = ( + self.no_strict_check.get_active() + ) + + if assignment_wrapper.permissive != self.permissive_check.get_active(): + assignment_wrapper.changed = True + assignment_wrapper.permissive = self.permissive_check.get_active() + + if assignment_wrapper.read_only != self.readonly_check.get_active(): + assignment_wrapper.changed = True + assignment_wrapper.read_only = self.readonly_check.get_active() + + if assignment_wrapper.device != self.dev_modeler.get_selected(): + assignment_wrapper.changed = True + assignment_wrapper.device = self.dev_modeler.get_selected() + + if sorted(assignment_wrapper.frontends) != sorted( + self.qube_handler.selected_vms + ): + assignment_wrapper.changed = True + assignment_wrapper.frontends = self.qube_handler.selected_vms.copy() + + super()._save_changes() + + def run_for_new(self, new_row_function: Callable) -> DevPolicyRow: + self.dialog.set_title(_("Create New Device Assignment")) + self.dev_combo.set_active_id(None) + + self.no_strict_check.set_active(False) + self.permissive_check.set_active(False) + self.readonly_check.set_active(False) + self.no_strict_check.set_sensitive(False) + self.permissive_check.set_sensitive(False) + self.readonly_check.set_sensitive(False) + + self.unknown_box.set_visible(False) + self.qube_handler.reset() + self.validate() + + new_row = super().run_for_new(new_row_function) + return new_row + + def run_for_existing(self, row: DevPolicyRow): + self.dialog.set_title(_("Edit Device Assignment")) + # load data + self.dev_modeler.select_value(row.assignment_wrapper.device) + + self.no_strict_check.set_active(row.assignment_wrapper.no_strict_reset) + self.permissive_check.set_active(row.assignment_wrapper.permissive) + self.readonly_check.set_active(row.assignment_wrapper.read_only) + + # fill qubes + self.qube_handler.reset() + for qube in row.assignment_wrapper.frontends: + self.qube_handler.add_selected_vm(qube) + + if row.assignment_wrapper.device.device.is_device_id_set: + self.unknown_box.set_visible(False) + else: + self.unknown_box.set_visible(True) + + super().run_for_existing(row) + + +class DeviceManager: + """A helper class to keep the system state in, to avoid re-querying + qubesd regularly""" + + def __init__(self, qapp: qubesadmin.Qubes): + self.qapp = qapp + + self.all_devices: List[DeviceInfo] = [] + self.assignments: List[ + Tuple[DeviceAssignment, qubesadmin.vm.QubesVM] + ] = [] + + def load_data(self): + """Load system state""" + self.all_devices.clear() + self.assignments.clear() + + for vm in self.qapp.domains: + for devclass in DEVICE_CLASSES: + for dev in vm.devices[devclass].get_exposed_devices(): + self.all_devices.append(dev) + for ass in vm.devices[devclass].get_assigned_devices(): + self.assignments.append((ass, vm)) + + def get_available_devices( + self, dev_classes: List[str] + ) -> Iterator[DeviceInfo]: + """Get all available devices of listed classes""" + for dev in self.all_devices: + if dev.devclass in dev_classes: + yield dev + + def get_assignments( + self, dev_classes: List[str] + ) -> Iterator[Tuple[DeviceAssignment, qubesadmin.vm.QubesVM]]: + """Get all available assignments of listed classes""" + for assignment, vm in self.assignments: + if assignment.devclass in dev_classes: + yield assignment, vm + + def get_blocks( + self, + ) -> Iterator[tuple[qubesadmin.vm.QubesVM, list[DeviceInterface]]]: + """Get all device interface blocks""" + for vm in self.qapp.domains: + if getattr(vm, "devices_denied", None): + yield vm, DeviceInterface.from_str_bulk(vm.devices_denied) + + +class DeviceWrapper: + """A convenience wrapper for any object that might represent what is + being connected to VMs. Can be an existing device, a virtual device or a + port.""" + + def __init__( + self, devclass: str, backend_domain: Optional[qubesadmin.vm.QubesVM] + ): + # a bare-bones device object, ready to be filled with data + self.device: Optional[DeviceInfo] = None + self.devclass: str = devclass + self.device_id: str = "" + + # shortest possible identifier + self.short_name: str = "" + # identifier to be used in dropdowns etc. + self.long_name: str = "" + + # domain + self.backend_domain = backend_domain + + # Description of the port + self.port: Optional[Port] = None + + @property + def identity_description(self) -> str: + if not self.device: + return _("Unknown device") + result = _("Device name: ") + self.device.description + "\n" + result += ( + _("Type: ") + + ", ".join( + interface.category.name for interface in self.device.interfaces + ) + + "\n" + ) + result += _("Vendor: ") + self.device.vendor + "\n" + result += _("Serial number: ") + self.device.serial + return result + + @classmethod + def new_from_device_info(cls, device_info: DeviceInfo): + dw = cls(device_info.devclass, device_info.backend_domain) + dw.device = device_info + dw.device_id = device_info.device_id + + dw.short_name = str(device_info.port) + dw.long_name = device_info.description + + dw.port = device_info.port + return dw + + def __eq__(self, other): + return self.device == getattr(other, "device", None) + + +class AssignmentWrapper: + """ + A set of assignments with the same device, port_id, assignment mode and + options. + """ + + def __init__(self, device: DeviceWrapper): + self.changed = False + self.assignments: List[DeviceAssignment] = [] + + # the variables below represent the state as displayed to a user; + # it might be different than current system state if the user made + # some changes and did not yet apply them + self.device: DeviceWrapper = device + + self.frontends: List[qubesadmin.vm.QubesVM] = [] + self.mode: AssignmentMode = AssignmentMode.REQUIRED + + self.port_required: bool = True + self.device_identity_required: bool = True + self.no_strict_reset = False + self.permissive = False + self.read_only = False + self.valid = True # does this set of assignments have any weirdness + # we can't handle? + + self.port: Optional[Port] = None + + @classmethod + def new_from_existing(cls, assignments: List[DeviceAssignment]): + """Create new AssignmentWrapper from a list of DeviceAssignments; + this assumes the DeviceAssignments are appropriately grouped""" + device = assignments[0].device + wrapped_device = DeviceWrapper.new_from_device_info(device_info=device) + + aw = cls(wrapped_device) + aw.assignments = assignments + + for ass in aw.assignments: + aw.frontends.append(ass.frontend_domain) + + aw.mode = aw.assignments[0].mode + aw.device_identity_required = aw.assignments[0].device_id != "*" + aw.port_required = aw.assignments[0].port_id != "*" + aw.port = aw.assignments[0].port + if not aw.device_identity_required and not aw.port_required: + aw.valid = False + + aw.no_strict_reset = ( + aw.assignments[0].options.get("no-strict-reset", "") == "True" + ) + aw.permissive = ( + aw.assignments[0].options.get("permissive", "") == "True" + ) + aw.read_only = aw.assignments[0].options.get("read-only", "") == "True" + + for opt in aw.assignments[0].options: + if opt not in ("permissive", "no-strict-reset", "read-only"): + aw.valid = False + return aw + + def device_identity_description(self): + """Nicely readable device identity description""" + if self.device_identity_required: + return self.device.identity_description + return "" + + def __str__(self): + return ( + self.device_description() + + " " + + self.action_description() + + " " + + ", ".join([vm.name for vm in self.frontends]) + ) + + def action_description(self) -> str: + """Description of associated action""" + if self.mode == AssignmentMode.AUTO: + return _("will attach automatically to") + if self.mode == AssignmentMode.REQUIRED: + return _("is required by ") + return _("will ask to be attached to") + + def device_description(self): + """ + Description of the assignment row, for use in the List of rows + """ + if not self.device_identity_required: + # we are at port + return _("Any {} device attached to {} ").format( + self.device.devclass, str(self.port) + ) + # device ID required + if self.port_required: + return "({}) {} ".format( + str(self.device.short_name), self.device.long_name + ) + + return "({}) {} ".format( + self.device.backend_domain, self.device.long_name + ) + + def remove(self): + """ + Remove the current assignment from the system. + .""" + for assignment in self.assignments: + assignment.frontend_domain.devices[assignment.devclass].unassign( + assignment + ) + + def save(self): + """ + Save all assignment changes via removing old assignments and + creating them again. + """ + if not self.changed: + return + if self.assignments: + options = self.assignments[0].options.copy() + # options we handle + if "no-strict-reset" in options: + del options["no-strict-reset"] + if "permissive" in options: + del options["permissive"] + else: + options = {} + + self.remove() + self.assignments.clear() + + device = self.device.device + + if not device: + raise ValueError("Device not found") + + if not self.device_identity_required: + device = device.clone(device_id="*") + if not self.port_required: + device = device.clone( + port=Port(device.backend_domain, "*", device.devclass) + ) + if self.mode == AssignmentMode.AUTO: + mode = "auto-attach" + elif self.mode == AssignmentMode.ASK: + mode = "ask-to-attach" + elif self.mode == AssignmentMode.REQUIRED: + mode = "required" + else: + raise ValueError("Assignment mode unknown") + + if self.no_strict_reset: + options["no-strict-reset"] = "True" + if self.permissive: + options["permissive"] = "True" + if self.read_only: + options["read-only"] = "True" + + for vm in self.frontends: + new_assignment = DeviceAssignment( + device=device, mode=mode, options=options + ) + vm.devices[device.devclass].assign(new_assignment) + self.assignments.append(new_assignment) + + self.changed = False + + +class AttachmentDescriptionRow(DevPolicyRow): + """A ListBoxRow describing an existing assignment rule""" + + def __init__( + self, assignment_wrapper: AssignmentWrapper, qapp: qubesadmin.Qubes + ): + super().__init__() + self.assignment_wrapper = assignment_wrapper + self.valid = assignment_wrapper.valid + self.qapp = qapp + + self.main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.main_box.set_homogeneous(True) + self.main_box.set_spacing(5) + + self.add(self.main_box) + + self.dev_box = Gtk.Box() + self.dev_label = Gtk.Label() + self.dev_label.set_max_width_chars(100) + self.dev_label.set_ellipsize(Pango.EllipsizeMode.END) + self.dev_box.add(self.dev_label) + + self.dev_label.set_halign(Gtk.Align.START) + self.dev_label.set_valign(Gtk.Align.START) + + self.action_label = Gtk.Label() + self.action_label.set_halign(Gtk.Align.CENTER) + self.action_label.set_valign(Gtk.Align.START) + + self.main_box.pack_start(self.dev_box, True, True, 0) + self.main_box.pack_start(self.action_label, True, True, 0) + + self.vm_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.vm_box.set_halign(Gtk.Align.START) + self.main_box.pack_start(self.vm_box, True, True, 0) + + self.update() + + def validate(self, row_collection) -> bool: + for row in row_collection: + if row == self: + continue + if ( + row.assignment_wrapper.device_description() + == self.assignment_wrapper.device_description() + ): + for vm in self.assignment_wrapper.frontends: + if vm in row.assignment_wrapper.frontends: + return False + return True + + def update(self): + """Update row display.""" + self.dev_label.set_markup(self.assignment_wrapper.device_description()) + self.action_label.set_markup( + self.assignment_wrapper.action_description() + ) + + for child in self.vm_box.get_children(): + self.vm_box.remove(child) + + self.valid = True + self.set_tooltip_text("") + for vm in self.assignment_wrapper.frontends: + self.vm_box.add(TokenName(vm.name, self.qapp)) + if vm.klass == "AdminVM": + self.valid = False + self.set_tooltip_text( + _("This rule cannot be edited with GUI tools.") + ) + + self.show_all() + + def save(self): + """ + Save row changes, if any. + """ + if not self.changed: + return + self.assignment_wrapper.save() + super().save() + + def remove_self(self): + """ + Remove all assignments associated with this row. + """ + self.assignment_wrapper.remove() + + def __str__(self): + """ + Used in descriptions of unsaved changes. + """ + return str(self.assignment_wrapper) + + +class AttachmentHandler(DevicePolicyHandler): + def __init__( + self, + builder: Gtk.Builder, + qapp: qubesadmin.Qubes, + prefix: str, + device_policy_manager: DeviceManager, + classes: list[str], + edit_dialog_class, + ): + self.classes = classes + super().__init__( + prefix=prefix, + builder=builder, + qapp=qapp, + device_policy_manager=device_policy_manager, + edit_dialog_class=edit_dialog_class, + ) + + def get_edit_dialog(self, builder) -> DevPolicyDialogHandler: + return self.edit_dialog_class( + builder=builder, + qapp=self.qapp, + parent_window=self.main_window, + device_manager=self.device_policy_manager, + ) + + def create_new_row(self) -> AttachmentDescriptionRow: + blank_device = DeviceWrapper("new", None) + assignment_wrapper = AssignmentWrapper(blank_device) + new_row = AttachmentDescriptionRow(assignment_wrapper, self.qapp) + return new_row + + @staticmethod + def assignment_id(device_assignment: DeviceAssignment): + """Unique identifier of a device_assignment, consists of a device id, + port id and options.""" + return ( + device_assignment.device_id, + device_assignment.port_id, + str(device_assignment.options), + ) + + def load_current_state(self): + """Load system state""" + assignments: dict[tuple[str, str, str], list[DeviceAssignment]] = {} + # we identify an attachment as device identity + port identity + for assignment, vm in self.device_policy_manager.get_assignments( + self.classes + ): + if assignment.frontend_domain is None: + assignment.frontend_domain = vm # WORKAROUND for qubesd not + # storing frontend domain correctly + assignment_id = self.assignment_id(assignment) + if assignment_id in assignments: + assignments[assignment_id].append(assignment) + else: + assignments[assignment_id] = [assignment] + + for assignment_id, assignment_list in assignments.items(): + aw = AssignmentWrapper.new_from_existing(assignment_list) + row = AttachmentDescriptionRow(aw, self.qapp) + self.rule_list.add(row) + + self.rule_list.show_all() + + +class DevAttachmentHandler(PageHandler): + """Handler for all the disparate Dev attachment functions.""" + + def __init__( + self, + qapp: qubesadmin.Qubes, + gtk_builder: Gtk.Builder, + ): + self.qapp = qapp + self.device_manager = DeviceManager(self.qapp) + self.device_manager.load_data() + + self.main_window = gtk_builder.get_object("main_window") + + self.dev_block_handler = DeviceBlockHandler( + qapp, gtk_builder, self.device_manager + ) + self.auto_attach_handler = AttachmentHandler( + builder=gtk_builder, + qapp=qapp, + prefix="devices_auto", + device_policy_manager=self.device_manager, + classes=["block", "mic", "usb"], + edit_dialog_class=AutoDeviceDialog, + ) + self.required_devices_handler = AttachmentHandler( + builder=gtk_builder, + qapp=qapp, + prefix="devices_required", + device_policy_manager=self.device_manager, + classes=["block", "pci"], + edit_dialog_class=RequiredDeviceDialog, + ) + + self.lists = [ + self.dev_block_handler.rule_list, + self.auto_attach_handler.rule_list, + self.required_devices_handler.rule_list, + ] + + for widget in self.lists: + widget.connect("row-selected", self._select_one) + widget.connect("rules-changed", self.validate_all_rows) + + def _select_one(self, selected_widget: Gtk.ListBox, selected_row, *_args): + if not selected_row: + return + for widget in self.lists: + if widget != selected_widget: + widget.select_row(None) + + def get_unsaved(self) -> str: + """Get human-readable description of unsaved changes, or + empty string if none were found.""" + unsaved = [ + self.dev_block_handler.get_unsaved(), + self.auto_attach_handler.get_unsaved(), + self.required_devices_handler.get_unsaved(), + ] + return "\n".join([x for x in unsaved if x]) + + def reset(self): + """Reset state to initial or last saved state, whichever is newer.""" + self.dev_block_handler.reset() + self.auto_attach_handler.reset() + self.required_devices_handler.reset() + + def validate_all_rows(self, *_args): + errors = [] + all_rows = ( + self.auto_attach_handler.rule_list.get_children() + + self.required_devices_handler.rule_list.get_children() + ) + for row in all_rows: + if not row.validate(all_rows): + errors.append(row) + row.get_style_context().add_class("error_row") + else: + row.get_style_context().remove_class("error_row") + + if errors: + show_error( + self.main_window, + _("Duplicate rules"), + _( + "Duplicate device rules were found. Those rules cannot be " + "all present in a system; only the last one will be saved. " + ), + ) + + def save(self): + """Save current rules, whatever they are - custom or default.""" + # pretty ugly solution to duplicate rule, but eh... + self.dev_block_handler.save() + self.auto_attach_handler.save() + self.required_devices_handler.save() + self.device_manager.load_data() diff --git a/qubes_config/global_config/device_blocks.py b/qubes_config/global_config/device_blocks.py new file mode 100644 index 00000000..7f7183e5 --- /dev/null +++ b/qubes_config/global_config/device_blocks.py @@ -0,0 +1,585 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2022 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . + +from typing import List, Optional, Iterator, Callable + +from qubesadmin.device_protocol import DeviceCategory, DeviceInterface + +from ..widgets.gtk_widgets import TokenName, VMListModeler +from .device_widgets import ( + DevPolicyDialogHandler, + DevPolicyRow, + DevicePolicyHandler, +) +import gi + +import qubesadmin.exc + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import gettext + +t = gettext.translation("desktop-linux-manager", fallback=True) +_ = t.gettext + + +class DevicePolicyDialog(DevPolicyDialogHandler): + """Handler for the Edit Blocks window.""" + + def __init__(self, builder: Gtk.Builder, qapp, parent_window: Gtk.Window): + super().__init__(builder, qapp, "device_policy", parent_window) + + self.new_label: Gtk.Label = builder.get_object( + "device_policy_new_label" + ) + + self.block_box: Gtk.Box = builder.get_object("device_policy_block_box") + self.qube_combo: Gtk.ComboBox = builder.get_object( + "device_policy_vm_combo" + ) + self.listbox: Gtk.ListBox = builder.get_object("device_policy_listbox") + + # setup qube modeler + self.qube_model = VMListModeler( + combobox=self.qube_combo, + qapp=self.qapp, + filter_function=lambda vm: vm.klass != "AdminVM", + current_value=None, + ) + self.qube_model.connect_change_callback(self._qube_selected) + + # setup treeview + self.device_category_model = DeviceCategoryModeler() + for row in self.device_category_model.get_rows(): + self.listbox.add(row) + row.check_box.connect("toggled", self.validate) + + self.other_row: Optional["DeviceCategoryRow"] = None + + self.listbox.connect("row-activated", self._row_activated) + + def _save_changes(self, *_args): + if not self.current_row: + self._cancel() + return + # save changed VM + self.current_row.vm_wrapper.vm = self.qube_model.get_selected() + + # save changed categories + new_categories = [] + for row in self.listbox.get_children(): + if isinstance(row, OtherCategoryRow): + continue + if row.check_box.get_active(): + if not row.parent or not row.parent.check_box.get_active(): + new_categories.append(row.category_wrapper) + + self.current_row.vm_wrapper.categories = new_categories + + super()._save_changes() + + def run_for_new(self, new_row_function: Callable) -> DevPolicyRow: + """Run for a new rule""" + self.dialog.set_title(_("New Device Attachment Block")) + self.clear() + return super().run_for_new(new_row_function) + + def run_for_existing(self, row: "DevPolicyRow"): + """Run for an existing rule""" + self.dialog.set_title("Edit Device Attachment Block") + super().run_for_existing(row) + self.clear() + self.qube_model.select_value(row.vm_wrapper.vm) + self.select_rows(row.vm_wrapper.categories) + + if row.vm_wrapper.other_interfaces: + self.other_row = OtherCategoryRow(row.vm_wrapper.other_interfaces) + self.listbox.add(self.other_row) + + def clear(self): + """Clear all selections and dropdowns""" + self.qube_model.clear_selection() + + if self.other_row: + self.listbox.remove(self.other_row) + self.other_row = None + + for row in self.listbox.get_children(): + row.check_box.set_active(False) + + def select_rows(self, categories: list["DeviceCategoryWrapper"]): + """Select rows matching provided categories""" + last_parent = None + for row in self.listbox.get_children(): + if last_parent == row.parent: + continue + if row.category_wrapper in categories: + row.check_box.set_active(True) + last_parent = row + + def _row_activated(self, _box, row, *_args): + row.row_activated() + + def check_validity(self) -> bool: + """Check if current state is save-able (in this case, a VM is + selected and at least one rule is selected).""" + if self.qube_model.get_selected(): + for row in self.listbox.get_children(): + if row.check_box.get_active(): + return True + return False + + def _qube_selected(self, *_args): + if self.qube_model.get_selected(): + self.new_label.set_visible(False) + self.block_box.set_sensitive(True) + else: + self.new_label.set_visible(True) + self.block_box.set_sensitive(False) + + self.validate() + + +class DeviceCategoryWrapper: + """This class represents a DeviceCategory with a nice description + wrapped around it.""" + + def __init__( + self, name: str, device_category: DeviceCategory, description: str = "" + ): + """ + :param name: readable name of the category + :param device_category: relevant DeviceCategory + :param description: optional description (will be shown in smaller font + beneath the main description) + """ + self.name = name + self.children: List[DeviceCategoryWrapper] = [] + self.device_category = device_category + self.description = description + self.interfaces = [ + DeviceInterface(s) for s in self.device_category.value + ] + + def readable_description(self): + """Nicely formatted category description, perfect to be placed in a + Gtk.Label's markup.""" + if self.description: + return self.name + "\n{}".format(self.description) + return self.name + + def matches( + self, interfaces: list[DeviceInterface] + ) -> (Optional)[list[DeviceInterface]]: + """ + If in the given list of string representations of interfaces there is a + complete match with my own category, return the matching list of + interfaces. + """ + if set(interfaces).issuperset(set(self.interfaces)): + return self.interfaces + return None + + def __eq__(self, other): + return self.device_category == getattr(other, "device_category", None) + + +class DeviceCategoryRow(Gtk.ListBoxRow): + """Gtk.ListBoxRow representing a block category""" + + def __init__( + self, + category_wrapper: DeviceCategoryWrapper, + parent: Optional["DeviceCategoryRow"], + ): + """ + :param category_wrapper: relevant DeviceCategoryWrapper + :param parent: parent row; if present, this row will be indented + relative to the parent and will 1. be selected when the parent is + selected 2. will deselect the parent if deselected + """ + super().__init__() + self.category_wrapper = category_wrapper + self.depth: int = parent.depth + 1 if parent else 0 + self.parent = parent + + self.main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.main_box.set_spacing(0) + self.main_box.set_margin_start(30 * self.depth) + + self.add(self.main_box) + + self.check_box: Gtk.CheckButton = Gtk.CheckButton() + self.main_box.add(self.check_box) + self.description_label = Gtk.Label() + self.description_label.set_markup( + self.category_wrapper.readable_description() + ) + self.main_box.add(self.description_label) + self.show_all() + + self.children: List[DeviceCategoryRow] = [] + self.check_box.connect("toggled", self._row_checked) + + def row_activated(self): + self.check_box.set_active(not self.check_box.get_active()) + + def _row_checked(self, *_args): + if not self.check_box.get_active() and self.parent: + self.parent.check_box.set_active(False) + for child in self.children: + child.check_box.set_active(self.check_box.get_active()) + + +class OtherCategoryRow(Gtk.ListBoxRow): + """Special row for the Other interfaces (listed as text and unclickable""" + + def __init__(self, interfaces: list[DeviceInterface]): + super().__init__() + self.other_interfaces: list[DeviceInterface] = interfaces + + self.main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.main_box.set_spacing(10) + + self.add(self.main_box) + + self.check_box = Gtk.CheckButton() + self.check_box.set_active(True) + self.main_box.add(self.check_box) + self.description_label = Gtk.Label() + self.description_label.set_markup(_("Other: ")) + self.main_box.add(self.description_label) + + self.text_box = Gtk.Entry() + self.main_box.add(self.text_box) + self.text_box.set_text( + ", ".join([repr(i) for i in self.other_interfaces]) + ) + self.set_tooltip_text(_("Block set in command line.")) + self.set_sensitive(False) + self.show_all() + + +class DeviceCategoryModeler: + """Helper class to manage our DeviceCategoryWrapper model""" + + def __init__(self): + self.root_device = DeviceCategoryWrapper( + "All devices", DeviceCategory.Other + ) + self.root_device.children.append( + DeviceCategoryWrapper( + _("Network devices"), + DeviceCategory.Network, + _("modems, WiFi and Ethernet adapters"), + ) + ) + + hid_devices = DeviceCategoryWrapper( + _("Human interface devices"), + DeviceCategory.Input, + _("all input devices, such as mice, keyboards, tablets etc."), + ) + self.root_device.children.append(hid_devices) + hid_devices.children.append( + DeviceCategoryWrapper(_("Keyboards"), DeviceCategory.Keyboard) + ) + hid_devices.children.append( + DeviceCategoryWrapper(_("Mice"), DeviceCategory.Mouse) + ) + + self.root_device.children.append( + DeviceCategoryWrapper(_("Printers"), DeviceCategory.Printer) + ) + + self.root_device.children.append( + DeviceCategoryWrapper( + _("Image input devices"), + DeviceCategory.Image_Input, + _("Scanners and cameras"), + ) + ) + + self.root_device.children.append( + DeviceCategoryWrapper( + _("Multimedia output devices"), + DeviceCategory.Multimedia_Output, + _("Displays and audio output devices"), + ) + ) + + audio_devices = DeviceCategoryWrapper( + _("Audio devices"), DeviceCategory.Audio + ) + self.root_device.children.append(audio_devices) + audio_devices.children.append( + DeviceCategoryWrapper(("Microphones"), DeviceCategory.Microphone) + ) + audio_devices.children.append( + DeviceCategoryWrapper( + _("Audio output devices"), DeviceCategory.Audio_Output + ) + ) + + storage_devices = DeviceCategoryWrapper( + _("Storage devices"), DeviceCategory.Storage + ) + self.root_device.children.append(storage_devices) + storage_devices.children.append( + DeviceCategoryWrapper( + _("Block devices"), DeviceCategory.Block_Storage + ) + ) + storage_devices.children.append( + DeviceCategoryWrapper( + _("USB storage devices"), + DeviceCategory.USB_Storage, + _( + "USB storage devices may offer more capabilities " + "than block storage devices.\nMost storage devices " + "can be attached as either block device or USB " + "device" + ), + ) + ) + + self.root_device.children.append( + DeviceCategoryWrapper(_("Bluetooth"), DeviceCategory.Bluetooth) + ) + + self.root_device.children.append( + DeviceCategoryWrapper( + _("Smart card readers"), DeviceCategory.Smart_Card_Readers + ) + ) + + def get_rows( + self, parent_row: DeviceCategoryRow | None = None + ) -> Iterator[DeviceCategoryRow]: + """Get relevant ListBoxRows""" + if not parent_row: + parent_row = DeviceCategoryRow(self.root_device, None) + yield parent_row + + for child in parent_row.category_wrapper.children: + row = DeviceCategoryRow(child, parent_row) + parent_row.children.append(row) + yield row + yield from self.get_rows(row) + + def parse_interfaces( + self, + interfaces: list[DeviceInterface], + categories: list[DeviceCategoryWrapper] | None = None, + node: DeviceCategoryWrapper | None = None, + ) -> tuple[list[DeviceCategoryWrapper], list[DeviceInterface]]: + """Turn a list of DeviceInterfaces into matching DeviceCategories and + a list of remaining interfaces""" + if categories is None: + categories = [] + if node is None: + node = self.root_device + + matched_interfaces = node.matches(interfaces) + + if matched_interfaces: + for m in matched_interfaces: + interfaces.remove(m) + categories.append(node) + return categories, interfaces + for child in node.children: + categories, interfaces = self.parse_interfaces( + interfaces, categories, child + ) + return categories, interfaces + + +class BlockPolicyWrapper: + """This class is a wrapper for a VM with its associated denied device + interfaces.""" + + def __init__( + self, + vm, + interfaces: List[DeviceInterface], + dev_cat_modeler: DeviceCategoryModeler, + ): + self.original_vm = vm + self.vm = vm + self.interfaces = interfaces + self.dev_cat_modeler = dev_cat_modeler + self.categories: list[DeviceCategoryWrapper] = [] + self.other_interfaces: list[DeviceInterface] = [] + self.update() + + def get_description(self) -> str: + """Get a nicely formatted (markup) description""" + int_descr = "" + if self.categories: + int_descr += ", ".join([cat.name for cat in self.categories]) + if self.other_interfaces: + if int_descr: + int_descr += ", " + int_descr += ", ".join([repr(i) for i in self.other_interfaces]) + return _("{} cannot be attached to ").format(int_descr) + + def update(self): + """Update matching categories.""" + self.categories.clear() + self.other_interfaces.clear() + self.categories, self.other_interfaces = ( + self.dev_cat_modeler.parse_interfaces(self.interfaces.copy()) + ) + + def save(self): + """Save changes""" + new_interfaces = [] + for c in self.categories: + new_interfaces.extend(c.interfaces) + new_interfaces.extend(self.other_interfaces) + + # remove old, make new + if self.original_vm: + # removing old only makes sense if they existed in the first place + if self.original_vm != self.vm: + self.remove() + else: + # remove only the missing ones + for interface in self.interfaces: + if interface not in new_interfaces: + self.original_vm.devices.allow(interface) + + # make new blocks + if self.original_vm != self.vm: + # all of them + for interface in new_interfaces: + self.vm.devices.deny(interface) + else: + for interface in new_interfaces: + if ( + interface not in self.interfaces + and interface not in self.other_interfaces + ): + self.vm.devices.deny(interface) + + self.interfaces = new_interfaces + + def remove(self): + """Remove all existing blocks""" + for interface in self.interfaces: + self.original_vm.devices.allow(interface) + + +class BlockPolicyRow(DevPolicyRow): + """ListBoxRow representing a vm with its associated blocks""" + + def __init__(self, vm_wrapper: BlockPolicyWrapper, qapp: qubesadmin.Qubes): + super().__init__() + self.vm_wrapper = vm_wrapper + self.qapp = qapp + + self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self.add(self.box) + + self.label = Gtk.Label() + self.box.add(self.label) + self.vm_token: TokenName | None = None + + self.update() + + def description(self): + """Nicely readable description, for use in unsaved changes (for + example)""" + return self.vm_wrapper.get_description() + self.vm_wrapper.vm.name + + def update(self): + """Update displayed state.""" + if self.vm_token: + self.box.remove(self.vm_token) + self.vm_token = None + + if self.vm_wrapper.vm: + self.vm_token = TokenName(self.vm_wrapper.vm, self.qapp) + self.box.add(self.vm_token) + self.box.show_all() + else: + self.vm_token = None + + self.label.set_markup(self.vm_wrapper.get_description()) + + if self.vm_wrapper.vm: + # only show all if it's in any way a sensible row + self.show_all() + + def save(self): + """Save changes in this row.""" + if not self.changed: + return + self.vm_wrapper.save() + + super().save() + + def remove_self(self): + """Remove all blocks from the system""" + self.vm_wrapper.remove() + + def __str__(self): + return self.vm_wrapper.get_description() + self.vm_wrapper.vm.name + + +class DeviceBlockHandler(DevicePolicyHandler): + """Handler class for all interface blocks""" + + def __init__( + self, + qapp: qubesadmin.Qubes, + builder: Gtk.Builder, + device_policy_manager, + ): + self.device_category_modeler = DeviceCategoryModeler() + super().__init__( + prefix="devices_policy", + builder=builder, + qapp=qapp, + device_policy_manager=device_policy_manager, + edit_dialog_class=DevPolicyDialogHandler, + ) + + def load_current_state(self): + """Load state from system""" + rules = [] + for vm, interfaces in self.device_policy_manager.get_blocks(): + rules.append( + BlockPolicyWrapper(vm, interfaces, self.device_category_modeler) + ) + + for rule in rules: + self.rule_list.add(BlockPolicyRow(rule, self.qapp)) + + def create_new_row(self) -> BlockPolicyRow: + blank_rule = BlockPolicyWrapper(None, [], self.device_category_modeler) + new_row = BlockPolicyRow(blank_rule, self.qapp) + return new_row + + def get_edit_dialog(self, builder) -> DevPolicyDialogHandler: + return DevicePolicyDialog( + builder=builder, qapp=self.qapp, parent_window=self.main_window + ) diff --git a/qubes_config/global_config/device_widgets.py b/qubes_config/global_config/device_widgets.py new file mode 100644 index 00000000..0ea667f9 --- /dev/null +++ b/qubes_config/global_config/device_widgets.py @@ -0,0 +1,323 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2022 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . +""" +Widgets relevant to Device Attachments page. +""" +import abc +from typing import List, Any, Callable + +import gi + +from ..widgets.gtk_utils import ask_question + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +import gettext + +t = gettext.translation("desktop-linux-manager", fallback=True) +_ = t.gettext + + +class HeaderComboModeler: + """A model handler for a ComboBox with a list with headers (insensitive + and unselectable)""" + + def __init__( + self, + combobox: Gtk.ComboBox, + values: dict[str, tuple[str, Any]], + ): + """ + A helper for modeling ComboBox contents that consist of a list with + headers (headers are not sensitive). + :param combobox: A Gtk.ComboBox or ComboBoxText to be filled + :param values: a dictionary that maps value_id (str) to a tuple of + nicely presented value (str) and the actual underlying value or None, + if it's a header + """ + self._combo: Gtk.ComboBox = combobox + self._combo.clear() + self._values: dict[str, tuple[str, Any]] = values + + self._model = Gtk.ListStore(str, str, bool) # id, text and sensitivity + self._fill_model() + + renderer = Gtk.CellRendererText() + self._combo.set_model(self._model) + self._combo.set_id_column(0) + self._combo.pack_start(renderer, True) + self._combo.add_attribute(renderer, "text", 1) + self._combo.add_attribute(renderer, "sensitive", 2) + + def get_selected(self): + """Get currently selected value.""" + active_id = self._combo.get_active_id() + result_tuple = self._values.get(active_id, None) + if result_tuple: + return result_tuple[1] + return None + + def _fill_model(self): + self._model.clear() + for device_id, value in self._values.items(): + description, device = value + self._model.append([device_id, description, bool(device)]) + + def select_value(self, selected_value: Any): + """Select provided value, if available.""" + self._fill_model() + + for key, value in self._values.items(): + if value[1] == selected_value: + self._combo.set_active_id(key) + + +class DevPolicyRow(Gtk.ListBoxRow): + """Base class for ListBoxRows representing various policy rules on the + Device Assignments page. + This is not an abstract class because Gtk.ListBoxRow already inherits + from an abstract class.""" + + def __init__(self): + super().__init__() + self.changed = False + self.valid = True + + @abc.abstractmethod + def update(self): + """Update displayed state""" + + @abc.abstractmethod + def save(self): + """Save changes to the system""" + self.changed = False + + @abc.abstractmethod + def remove_self(self): + """ + This should remove the current assignment or policy from the system. + """ + + +class DevPolicyDialogHandler: + """ + A generic class for various Edit Device Policy row dialogs. + + Every edit dialog needs: + - {prefix}_dialog - a Gtk.Dialog + - {prefix}_ok_button - a Gtk.Button + - {prefix}_cancel_button - a Gtk.Button + """ + + def __init__( + self, builder: Gtk.Builder, qapp, prefix: str, parent_window: Gtk.Window + ): + self.qapp = qapp + + self.dialog: Gtk.Dialog = builder.get_object(f"{prefix}_dialog") + self.dialog.set_transient_for(parent_window) + + # ok and cancel buttons + self.ok_button: Gtk.Button = builder.get_object(f"{prefix}_ok_button") + self.cancel_button: Gtk.Button = builder.get_object( + f"{prefix}_cancel_button" + ) + + self.current_row: Gtk.ListBoxRow | None = None + self.remove_on_cancel = False + + self.dialog.connect("delete-event", self._cancel) + self.ok_button.connect("clicked", self._save_changes) + self.cancel_button.connect("clicked", self._cancel) + + def _cancel(self, *_args): + if self.remove_on_cancel and self.current_row: + self.current_row.get_parent().remove(self.current_row) + self.current_row = None + self.remove_on_cancel = False + self.dialog.hide() + # return True to stop other handlers from destroying the dialog + return True + + def _save_changes(self, *_args): + if not self.current_row: + self._cancel() + return + self.current_row.update() + self.current_row.changed = True + parent = self.current_row.get_parent() + + self.current_row = None + self.remove_on_cancel = False + + self.dialog.hide() + parent.emit("rules-changed", None) + + def run_for_new(self, new_row_function: Callable) -> DevPolicyRow: + """Run for a new row""" + new_row = new_row_function() + self.current_row = new_row + self.remove_on_cancel = True + + self.dialog.show() + + return new_row + + def run_for_existing(self, row: DevPolicyRow): + """Run for an existing row""" + self.current_row = row + self.validate() + self.remove_on_cancel = False + self.dialog.show() + + def validate(self, *_args): + """Connect this function to any events that should trigger + re-checking for dialog validity.""" + self.ok_button.set_sensitive(self.check_validity()) + + def check_validity(self) -> bool: + """Return True if the current dialog state should allow saving, + False otherwise.""" + return True + + +class DevicePolicyHandler: + """Generic handler for DeviceAssignments""" + + def __init__( + self, + prefix: str, + builder: Gtk.Builder, + qapp, + device_policy_manager, + edit_dialog_class, + ): + self.qapp = qapp + self.device_policy_manager = device_policy_manager + self.edit_dialog_class = edit_dialog_class + + self.main_window: Gtk.Window = builder.get_object("main_window") + + self.rule_list: Gtk.ListBox = builder.get_object(f"{prefix}_list") + + self.add_button: Gtk.Button = builder.get_object( + f"{prefix}_add_rule_button" + ) + self.edit_button: Gtk.Button = builder.get_object( + f"{prefix}_edit_rule_button" + ) + self.remove_button: Gtk.Button = builder.get_object( + f"{prefix}_del_rule_button" + ) + + self.add_button.connect("clicked", self.add_new_rule) + self.edit_button.connect("clicked", self.edit_rule) + self.remove_button.connect("clicked", self.remove_rule) + self.rule_list.connect("row-selected", self._row_selected) + self.rule_list.connect("row-activated", self.edit_rule) + + self.edit_button.set_sensitive(False) + self.remove_button.set_sensitive(False) + + self.removed_rows: List[DevPolicyRow] = [] + + self.edit_dialog = self.get_edit_dialog(builder) + + self.load_current_state() + + def _row_selected(self, *_args): + row = self.rule_list.get_selected_row() + if row: + self.edit_button.set_sensitive(row.valid) + self.remove_button.set_sensitive(True) + else: + self.edit_button.set_sensitive(False) + self.remove_button.set_sensitive(False) + + def add_new_rule(self, *_args): + """Add a new row to the list""" + new_row = self.edit_dialog.run_for_new(self.create_new_row) + self.rule_list.add(new_row) + + def edit_rule(self, *_args): + """Edit currently selected rule""" + current_row = self.rule_list.get_selected_row() + self.edit_dialog.run_for_existing(current_row) + + def remove_rule(self, *_args): + current_row = self.rule_list.get_selected_row() + response = ask_question( + self.main_window, + "Delete rule", + _("Are you sure you want to delete rule:\n") + str(current_row), + ) + if response == Gtk.ResponseType.NO: + return + + self.removed_rows.append(current_row) + self.rule_list.remove(current_row) + self.rule_list.emit("rules-changed", None) + + def save(self) -> None: + """Save all changes""" + # new and existing + for row in self.rule_list.get_children(): + row.save() + + # removed + for row in self.removed_rows: + row.remove_self() + + self.removed_rows.clear() + self.device_policy_manager.load_data() + + def reset(self) -> None: + """Reset all changes.""" + for row in self.rule_list.get_children(): + self.rule_list.remove(row) + self.removed_rows.clear() + self.load_current_state() + + def get_unsaved(self) -> str: + """ + Get all unsaved changes. + """ + results = [] + for row in self.rule_list.get_children(): + if row.changed: + results.append(_("Attachment changed: ") + str(row)) + + for row in self.removed_rows: + results.append(_("Removed assignment: ") + str(row)) + + return "\n".join(results) + + @abc.abstractmethod + def create_new_row(self) -> DevPolicyRow: + """This function should return a blank DevPolicyRow.""" + + def load_current_state(self): + """This function should load current system state.""" + + @abc.abstractmethod + def get_edit_dialog(self, builder: Gtk.Builder) -> DevPolicyDialogHandler: + """this function should get a DevPolicyDialogHandler for an + appropriate edit dialog.""" diff --git a/qubes_config/global_config/global_config.py b/qubes_config/global_config/global_config.py index b3b76789..c8a2ffae 100644 --- a/qubes_config/global_config/global_config.py +++ b/qubes_config/global_config/global_config.py @@ -53,6 +53,7 @@ from .basics_handler import BasicSettingsHandler, FeatureHandler from .policy_exceptions_handler import DispvmExceptionHandler from .thisdevice_handler import ThisDeviceHandler +from .device_attachments import DevAttachmentHandler import gi @@ -83,6 +84,9 @@ "clipboard_policy", "filecopy_policy", "open_in_vm", + "attachment_policy", + "auto_attachment", + "required_devices", ] @@ -438,6 +442,11 @@ def perform_setup(self): ) self.progress_bar_dialog.update_progress(page_progress) + self.handlers["attachments"] = DevAttachmentHandler( + self.qapp, self.builder + ) + self.progress_bar_dialog.update_progress(page_progress) + self.handlers["splitgpg"] = VMSubsetPolicyHandler( qapp=self.qapp, gtk_builder=self.builder, @@ -548,7 +557,7 @@ def load_icons(self): icon_dict = { "settings_tab_icon": "settings-", "usb_tab_icon": "usb-", - # "devices_tab_icon": "devices-", + "devices_tab_icon": "devices-", "updates_tab_icon": "qui-updates-", "splitgpg_tab_icon": "key-", "clipboard_tab_icon": "qui-clipboard-", diff --git a/qubes_config/global_config/vm_flowbox.py b/qubes_config/global_config/vm_flowbox.py index 3af5939d..73741937 100644 --- a/qubes_config/global_config/vm_flowbox.py +++ b/qubes_config/global_config/vm_flowbox.py @@ -79,7 +79,9 @@ def __init__(self, vm: qubesadmin.vm.QubesVM): def _remove_self(self, *_args): response = ask_question( - self, _("Delete"), _("Are you sure you want to remove this qube?") + self, + _("Delete"), + _("Are you sure you want to remove this qube from the list?"), ) if response == Gtk.ResponseType.NO: return @@ -123,6 +125,7 @@ def __init__( """ self.qapp = qapp self.verification_callback = verification_callback + self._change_callback: Callable | None = None self.flowbox: Gtk.FlowBox = gtk_builder.get_object(f"{prefix}_flowbox") self.box: Gtk.Box = gtk_builder.get_object(f"{prefix}_box") @@ -176,9 +179,13 @@ def _add_confirm_clicked(self, _widget): return self.flowbox.add(VMFlowBoxButton(select_vm)) self.placeholder.set_visible(False) + if self._change_callback: + self._change_callback() def _vm_removed(self, *_args): self.placeholder.set_visible(not bool(self.selected_vms)) + if self._change_callback: + self._change_callback() def set_visible(self, state: bool): """Set flowbox to visible/usable.""" @@ -189,6 +196,9 @@ def add_selected_vm(self, vm): Add a vm to selected vms. """ self.flowbox.add(VMFlowBoxButton(vm)) + self.placeholder.set_visible(False) + if self._change_callback: + self._change_callback() @property def selected_vms(self) -> List[qubesadmin.vm.QubesVM]: @@ -229,7 +239,17 @@ def reset(self): self.placeholder.set_visible(not bool(self.selected_vms)) def clear(self): - """Remove all selected qubes""" + """Remove all selected qubes and clear selected VM""" for child in self.flowbox.get_children(): if isinstance(child, VMFlowBoxButton): self.flowbox.remove(child) + self._vm_removed() + self.add_qube_model.clear_selection() + + def set_sensitive(self, state: bool): + self.flowbox.set_sensitive(state) + self.add_button.set_sensitive(state) + self.qube_combo.set_sensitive(state) + + def connect_change_callback(self, func: Callable): + self._change_callback = func diff --git a/qubes_config/qubes-global-config-base.css b/qubes_config/qubes-global-config-base.css index a3c598cd..c19626a0 100644 --- a/qubes_config/qubes-global-config-base.css +++ b/qubes_config/qubes-global-config-base.css @@ -145,11 +145,23 @@ separator { padding-bottom: 5px; } +.alternate_rows row:nth-child(even) { + background: @alt-row; +} + +.alternate_rows row:selected { + background: @qubes-blue; +} + .permission_row { background: @top-background-2; padding:5px; } +.error_row { + background: @problem-background; +} + .permission_row:hover { background: @qubes-blue; } @@ -255,3 +267,19 @@ separator { .info_grid { margin: 20px; } + +.add_rule_box { + padding: 10px 10px 10px 10px; + margin: 5px 0px 5px 0px; + background: white; + border-width: 2px; + border-style: solid; + border-radius: 4px; + border-color: @dark-gray; +} + +.error_label { + color: @red-label; + font-style: italic; + font-weight: bold; +} \ No newline at end of file diff --git a/qubes_config/tests/conftest.py b/qubes_config/tests/conftest.py index ef88df3c..a83e5117 100644 --- a/qubes_config/tests/conftest.py +++ b/qubes_config/tests/conftest.py @@ -19,6 +19,9 @@ # with this program; if not, see . """Conftest helper pytest file: fixtures container here are reachable by all tests""" +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name + import pytest import importlib.resources import subprocess @@ -33,19 +36,27 @@ from ..global_config.policy_manager import PolicyManager from ..new_qube.new_qube_app import CreateNewQube -from qubesadmin.tests.mock_app import MockQubesComplete, MockQubes, \ - MockQubesWhonix, QubesTestWrapper, GLOBAL_PROPERTIES - +from qubesadmin.tests.mock_app import ( + MockQubesComplete, + MockQube, + MockQubes, + MockQubesWhonix, + QubesTestWrapper, + GLOBAL_PROPERTIES, + MockDevice, +) @pytest.fixture def test_qapp(): test_qapp = MockQubesComplete() - test_qapp._qubes['dom0'].features['gui-default-secure-copy-sequence'] = None - test_qapp._qubes['sys-usb'].features[ - 'supported-feature.keyboard-layout'] = '1' + test_qapp._qubes["dom0"].features["gui-default-secure-copy-sequence"] = None + test_qapp._qubes["sys-usb"].features[ + "supported-feature.keyboard-layout" + ] = "1" test_qapp.update_vm_calls() return test_qapp + @pytest.fixture def test_qapp_simple(): test_qapp_simple = MockQubes() @@ -66,11 +77,11 @@ def test_qapp_broken(): # pylint: disable=redefined-outer-name qapp._global_properties = GLOBAL_PROPERTIES.copy() - qapp.set_global_property('clockvm', "") - qapp.set_global_property('default_dispvm', "") - qapp.set_global_property('default_netvm', "") - qapp.set_global_property('default_template', "") - qapp.set_global_property('updatevm', "") + qapp.set_global_property("clockvm", "") + qapp.set_global_property("default_dispvm", "") + qapp.set_global_property("default_netvm", "") + qapp.set_global_property("default_template", "") + qapp.set_global_property("updatevm", "") qapp.update_global_properties() qapp.update_vm_calls() @@ -78,6 +89,136 @@ def test_qapp_broken(): # pylint: disable=redefined-outer-name return qapp +@pytest.fixture +def test_qapp_devices(): + test_qapp_devices = MockQubesComplete() + + test_qapp_devices._qubes["test-dev"] = MockQube( + name="test-dev", + qapp=test_qapp_devices, + label="purple", + devices_denied="m******", + ) + test_qapp_devices._qubes["test-dev2"] = MockQube( + name="test-dev2", + qapp=test_qapp_devices, + label="green", + devices_denied="p02****u02****p07****p123***", + ) + + test_qapp_devices.update_vm_calls() + + # add some PCI devices + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="pci", + device_id="0x8086:0x51f0::p028000", + product="Network Card", + vendor="unknown", + backend_vm="dom0", + assigned=[("sys-net", "required", None)], + port="0c.0", + ) + ) + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="pci", + product="USB Controller", + backend_vm="dom0", + assigned=[("sys-net", "required", None)], + device_id="0x8086:0x461e::p0c0330", + port="0d.0", + vendor="ACME", + ) + ) + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="pci", + device_id="0x8086:0x51f0::p300000", + product="Piano Dropper", + vendor="ACME", + backend_vm="dom0", + port="0f.0", + ) + ) + # and one assigned with no-strict-reset + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="pci", + device_id="0x8086:0x51c8::p040300", + product="Whole Symphonic Orchestra", + vendor="Berlin Philharmonie", + backend_vm="dom0", + assigned=[("test-red", "required", ["no-strict-reset"])], + port="0h.0", + ) + ) + + # add two pre-assigned devices + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="usb", + product="Anvil", + vendor="ACME", + backend_vm="sys-usb", + assigned=[ + ("test-vm", "ask-to-attach", None), + ("test-red", "ask-to-attach", None), + ], + device_id="3:4:b011010", + port="2-22", + ) + ) + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="usb", + product="Hammer", + vendor="ACME", + backend_vm="sys-usb", + assigned=[("test-vm", "auto-attach", None)], + device_id="1:2:u011010", + port="2-23", + ) + ) + + # add an unassigned block device + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="block", + product="Ouroboros", + vendor="ACME", + backend_vm="sys-usb", + device_id="444:888:b123422", + port="sda", + ) + ) + + test_qapp_devices.update_vm_calls() + + # an assignment for a currently not-connected device + + call = ("test-vm", "admin.vm.device.usb.Assigned", None, None) + assignment_string = ( + "sys-usb+2-30 device_id='0:0007:u01101' " + "port_id='2-30' devclass='usb' " + "backend_domain='sys-usb' mode='ask-to-attach' " + "frontend_domain='test-vm'\n" + ).encode() + current_response = test_qapp_devices.expected_calls[call] + test_qapp_devices.expected_calls[call] = ( + current_response + assignment_string + ) + + return test_qapp_devices + + @pytest.fixture def test_builder(): """Test gtk_builder with loaded test glade file and registered signals.""" diff --git a/qubes_config/tests/test_device_attachments.py b/qubes_config/tests/test_device_attachments.py new file mode 100644 index 00000000..368781c7 --- /dev/null +++ b/qubes_config/tests/test_device_attachments.py @@ -0,0 +1,1326 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2025 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . +# pylint: disable=missing-module-docstring +# pylint: disable=missing-function-docstring +# pylint: disable=missing-class-docstring +# pylint: disable=protected-access +from unittest.mock import patch + +import pytest + +from qubesadmin.tests.mock_app import MockDevice +from ..global_config.device_attachments import AutoDeviceDialog, DeviceManager +from ..global_config.device_attachments import ( + AttachmentHandler, + RequiredDeviceDialog, +) + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +### AUTO ATTACH HANDLER TESTS + +AUTO_CLASSES = ["block", "mic", "usb"] +PCI_CLASSES = ["block", "pci"] + + +# pylint fails to correctly react to fixtures +# pylint: disable=redefined-outer-name +@pytest.fixture +def auto_attach_handler(real_builder, test_qapp_devices): + dev_policy_manager = DeviceManager(test_qapp_devices) + dev_policy_manager.load_data() + + handler = AttachmentHandler( + qapp=test_qapp_devices, + builder=real_builder, + device_policy_manager=dev_policy_manager, + classes=AUTO_CLASSES, + edit_dialog_class=AutoDeviceDialog, + prefix="devices_auto", + ) + return handler + + +@pytest.fixture +def required_handler(real_builder, test_qapp_devices): + dev_policy_manager = DeviceManager(test_qapp_devices) + dev_policy_manager.load_data() + + handler = AttachmentHandler( + qapp=test_qapp_devices, + builder=real_builder, + device_policy_manager=dev_policy_manager, + classes=PCI_CLASSES, + edit_dialog_class=RequiredDeviceDialog, + prefix="devices_required", + ) + return handler + + +def test_auto_attach_init(auto_attach_handler): + assert len(auto_attach_handler.rule_list.get_children()) == 3 + + +def test_auto_attach_edit_dialog(auto_attach_handler): + # test if the edit dialog works + qapp = auto_attach_handler.qapp + + existing_rows = auto_attach_handler.rule_list.get_children() + assert len(existing_rows) == 3 + + auto_attach_handler.add_button.clicked() + + assert auto_attach_handler.edit_dialog.dev_modeler.get_selected() is None + assert ( + "Internal Mic" + not in auto_attach_handler.edit_dialog.devident_label.get_text() + ) + + auto_attach_handler.edit_dialog.dev_combo.set_active_id("dom0:mic::m000000") + assert ( + auto_attach_handler.edit_dialog.dev_modeler.get_selected() is not None + ) + assert ( + "Internal Mic" + in auto_attach_handler.edit_dialog.devident_label.get_text() + ) + assert "dom0:mic" in auto_attach_handler.edit_dialog.port_check.get_label() + + auto_attach_handler.edit_dialog.dev_combo.set_active_id("1:2:u011010") + assert ( + auto_attach_handler.edit_dialog.dev_modeler.get_selected() is not None + ) + assert "Hammer" in auto_attach_handler.edit_dialog.devident_label.get_text() + assert ( + "sys-usb:2-23" in auto_attach_handler.edit_dialog.port_check.get_label() + ) + + auto_attach_handler.edit_dialog.auto_radio.set_active(True) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + + auto_attach_handler.edit_dialog.ok_button.clicked() + + new_rows = auto_attach_handler.rule_list.get_children() + assert len(new_rows) == len(existing_rows) + 1 + + diff_rows = set(new_rows) - set(existing_rows) + assert len(diff_rows) == 1 + new_row = diff_rows.pop() + assert "Hammer" in new_row.dev_label.get_text() + assert "will attach automatically" in new_row.action_label.get_text() + assert len(new_row.vm_box.get_children()) == 1 + + expected_call = ( + "test-vm", + "admin.vm.device.usb.Assign", + "sys-usb+*:1:2:u011010", + b"device_id='1:2:u011010' port_id='*' devclass='usb' " + b"backend_domain='sys-usb' mode='auto-attach'" + b" frontend_domain='test-vm'", + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + auto_attach_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_auto_attach_dialog_port_two_vms(auto_attach_handler): + qapp = auto_attach_handler.qapp + auto_attach_handler.add_button.clicked() + auto_attach_handler.edit_dialog.dev_combo.set_active_id("1:2:u011010") + auto_attach_handler.edit_dialog.devident_check.set_active(False) + auto_attach_handler.edit_dialog.port_check.set_active(True) + auto_attach_handler.edit_dialog.ask_radio.set_active(True) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-red"] + ) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-blue"] + ) + auto_attach_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-vm", + "admin.vm.device.usb.Assign", + "sys-usb+2-23:*", + b"device_id='*' port_id='2-23' devclass='usb' " + b"backend_domain='sys-usb' mode='ask-to-attach' " + b"frontend_domain='test-vm'", + ), + ( + "test-red", + "admin.vm.device.usb.Assign", + "sys-usb+2-23:*", + b"device_id='*' port_id='2-23' devclass='usb' " + b"backend_domain='sys-usb' mode='ask-to-attach' " + b"frontend_domain='test-red'", + ), + ( + "test-blue", + "admin.vm.device.usb.Assign", + "sys-usb+2-23:*", + b"device_id='*' port_id='2-23' devclass='usb' " + b"backend_domain='sys-usb' mode='ask-to-attach' " + b"frontend_domain='test-blue'", + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + auto_attach_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_edit_rule_ident(auto_attach_handler): + qapp = auto_attach_handler.qapp + # change device and port checking, leave the rest the same + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + auto_attach_handler.edit_button.clicked() + assert "Hammer" in auto_attach_handler.edit_dialog.devident_label.get_text() + assert auto_attach_handler.edit_dialog.devident_check.get_active() + assert auto_attach_handler.edit_dialog.port_check.get_active() + assert auto_attach_handler.edit_dialog.auto_radio.get_active() + assert auto_attach_handler.edit_dialog.qube_handler.selected_vms == [ + qapp.domains["test-vm"] + ] + + auto_attach_handler.edit_dialog.dev_combo.set_active_id("dom0:mic::m000000") + auto_attach_handler.edit_dialog.devident_check.set_active(True) + auto_attach_handler.edit_dialog.port_check.set_active(False) + + auto_attach_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-vm", + "admin.vm.device.mic.Assign", + "dom0+*:dom0:mic::m000000", + b"device_id='dom0:mic::m000000' port_id='*' devclass='mic' " + b"backend_domain='dom0' mode='auto-attach' " + b"frontend_domain='test-vm'", + ), + ( + "test-vm", + "admin.vm.device.usb.Unassign", + "sys-usb+2-23:1:2:u011010", + None, + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + auto_attach_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_edit_rule_action(auto_attach_handler): + qapp = auto_attach_handler.qapp + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + auto_attach_handler.edit_button.clicked() + + auto_attach_handler.edit_dialog.ask_radio.set_active(True) + + auto_attach_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-vm", + "admin.vm.device.usb.Assign", + "sys-usb+2-23:1:2:u011010", + b"device_id='1:2:u011010' port_id='2-23' devclass='usb' " + b"backend_domain='sys-usb' mode='ask-to-attach' " + b"frontend_domain='test-vm'", + ), + ( + "test-vm", + "admin.vm.device.usb.Unassign", + "sys-usb+2-23:1:2:u011010", + None, + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + auto_attach_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_edit_rule_vms(auto_attach_handler): + qapp = auto_attach_handler.qapp + + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + auto_attach_handler.edit_button.clicked() + + auto_attach_handler.edit_dialog.qube_handler.clear() + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-red"] + ) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-blue"] + ) + auto_attach_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-red", + "admin.vm.device.usb.Assign", + "sys-usb+2-23:1:2:u011010", + b"device_id='1:2:u011010' port_id='2-23' devclass='usb' " + b"backend_domain='sys-usb' mode='auto-attach' " + b"frontend_domain='test-red'", + ), + ( + "test-blue", + "admin.vm.device.usb.Assign", + "sys-usb+2-23:1:2:u011010", + b"device_id='1:2:u011010' port_id='2-23' devclass='usb' " + b"backend_domain='sys-usb' mode='auto-attach' " + b"frontend_domain='test-blue'", + ), + ( + "test-vm", + "admin.vm.device.usb.Unassign", + "sys-usb+2-23:1:2:u011010", + None, + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + auto_attach_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_edit_rule_unknown_opt(real_builder, test_qapp_devices): + # attachment with both port and device as * + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="usb", + product="Strange", + vendor="ACME", + backend_vm="sys-usb", + assigned=[("test-vm", "auto-attach", ["misc_opt"])], + device_id="1:3:u011010", + port="2-24", + ) + ) + test_qapp_devices.update_vm_calls() + + dev_policy_manager = DeviceManager(test_qapp_devices) + dev_policy_manager.load_data() + + handler = AttachmentHandler( + qapp=test_qapp_devices, + builder=real_builder, + device_policy_manager=dev_policy_manager, + classes=AUTO_CLASSES, + edit_dialog_class=AutoDeviceDialog, + prefix="devices_auto", + ) + assert isinstance(handler.edit_dialog, AutoDeviceDialog) + for row in handler.rule_list.get_children(): + if "Strange" in row.dev_label.get_text(): + handler.rule_list.select_row(row) + break + else: + assert False + + handler.edit_button.clicked() + + handler.edit_dialog.qube_handler.clear() + handler.edit_dialog.qube_handler.add_selected_vm( + test_qapp_devices.domains["test-red"] + ) + handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-red", + "admin.vm.device.usb.Assign", + "sys-usb+2-24:1:3:u011010", + b"device_id='1:3:u011010' port_id='2-24' devclass='usb' " + b"backend_domain='sys-usb' mode='auto-attach' " + b"frontend_domain='test-red' _misc_opt='True'", + ), + ( + "test-vm", + "admin.vm.device.usb.Unassign", + "sys-usb+2-24:1:3:u011010", + None, + ), + ] + for call in expected_calls: + assert call not in test_qapp_devices.actual_calls + test_qapp_devices.expected_calls[call] = b"0\x00" + + handler.save() + + for call in expected_calls: + assert call in test_qapp_devices.actual_calls + + +def test_remove_rule(auto_attach_handler): + qapp = auto_attach_handler.qapp + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + auto_attach_handler.remove_button.clicked() + + expected_call = ( + "test-vm", + "admin.vm.device.usb.Unassign", + "sys-usb+2-23:1:2:u011010", + None, + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + auto_attach_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_auto_attach_buttons(auto_attach_handler): + assert auto_attach_handler.add_button.get_sensitive() + assert not auto_attach_handler.edit_button.get_sensitive() + assert not auto_attach_handler.remove_button.get_sensitive() + + for row in auto_attach_handler.rule_list.get_children(): + auto_attach_handler.rule_list.select_row(row) + break + + assert auto_attach_handler.add_button.get_sensitive() + assert auto_attach_handler.edit_button.get_sensitive() + assert auto_attach_handler.remove_button.get_sensitive() + + auto_attach_handler.rule_list.select_row(None) + assert auto_attach_handler.add_button.get_sensitive() + assert not auto_attach_handler.edit_button.get_sensitive() + assert not auto_attach_handler.remove_button.get_sensitive() + + +def test_auto_attach_noop(auto_attach_handler): + for row in auto_attach_handler.rule_list.get_children(): + auto_attach_handler.rule_list.select_row(row) + break + + auto_attach_handler.edit_button.clicked() + auto_attach_handler.edit_dialog.ok_button.clicked() + + auto_attach_handler.save() + + +def test_auto_attach_device_unavailable(auto_attach_handler): + qapp = auto_attach_handler.qapp + + for row in auto_attach_handler.rule_list.get_children(): + # TODO: fix when prbartman fixes saving device identity + if "?***" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + # this only tests removing the rule + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + auto_attach_handler.remove_button.clicked() + + expected_call = ( + "test-vm", + "admin.vm.device.usb.Unassign", + "sys-usb+2-30:0:0007:u01101", + None, + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + auto_attach_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_auto_attach_validity(auto_attach_handler): + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + auto_attach_handler.edit_button.clicked() + + assert auto_attach_handler.edit_dialog.ok_button.get_sensitive() + + auto_attach_handler.edit_dialog.port_check.set_active(False) + auto_attach_handler.edit_dialog.devident_check.set_active(False) + + assert not auto_attach_handler.edit_dialog.ok_button.get_sensitive() + + auto_attach_handler.edit_dialog.port_check.set_active(True) + + assert auto_attach_handler.edit_dialog.ok_button.get_sensitive() + + auto_attach_handler.edit_dialog.qube_handler.clear() + + assert not auto_attach_handler.edit_dialog.ok_button.get_sensitive() + + +def test_auto_attach_get_unsaved(auto_attach_handler): + assert auto_attach_handler.get_unsaved() == "" + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + auto_attach_handler.remove_button.clicked() + + for row in auto_attach_handler.rule_list.get_children(): + if "Anvil" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + assert auto_attach_handler.edit_button.get_sensitive() + auto_attach_handler.edit_button.clicked() + auto_attach_handler.edit_dialog.port_check.set_active(False) + auto_attach_handler.edit_dialog.ok_button.clicked() + + assert "Hammer" in auto_attach_handler.get_unsaved() + assert "Anvil" in auto_attach_handler.get_unsaved() + + +def test_auto_attach_reset(auto_attach_handler): + qapp = auto_attach_handler.qapp + + assert auto_attach_handler.get_unsaved() == "" + for row in auto_attach_handler.rule_list.get_children(): + if "Hammer" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + auto_attach_handler.remove_button.clicked() + + for row in auto_attach_handler.rule_list.get_children(): + if "Anvil" in row.dev_label.get_text(): + auto_attach_handler.rule_list.select_row(row) + break + else: + assert False + + auto_attach_handler.edit_button.clicked() + auto_attach_handler.edit_dialog.port_check.set_active(False) + auto_attach_handler.edit_dialog.ok_button.clicked() + + auto_attach_handler.add_button.clicked() + auto_attach_handler.edit_dialog.dev_combo.set_active_id("1:2:u011010") + auto_attach_handler.edit_dialog.devident_check.set_active(False) + auto_attach_handler.edit_dialog.port_check.set_active(True) + auto_attach_handler.edit_dialog.ask_radio.set_active(True) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + auto_attach_handler.edit_dialog.ok_button.clicked() + + assert auto_attach_handler.get_unsaved() != "" + + auto_attach_handler.reset() + assert auto_attach_handler.get_unsaved() == "" + # nothing should be called here + auto_attach_handler.save() + + +def test_auto_attach_add_cancel(auto_attach_handler): + # no ghosts after cancelling adding a rule + assert len(auto_attach_handler.rule_list.get_children()) == 3 + + auto_attach_handler.add_button.clicked() + auto_attach_handler.edit_dialog.cancel_button.clicked() + + assert len(auto_attach_handler.rule_list.get_children()) == 3 + + +def test_auto_attach_block_read_only(auto_attach_handler): + qapp = auto_attach_handler.qapp + auto_attach_handler.add_button.clicked() + auto_attach_handler.edit_dialog.dev_combo.set_active_id("444:888:b123422") + auto_attach_handler.edit_dialog.devident_check.set_active(True) + auto_attach_handler.edit_dialog.port_check.set_active(True) + auto_attach_handler.edit_dialog.ask_radio.set_active(True) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + auto_attach_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-red"] + ) + auto_attach_handler.edit_dialog.read_only_check.set_active(True) + auto_attach_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-vm", + "admin.vm.device.block.Assign", + "sys-usb+sda:444:888:b123422", + b"device_id='444:888:b123422' port_id='sda' devclass='block' " + b"backend_domain='sys-usb' mode='ask-to-attach' " + b"frontend_domain='test-vm' _read-only='True'", + ), + ( + "test-red", + "admin.vm.device.block.Assign", + "sys-usb+sda:444:888:b123422", + b"device_id='444:888:b123422' port_id='sda' devclass='block' " + b"backend_domain='sys-usb' mode='ask-to-attach' " + b"frontend_domain='test-red' _read-only='True'", + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + auto_attach_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_req_init(required_handler): + assert len(required_handler.rule_list.get_children()) == 3 + + +def test_req_edit_dialog(required_handler): + # test if the edit dialog works + qapp = required_handler.qapp + + existing_rows = required_handler.rule_list.get_children() + required_handler.add_button.clicked() + + assert required_handler.edit_dialog.dev_modeler.get_selected() is None + assert "Piano" not in required_handler.edit_dialog.devident_label.get_text() + + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x51f0::p028000" + ) + assert required_handler.edit_dialog.dev_modeler.get_selected() is not None + assert "Network" in required_handler.edit_dialog.devident_label.get_text() + + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x51f0::p300000" + ) + assert required_handler.edit_dialog.dev_modeler.get_selected() is not None + assert "Piano" in required_handler.edit_dialog.devident_label.get_text() + + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + + required_handler.edit_dialog.ok_button.clicked() + + new_rows = required_handler.rule_list.get_children() + assert len(new_rows) == len(existing_rows) + 1 + + diff_rows = set(new_rows) - set(existing_rows) + assert len(diff_rows) == 1 + new_row = diff_rows.pop() + assert "Piano" in new_row.dev_label.get_text() + assert "required" in new_row.action_label.get_text() + assert len(new_row.vm_box.get_children()) == 1 + + expected_call = ( + "test-vm", + "admin.vm.device.pci.Assign", + "dom0+0f.0:0x8086:0x51f0::p300000", + b"device_id='0x8086:0x51f0::p300000' port_id='0f.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-vm'", + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + required_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_req_dialog_change_vm(required_handler): + qapp = required_handler.qapp + + for row in required_handler.rule_list.get_children(): + if "Network Card" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + + required_handler.edit_dialog.qube_handler.clear() + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-red"] + ) + required_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-red", + "admin.vm.device.pci.Assign", + "dom0+0c.0:0x8086:0x51f0::p028000", + b"device_id='0x8086:0x51f0::p028000' port_id='0c.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-red'", + ), + ( + "sys-net", + "admin.vm.device.pci.Unassign", + "dom0+0c.0:0x8086:0x51f0::p028000", + None, + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + required_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_req_add_no_strict(required_handler): + # test if the edit dialog works + qapp = required_handler.qapp + + required_handler.add_button.clicked() + + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x51f0::p300000" + ) + + assert not required_handler.edit_dialog.ok_button.get_sensitive() + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + + assert not required_handler.edit_dialog.no_strict_check.get_active() + required_handler.edit_dialog.no_strict_check.set_active(True) + + required_handler.edit_dialog.ok_button.clicked() + + expected_call = ( + "test-vm", + "admin.vm.device.pci.Assign", + "dom0+0f.0:0x8086:0x51f0::p300000", + b"device_id='0x8086:0x51f0::p300000' port_id='0f.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-vm' _no-strict-reset='True'", + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + required_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_req_add_both_opts(required_handler): + # test if the edit dialog works + qapp = required_handler.qapp + + required_handler.add_button.clicked() + + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x51f0::p300000" + ) + + assert not required_handler.edit_dialog.ok_button.get_sensitive() + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + assert required_handler.edit_dialog.ok_button.get_sensitive() + + assert not required_handler.edit_dialog.permissive_check.get_active() + assert not required_handler.edit_dialog.no_strict_check.get_active() + required_handler.edit_dialog.permissive_check.set_active(True) + required_handler.edit_dialog.no_strict_check.set_active(True) + + required_handler.edit_dialog.ok_button.clicked() + + expected_call = ( + "test-vm", + "admin.vm.device.pci.Assign", + "dom0+0f.0:0x8086:0x51f0::p300000", + b"device_id='0x8086:0x51f0::p300000' port_id='0f.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-vm' _no-strict-reset='True' " + b"_permissive='True'", + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + required_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_req_dialog_remove_opts(required_handler): + # open a bunch of edit screens in order to make sure checkboxes get + # correctly checked and unchecked + qapp = required_handler.qapp + + for row in required_handler.rule_list.get_children(): + if "Orchestra" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + + assert required_handler.edit_dialog.no_strict_check.get_active() + assert not required_handler.edit_dialog.permissive_check.get_active() + + # close, select another + required_handler.edit_dialog.cancel_button.clicked() + + for row in required_handler.rule_list.get_children(): + if "Network Card" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + + assert not required_handler.edit_dialog.no_strict_check.get_active() + assert not required_handler.edit_dialog.permissive_check.get_active() + + required_handler.edit_dialog.cancel_button.clicked() + + for row in required_handler.rule_list.get_children(): + if "Orchestra" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + + assert required_handler.edit_dialog.no_strict_check.get_active() + assert not required_handler.edit_dialog.permissive_check.get_active() + required_handler.edit_dialog.no_strict_check.set_active(False) + required_handler.edit_dialog.permissive_check.set_active(True) + + required_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-red", + "admin.vm.device.pci.Assign", + "dom0+0h.0:0x8086:0x51c8::p040300", + b"device_id='0x8086:0x51c8::p040300' port_id='0h.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-red' _permissive='True'", + ), + ( + "test-red", + "admin.vm.device.pci.Unassign", + "dom0+0h.0:0x8086:0x51c8::p040300", + None, + ), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + required_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_req_buttons(required_handler): + assert required_handler.add_button.get_sensitive() + assert not required_handler.edit_button.get_sensitive() + assert not required_handler.remove_button.get_sensitive() + + for row in required_handler.rule_list.get_children(): + required_handler.rule_list.select_row(row) + break + + assert required_handler.add_button.get_sensitive() + assert required_handler.edit_button.get_sensitive() + assert required_handler.remove_button.get_sensitive() + + required_handler.rule_list.select_row(None) + assert required_handler.add_button.get_sensitive() + assert not required_handler.edit_button.get_sensitive() + assert not required_handler.remove_button.get_sensitive() + + +def test_req_noop(required_handler): + for row in required_handler.rule_list.get_children(): + required_handler.rule_list.select_row(row) + break + + assert required_handler.edit_button.get_sensitive() + required_handler.edit_button.clicked() + required_handler.edit_dialog.ok_button.clicked() + + required_handler.save() + + +def test_req_validity(required_handler): + for row in required_handler.rule_list.get_children(): + if "Network Card" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + + assert required_handler.edit_dialog.ok_button.get_sensitive() + + required_handler.edit_dialog.qube_handler.clear() + + assert not required_handler.edit_dialog.ok_button.get_sensitive() + + required_handler.edit_dialog.qube_handler.add_selected_vm( + required_handler.qapp.domains["test-red"] + ) + + assert required_handler.edit_dialog.ok_button.get_sensitive() + + +def test_req_get_unsaved(required_handler): + assert required_handler.get_unsaved() == "" + for row in required_handler.rule_list.get_children(): + if "Network Card" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + required_handler.remove_button.clicked() + + for row in required_handler.rule_list.get_children(): + if "Orchestra" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + assert required_handler.edit_button.get_sensitive() + required_handler.edit_button.clicked() + required_handler.edit_dialog.no_strict_check.set_active(False) + required_handler.edit_dialog.ok_button.clicked() + + required_handler.add_button.clicked() + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x461e::p0c0330" + ) + required_handler.edit_dialog.qube_handler.add_selected_vm( + required_handler.qapp.domains["test-vm"] + ) + required_handler.edit_dialog.ok_button.clicked() + + assert "Network Card" in required_handler.get_unsaved() + assert "Orchestra" in required_handler.get_unsaved() + assert "USB Controller" in required_handler.get_unsaved() + + +def test_req_reset(required_handler): + qapp = required_handler.qapp + + assert required_handler.get_unsaved() == "" + for row in required_handler.rule_list.get_children(): + if "Network Card" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + required_handler.remove_button.clicked() + + for row in required_handler.rule_list.get_children(): + if "Orchestra" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + required_handler.edit_dialog.no_strict_check.set_active(False) + required_handler.edit_dialog.ok_button.clicked() + + required_handler.add_button.clicked() + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x461e::p0c0330" + ) + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + required_handler.edit_dialog.ok_button.clicked() + + assert required_handler.get_unsaved() != "" + + required_handler.reset() + assert required_handler.get_unsaved() == "" + # nothing should be called here + required_handler.save() + + +def test_req_add_cancel(required_handler): + # no ghosts after cancelling adding a rule + assert len(required_handler.rule_list.get_children()) == 3 + + required_handler.add_button.clicked() + required_handler.edit_dialog.cancel_button.clicked() + + assert len(required_handler.rule_list.get_children()) == 3 + + +def test_req_multiple_save(required_handler): + """Check if we do not do superfluous saves when saving multiple times.""" + qapp = required_handler.qapp + required_handler.add_button.clicked() + + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x51f0::p300000" + ) + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + required_handler.edit_dialog.ok_button.clicked() + + for row in required_handler.rule_list.get_children(): + if "Network Card" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + required_handler.remove_button.clicked() + + for row in required_handler.rule_list.get_children(): + if "Orchestra" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + assert required_handler.edit_button.get_sensitive() + required_handler.edit_button.clicked() + required_handler.edit_dialog.no_strict_check.set_active(False) + required_handler.edit_dialog.permissive_check.set_active(True) + required_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "test-vm", + "admin.vm.device.pci.Assign", + "dom0+0f.0:0x8086:0x51f0::p300000", + b"device_id='0x8086:0x51f0::p300000' port_id='0f.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-vm'", + ), # new assignment + ( + "test-red", + "admin.vm.device.pci.Unassign", + "dom0+0h.0:0x8086:0x51c8::p040300", + None, + ), # remove edited assignment + ( + "test-red", + "admin.vm.device.pci.Assign", + "dom0+0h.0:0x8086:0x51c8::p040300", + b"device_id='0x8086:0x51c8::p040300' port_id='0h.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-red' _permissive='True'", + ), # add edited + ( + "sys-net", + "admin.vm.device.pci.Unassign", + "dom0+0c.0:0x8086:0x51f0::p028000", + None, + ), # removed assignment + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + required_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + # see if next saves do not do any superfluous calls + for call in expected_calls: + del qapp.expected_calls[call] + + required_handler.save() + required_handler.save() + + for call in expected_calls: + assert qapp.actual_calls.count(call) == 1 + + +def test_req_options_after_save(required_handler): + qapp = required_handler.qapp + # test for a bug where applying options leads to incorrect options being + # shown in edit dialog + for row in required_handler.rule_list.get_children(): + if "USB Controller" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + assert not required_handler.edit_dialog.permissive_check.get_active() + + required_handler.edit_dialog.permissive_check.set_active(True) + required_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ( + "sys-net", + "admin.vm.device.pci.Assign", + "dom0+0d.0:0x8086:0x461e::p0c0330", + b"device_id='0x8086:0x461e::p0c0330' port_id='0d.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='sys-net' _permissive='True'", + ), # new assignment + ( + "sys-net", + "admin.vm.device.pci.Unassign", + "dom0+0d.0:0x8086:0x461e::p0c0330", + None, + ), # remove edited assignment + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + # because the main program does a reset after save to ensure correctness, + # we need to check that the data loads correctly + qapp.expected_calls[ + ("sys-net", "admin.vm.device.pci.Assigned", None, None) + ] = ( + "0\x00dom0+0d.0:0x8086:0x461e::p0c0330 " + "device_id='0x8086:0x461e::p0c0330' port_id='0d.0' devclass='pci' " + "backend_domain='dom0' mode='required' frontend_domain='sys-net' " + "_permissive='True'\n".encode() + ) + + required_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + required_handler.reset() + for row in required_handler.rule_list.get_children(): + if "USB Controller" in row.dev_label.get_text(): + required_handler.rule_list.select_row(row) + break + else: + assert False + + required_handler.edit_button.clicked() + assert required_handler.edit_dialog.permissive_check.get_active() + + +def test_req_readonly(required_handler): + # test if the edit dialog works + qapp = required_handler.qapp + + required_handler.add_button.clicked() + + required_handler.edit_dialog.dev_combo.set_active_id( + "0x8086:0x51f0::p300000" + ) + + assert not required_handler.edit_dialog.ok_button.get_sensitive() + required_handler.edit_dialog.qube_handler.add_selected_vm( + qapp.domains["test-vm"] + ) + + assert not required_handler.edit_dialog.no_strict_check.get_active() + required_handler.edit_dialog.no_strict_check.set_active(True) + + required_handler.edit_dialog.ok_button.clicked() + + expected_call = ( + "test-vm", + "admin.vm.device.pci.Assign", + "dom0+0f.0:0x8086:0x51f0::p300000", + b"device_id='0x8086:0x51f0::p300000' port_id='0f.0' " + b"devclass='pci' backend_domain='dom0' mode='required'" + b" frontend_domain='test-vm' _no-strict-reset='True'", + ) + qapp.expected_calls[expected_call] = b"0\x00" + assert expected_call not in qapp.actual_calls + + required_handler.save() + + assert expected_call in qapp.actual_calls + + +def test_req_grouping(real_builder, test_qapp_devices): + # a device that has three assignments with different opts should appear + # thrice + test_qapp_devices._devices.append( + MockDevice( + test_qapp_devices, + dev_class="pci", + product="OptionsDevice", + backend_vm="dom0", + assigned=[ + ("sys-net", "required", ["permissive"]), + ("sys-usb", "required", ["no-strict-reset"]), + ("test-vm", "required", None), + ], + device_id="0x8086:0x8857::p0c0330", + port="0d.0", + vendor="ACME", + ) + ) + + test_qapp_devices.update_vm_calls() + + dev_policy_manager = DeviceManager(test_qapp_devices) + dev_policy_manager.load_data() + + handler = AttachmentHandler( + qapp=test_qapp_devices, + builder=real_builder, + device_policy_manager=dev_policy_manager, + classes=PCI_CLASSES, + edit_dialog_class=RequiredDeviceDialog, + prefix="devices_required", + ) + + rows = [] + for row in handler.rule_list.get_children(): + if "OptionsDevice" in row.dev_label.get_text(): + rows.append(row) + + assert len(rows) == 3 + + +# def test_auto_attach_double_star(real_builder, test_qapp_devices): +# # attachment with both port and device as * +# call = ('test-blue', f"admin.vm.device.usb.Assigned", None, None) +# assignment_string = (f"sys-usb+*:* device_id='*' " +# f"port_id='*' devclass='usb' " +# f"backend_domain='sys-usb' mode='ask-to-attach' " +# f"frontend_domain='test-blue'\n").encode() +# current_response = test_qapp_devices.expected_calls[call] +# test_qapp_devices.expected_calls[call] = current_response + \ +# assignment_string +# +# dev_policy_manager = DeviceManager(test_qapp_devices) +# dev_policy_manager.load_data() +# +# handler = AttachmentHandler(qapp=test_qapp_devices, builder=real_builder, +# device_policy_manager=dev_policy_manager, +# classes=AUTO_CLASSES, +# edit_dialog_class=AutoDeviceDialog, +# prefix="devices_auto") +# +# assert list(handler.rule_list.get_children()) == 4 diff --git a/qubes_config/tests/test_device_blocks.py b/qubes_config/tests/test_device_blocks.py new file mode 100644 index 00000000..1f8fdc7a --- /dev/null +++ b/qubes_config/tests/test_device_blocks.py @@ -0,0 +1,421 @@ +# -*- encoding: utf8 -*- +# +# The Qubes OS Project, http://www.qubes-os.org +# +# Copyright (C) 2025 Marta Marczykowska-Górecka +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program; if not, see . +# pylint: disable=missing-module-docstring +# pylint: disable=missing-function-docstring +# pylint: disable=missing-class-docstring +# pylint: disable=protected-access +from unittest.mock import patch + +import pytest + +from ..global_config.device_attachments import DeviceManager +from ..global_config.device_blocks import DeviceBlockHandler, OtherCategoryRow + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + + +### DEVICE POLICY BLOCK HANDLER TESTS + + +# pylint fails to correctly react to fixtures +# pylint: disable=redefined-outer-name +@pytest.fixture +def block_handler(real_builder, test_qapp_devices): + dev_policy_manager = DeviceManager(test_qapp_devices) + dev_policy_manager.load_data() + + handler = DeviceBlockHandler( + test_qapp_devices, real_builder, dev_policy_manager + ) + return handler + + +def test_policy_block_init(block_handler): + assert len(block_handler.rule_list.get_children()) == 2 + + +def test_policy_block_remove(block_handler): + qapp = block_handler.qapp + assert not block_handler.remove_button.is_sensitive() + + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + assert block_handler.remove_button.is_sensitive() + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + block_handler.remove_button.clicked() + + expected_calls = [ + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p02****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"u02****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p07****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p123***"), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + block_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_policy_block_add(block_handler): + qapp = block_handler.qapp + assert block_handler.add_button.is_sensitive() + + block_handler.add_button.clicked() + + block_handler.edit_dialog.qube_model.select_value("test-blue") + + for row in block_handler.edit_dialog.listbox.get_children(): + if row.category_wrapper.name == "Human interface devices": + row.activate() + break + else: + assert False, "Category not found" + + for row in block_handler.edit_dialog.listbox.get_children(): + assert row.check_box.get_active() == ( + row.category_wrapper.name + in ("Human interface devices", "Mice", "Keyboards") + ) + + assert block_handler.edit_dialog.ok_button.get_sensitive() + block_handler.edit_dialog.ok_button.clicked() + assert len(block_handler.rule_list.get_children()) == 3 + + # only top level categories should block + expected_calls = [ + ("test-blue", "admin.vm.device.denied.Add", None, b"u03****"), + ("test-blue", "admin.vm.device.denied.Add", None, b"p09****"), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + block_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_policy_block_edit_vm(block_handler): + qapp = block_handler.qapp + assert not block_handler.edit_button.is_sensitive() + + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + assert block_handler.edit_button.is_sensitive() + + block_handler.edit_button.clicked() + + assert ( + block_handler.edit_dialog.qube_model.get_selected().name == "test-dev2" + ) + + for row in block_handler.edit_dialog.listbox.get_children(): + if isinstance(row, OtherCategoryRow): + assert row.text_box.get_text() == "p123***" + break + else: + assert False, "Misc row not found" + + for row in block_handler.edit_dialog.listbox.get_children(): + assert isinstance(row, OtherCategoryRow) or ( + row.check_box.get_active() + == (row.category_wrapper.name == "Network devices") + ) + + block_handler.edit_dialog.qube_model.select_value("test-blue") + block_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p02****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"u02****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p07****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p123***"), + ("test-blue", "admin.vm.device.denied.Add", None, b"p02****"), + ("test-blue", "admin.vm.device.denied.Add", None, b"p07****"), + ("test-blue", "admin.vm.device.denied.Add", None, b"u02****"), + ("test-blue", "admin.vm.device.denied.Add", None, b"p123***"), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + block_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_policy_block_edit_rules(block_handler): + qapp = block_handler.qapp + assert not block_handler.edit_button.is_sensitive() + + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + assert block_handler.edit_button.is_sensitive() + + block_handler.edit_button.clicked() + + for row in block_handler.edit_dialog.listbox.get_children(): + if row.category_wrapper.name == "Human interface devices": + row.activate() + break + else: + assert False, "Category not found" + + block_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ("test-dev2", "admin.vm.device.denied.Add", None, b"u03****"), + ("test-dev2", "admin.vm.device.denied.Add", None, b"p09****"), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + block_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + +def test_policy_block_add_cancel(block_handler): + assert block_handler.add_button.is_sensitive() + + assert len(block_handler.rule_list.get_children()) == 2 + + block_handler.add_button.clicked() + block_handler.edit_dialog.cancel_button.clicked() + + assert len(block_handler.rule_list.get_children()) == 2 + + +def test_policy_block_unsaved(block_handler): + # add + block_handler.add_button.clicked() + block_handler.edit_dialog.qube_model.select_value("test-red") + + for row in block_handler.edit_dialog.listbox.get_children(): + if row.category_wrapper.name == "Printers": + row.activate() + break + else: + assert False, "Category not found" + block_handler.edit_dialog.ok_button.clicked() + + # remove + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + block_handler.remove_button.clicked() + + # edit + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev row" + block_handler.edit_button.clicked() + block_handler.edit_dialog.qube_model.select_value("test-blue") + block_handler.edit_dialog.ok_button.clicked() + + unsaved = block_handler.get_unsaved().split("\n") + assert len(unsaved) == 3 + assert len([l for l in unsaved if "Removed" in l and "test-dev2" in l]) == 1 + assert len([l for l in unsaved if "changed" in l and "test-blue" in l]) == 1 + assert ( + len( + [ + l + for l in unsaved + if "changed" in l and "test-red" in l and "Printers" in l + ] + ) + == 1 + ) + + +def test_policy_block_reset(block_handler): + # add + block_handler.add_button.clicked() + block_handler.edit_dialog.qube_model.select_value("test-red") + + for row in block_handler.edit_dialog.listbox.get_children(): + if row.category_wrapper.name == "Printers": + row.activate() + break + else: + assert False, "Category not found" + block_handler.edit_dialog.ok_button.clicked() + + # remove + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + block_handler.remove_button.clicked() + + # edit + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev row" + block_handler.edit_button.clicked() + block_handler.edit_dialog.qube_model.select_value("test-blue") + block_handler.edit_dialog.ok_button.clicked() + + block_handler.reset() + assert block_handler.get_unsaved() == "" + + # nothing should be called + block_handler.save() + + +def test_policy_block_add_after_edit(block_handler): + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + assert block_handler.edit_button.is_sensitive() + block_handler.edit_button.clicked() + block_handler.edit_dialog.cancel_button.clicked() + + block_handler.add_button.clicked() + + assert block_handler.edit_dialog.qube_model.get_selected() is None + for row in block_handler.edit_dialog.listbox.get_children(): + assert not row.check_box.get_active() + + +def test_multiple_save(block_handler): + qapp = block_handler.qapp + + # add + block_handler.add_button.clicked() + block_handler.edit_dialog.qube_model.select_value("test-red") + + for row in block_handler.edit_dialog.listbox.get_children(): + if row.category_wrapper.name == "Printers": + row.activate() + break + else: + assert False, "Category not found" + block_handler.edit_dialog.ok_button.clicked() + # remove + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev2": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev2 row" + + with patch( + "qubes_config.global_config.device_widgets.ask_question", + return_value=Gtk.ResponseType.YES, + ): + block_handler.remove_button.clicked() + + # edit + for row in block_handler.rule_list.get_children(): + if row.vm_wrapper.vm.name == "test-dev": + block_handler.rule_list.select_row(row) + break + else: + assert False, "Failed to find test-dev row" + block_handler.edit_button.clicked() + block_handler.edit_dialog.qube_model.select_value("test-blue") + block_handler.edit_dialog.ok_button.clicked() + + expected_calls = [ + ("test-red", "admin.vm.device.denied.Add", None, b"u07****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p123***"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p02****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"p07****"), + ("test-dev2", "admin.vm.device.denied.Remove", None, b"u02****"), + ("test-blue", "admin.vm.device.denied.Add", None, b"m******"), + ("test-dev", "admin.vm.device.denied.Remove", None, b"m******"), + ] + for call in expected_calls: + assert call not in qapp.actual_calls + qapp.expected_calls[call] = b"0\x00" + + block_handler.save() + + for call in expected_calls: + assert call in qapp.actual_calls + + # save a couple more times + for call in expected_calls: + del qapp.expected_calls[call] + assert qapp.actual_calls.count(call) == 1 + + block_handler.save() + block_handler.save() + block_handler.save() + + for call in expected_calls: + assert qapp.actual_calls.count(call) == 1 diff --git a/qubes_config/widgets/gtk_widgets.py b/qubes_config/widgets/gtk_widgets.py index 869f257d..9d35e7c5 100644 --- a/qubes_config/widgets/gtk_widgets.py +++ b/qubes_config/widgets/gtk_widgets.py @@ -304,6 +304,13 @@ def update_initial(self): if self.style_changes: self.entry_box.get_style_context().remove_class("combo-changed") + def clear_selection(self): + """ + Clear currently selected item. + """ + self.entry_box.set_text("") + self.combo.set_active_id(None) + def reset(self): """Reset changes.""" self.combo.set_active_id(self._initial_id) diff --git a/qui/styles/qubes-colors-dark.css b/qui/styles/qubes-colors-dark.css index 266977b3..384e477e 100644 --- a/qui/styles/qubes-colors-dark.css +++ b/qui/styles/qubes-colors-dark.css @@ -16,6 +16,7 @@ @define-color separator-color #262626; @define-color soft-text-color #e7e7e7; @define-color misc-text-color #f2f2f2; +@define-color alt-row #111827; @define-color problem-background #991b1b; @define-color problem-frame #dc2626; diff --git a/qui/styles/qubes-colors-light.css b/qui/styles/qubes-colors-light.css index d6a870f2..e0685516 100644 --- a/qui/styles/qubes-colors-light.css +++ b/qui/styles/qubes-colors-light.css @@ -16,6 +16,7 @@ @define-color separator-color #cdcdcd; @define-color soft-text-color #858585; @define-color misc-text-color #979797; +@define-color alt-row #e5e7eb; @define-color problem-background #fecaca; @define-color problem-frame #b91c1c; diff --git a/qui/styles/qubes-widgets-base.css b/qui/styles/qubes-widgets-base.css index e45a2861..c9558008 100644 --- a/qui/styles/qubes-widgets-base.css +++ b/qui/styles/qubes-widgets-base.css @@ -121,6 +121,11 @@ radiobutton radio { color: white; } +.button_save:disabled { + background: @medium-gray; + color: black; +} + .button_cancel { background: @button-color; color: @dark-text-color; diff --git a/rpm_spec/qubes-desktop-linux-manager.spec.in b/rpm_spec/qubes-desktop-linux-manager.spec.in index 76ab6b99..0baa2eb1 100644 --- a/rpm_spec/qubes-desktop-linux-manager.spec.in +++ b/rpm_spec/qubes-desktop-linux-manager.spec.in @@ -164,6 +164,9 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || : %{python3_sitelib}/qubes_config/global_config/__pycache__/* %{python3_sitelib}/qubes_config/global_config/basics_handler.py %{python3_sitelib}/qubes_config/global_config/conflict_handler.py +%{python3_sitelib}/qubes_config/global_config/device_attachments.py +%{python3_sitelib}/qubes_config/global_config/device_blocks.py +%{python3_sitelib}/qubes_config/global_config/device_widgets.py %{python3_sitelib}/qubes_config/global_config/global_config.py %{python3_sitelib}/qubes_config/global_config/page_handler.py %{python3_sitelib}/qubes_config/global_config/policy_handler.py @@ -252,6 +255,8 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || : /usr/share/icons/hicolor/scalable/apps/check_yes.svg /usr/share/icons/hicolor/scalable/apps/detach-dark.svg /usr/share/icons/hicolor/scalable/apps/detach-light.svg +/usr/share/icons/hicolor/scalable/apps/devices-dark.svg +/usr/share/icons/hicolor/scalable/apps/devices-light.svg /usr/share/icons/hicolor/scalable/apps/edit-dark.svg /usr/share/icons/hicolor/scalable/apps/edit-light.svg /usr/share/icons/hicolor/scalable/apps/harddrive-dark.svg @@ -299,6 +304,7 @@ gtk-update-icon-cache %{_datadir}/icons/Adwaita &>/dev/null || : /usr/share/icons/hicolor/scalable/apps/qubes-policy-editor.svg /usr/share/icons/hicolor/scalable/apps/qubes-question.svg /usr/share/icons/hicolor/scalable/apps/qubes-this-device.svg +/usr/share/icons/hicolor/scalable/apps/qubes-unplug.svg /usr/share/icons/hicolor/scalable/apps/qui-clipboard.svg /usr/share/icons/hicolor/scalable/apps/qui-clipboard-light.svg /usr/share/icons/hicolor/scalable/apps/qui-clipboard-dark.svg From 6076bd4447aa3e617ea5127255b334963459419e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Tue, 20 May 2025 15:20:33 +0200 Subject: [PATCH 4/8] Review fixes --- qubes_config/global_config.glade | 59 +++- .../global_config/device_attachments.py | 269 ++++++++---------- qubes_config/global_config/device_blocks.py | 55 ++-- qubes_config/global_config/device_widgets.py | 37 ++- qubes_config/global_config/global_config.py | 6 +- qubes_config/qubes-global-config-base.css | 4 +- qubes_config/tests/conftest.py | 2 +- qubes_config/tests/test_device_attachments.py | 98 +++++-- qubes_config/tests/test_device_blocks.py | 48 +++- 9 files changed, 334 insertions(+), 244 deletions(-) diff --git a/qubes_config/global_config.glade b/qubes_config/global_config.glade index beca7858..dcd61379 100644 --- a/qubes_config/global_config.glade +++ b/qubes_config/global_config.glade @@ -168,7 +168,7 @@ True False start - Block devices + Deny device attachment @@ -200,16 +200,31 @@ - + True - False - none - + True + True + in + 300 + + + True + False + + + True + False + none + + + + + - False + True True 2 @@ -226,7 +241,7 @@ - False + True True 1 @@ -604,7 +619,8 @@ True False - If you select matching only the port and not device identity, the rule will be applied to any device of this class connected to that port. + <b>Port</b> refers to the port this device is currently plugged in. If you select matching the port, the device <b>must</b> be plugged into this port to be automatically attached. If you select port and not device identity, all devices plugged into this port will be automatically attached. + True True 0 + + + False + True + 10 + + True @@ -9561,7 +9594,7 @@ Other description: False True - 10 + 11 @@ -9677,7 +9710,7 @@ Other description: False True - 11 + 13 @@ -9695,7 +9728,7 @@ Other description: False True - 12 + 14 + + + False + True + 0 + + + + + True + False + Number of disposable qubes to preload from the default disposable template. + True + 0 + + + + False + True + 1 + + + + + 0 + 8 + + + + + True + False + + + True + True + center + 5 + 5 + True + 2 + 2 + + + False + True + 0 + + + + + True + False + start + 5 + 5 + 5 + 5 + True + + + + False + True + 1 + + + + + 1 + 8 diff --git a/qubes_config/global_config/basics_handler.py b/qubes_config/global_config/basics_handler.py index aca51640..c6c1b6fc 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,99 @@ 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, + widget: Gtk.SpinButton, + defdispvm_model: VMListModeler, + is_dvm_template: Optional[Callable] = None, + ): + self.qapp = qapp + self.widget = widget + self.defdispvm_model = defdispvm_model + self.defdispvm_model.connect_change_callback(self.on_defdispvm_changed) + self.is_dvm_template = is_dvm_template + self.preload_dispvm_adjustment = Gtk.Adjustment() + self.preload_dispvm_adjustment.configure(0, 0, 50, 1, 10, 0) + self.widget.configure(self.preload_dispvm_adjustment, 0.1, 0) + self.widget.set_value(self.get_current_value()) + self.widget.set_sensitive(bool(self.get_defdispvm_value())) + + def get_defdispvm_value(self): + return self.defdispvm_model.get_selected() + + def on_defdispvm_changed(self): + value = self.get_defdispvm_value() + if value: + self.widget.set_sensitive(True) + self.widget.set_value(self.get_current_value()) + else: + self.widget.set_value(0) + self.widget.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 save(self): + """Save changes: update system value and mark it as new initial value""" + if not self.is_changed(): + return + if not self.get_defdispvm_value(): + return + apply_feature_change( + self.qapp.domains["dom0"], + "preload-dispvm-max", + int(self.widget.get_value()), + ) + + def reset(self): + """Reset selection to the initial value.""" + if not self.widget.is_sensitive(): + return + self.widget.set_value(self.get_current_value()) + + def is_changed(self) -> bool: + """Has the user selected something different from the initial value?""" + if not self.widget.is_sensitive(): + return False + if int(self.widget.get_value()) != 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 get_current_value(self): + """This should never be called.""" + if not self.get_defdispvm_value(): + return 0 + if not self.is_dvm_template: + return 0 + return int( + get_feature(self.qapp.domains["dom0"], "preload-dispvm-max") or 0 + ) + + 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.""" @@ -456,6 +549,9 @@ def __init__(self, gtk_builder: Gtk.Builder, qapp: qubesadmin.Qubes): self.defdispvm_combo: Gtk.ComboBox = gtk_builder.get_object( "basics_defdispvm_combo" ) + self.preload_dispvm_spin: Gtk.SpinButton = gtk_builder.get_object( + "basics_preload_dispvm" + ) self.fullscreen_combo: Gtk.ComboBoxText = gtk_builder.get_object( "basics_fullscreen_combo" ) @@ -513,6 +609,17 @@ 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, + widget=self.preload_dispvm_spin, + defdispvm_model=defdispvm_model, + is_dvm_template=self._default_dispvm_filter, + ) + ) 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 118cd232..ffa6fbbe 100644 --- a/qubes_config/tests/test_basics_handler.py +++ b/qubes_config/tests/test_basics_handler.py @@ -338,29 +338,86 @@ 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") +def test_preload_handler( + mock_get, real_builder, test_qapp +): # pylint: disable=unused-argument + mock_get.return_value = "1" + test_qapp.expected_calls[ + ("dom0", "admin.vm.feature.Get", "preload-dispvm-max", None) + ] = b"0\x00" + basics_handler = BasicSettingsHandler(real_builder, test_qapp) + + 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" + ) + + initial_preload_dispvm = preload_dispvm_spin.get_value() + initial_default_dispvm = defdispvm_combo.get_active_id() + preload_dispvm_spin.set_value(initial_preload_dispvm + 1) + defdispvm_combo.set_active_id("(none)") + assert not preload_dispvm_spin.is_sensitive() + assert preload_dispvm_spin.get_value() == 0 + # Assert that preload feature hasn't changed. + test_qapp.expected_calls[ + ("dom0", "admin.property.Set", "default_dispvm", b"") + ] = b"0\x00" + basics_handler.save() + + initial_preload_dispvm = preload_dispvm_spin.get_value() + assert not preload_dispvm_spin.is_sensitive() + defdispvm_combo.set_active_id(initial_default_dispvm) + assert preload_dispvm_spin.is_sensitive() + new_preload_value = initial_preload_dispvm + 1 + preload_dispvm_spin.set_value(new_preload_value) + test_qapp.expected_calls[ + ( + "dom0", + "admin.property.Set", + "default_dispvm", + initial_default_dispvm.encode(), + ) + ] = b"0\x00" + test_qapp.expected_calls[ + ( + "dom0", + "admin.vm.features.Set", + "preload-dispvm-max", + str(new_preload_value).encode(), + ), + ] = b"0\x00" + basics_handler.save() + + def test_basics_handler(real_builder, test_qapp): + test_qapp.expected_calls[ + ("dom0", "admin.vm.feature.Get", "preload-dispvm-max", None) + ] = b"0\x00" 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/qubes_config/tests/test_global_config.py b/qubes_config/tests/test_global_config.py index 67190f79..13258847 100644 --- a/qubes_config/tests/test_global_config.py +++ b/qubes_config/tests/test_global_config.py @@ -75,6 +75,9 @@ def test_qubes_global_config(): def test_global_config_init( mock_error, mock_subprocess, test_qapp, test_policy_manager, test_builder ): + test_qapp.expected_calls[ + ("dom0", "admin.vm.feature.Get", "preload-dispvm-max", None) + ] = b"0\x00" mock_subprocess.return_value = b"" app = GlobalConfig(test_qapp, test_policy_manager) # do not call do_activate - it will make Gtk confused and, in case @@ -140,6 +143,9 @@ def test_global_config_init( def test_global_config_page_change( mock_error, mock_subprocess, test_qapp, test_policy_manager, test_builder ): + test_qapp.expected_calls[ + ("dom0", "admin.vm.feature.Get", "preload-dispvm-max", None) + ] = b"0\x00" mock_subprocess.return_value = b"" app = GlobalConfig(test_qapp, test_policy_manager) # do not call do_activate - it will make Gtk confused and, in case @@ -238,6 +244,9 @@ def test_global_config_page_change( def test_global_config_failure( mock_error, mock_subprocess, test_qapp, test_policy_manager, test_builder ): + test_qapp.expected_calls[ + ("dom0", "admin.vm.feature.Get", "preload-dispvm-max", None) + ] = b"0\x00" mock_subprocess.return_value = b"" app = GlobalConfig(test_qapp, test_policy_manager) # do not call do_activate - it will make Gtk confused and, in case @@ -302,6 +311,9 @@ def test_global_config_broken_system( def test_global_config_open_at( mock_error, mock_subprocess, test_qapp, test_policy_manager, real_builder ): + test_qapp.expected_calls[ + ("dom0", "admin.vm.feature.Get", "preload-dispvm-max", None) + ] = b"0\x00" mock_subprocess.return_value = b"" app = GlobalConfig(test_qapp, test_policy_manager) # do not call do_activate - it will make Gtk confused and, in case From b445fbdc8531ccf4a2019ab1aa08f1b82fdf1e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marta=20Marczykowska-G=C3=B3recka?= Date: Wed, 11 Jun 2025 18:43:26 +0200 Subject: [PATCH 8/8] Last round of review fixes --- qubes_config/global_config.glade | 2044 +++++++++-------- .../global_config/device_attachments.py | 5 +- qubes_config/global_config/device_widgets.py | 9 +- qubes_config/global_config/global_config.py | 25 +- qubes_config/global_config/vm_flowbox.py | 8 + qubes_config/widgets/gtk_utils.py | 19 + 6 files changed, 1081 insertions(+), 1029 deletions(-) diff --git a/qubes_config/global_config.glade b/qubes_config/global_config.glade index dcd61379..d0311c7a 100644 --- a/qubes_config/global_config.glade +++ b/qubes_config/global_config.glade @@ -63,182 +63,198 @@ - + True - False - vertical - - - True - False - start - Qube - - - - False - True - 0 - - + True + True + True + in + True + True - + True False - 10 - - - True - False - center - True - Select qube: - 0 - - - - False - True - 0 - - - + True False - center - 5 - 5 - True - True - - - True - 24 + vertical + + + True + False + start + Qube + + + False + True + 0 + - - - - False - True - 1 - - - - - False - False - 1 - - - - - True - False - center - True - Select a qube to be able to select device types to block. - True - True - 0 - - - - False - True - 2 - - - - - True - False - vertical - - - True - False - start - Deny device attachment - - - - False - True - 0 - - - - - True - False - center - True - You will not be able to manually attach any of the selected device types to this qube. This does <b>NOT</b> apply to automatic attachments. - True - True - 0 - - - - False - True - 1 - - - - - True - True - True - in - 300 - + True False + 10 - + True False - none + center + True + Select qube: + 0 + + + False + True + 0 + + + + + True + False + center + 5 + 5 + True + True + + + True + + + + + + False + True + 1 + + + + + False + False + 1 + + + + + True + False + center + True + Select a qube to be able to select device types to block. + True + True + 0 + + + + False + True + 2 + + + + + True + False + vertical + + + True + False + start + Deny device attachment + + + + False + True + 0 + + + + + True + False + center + True + You will not be able to manually attach any of the selected device types to this qube. This does <b>NOT</b> apply to automatic attachments. + True + True + 0 + + + False + True + 1 + + + + + True + True + True + in + 300 + + + True + False + + + True + False + none + + + + + + + + True + True + 2 + + + False + True + 3 + + - - True - True - 2 - - - False - True - 3 - - True @@ -310,372 +326,18 @@ - + True - False - vertical - - - True - False - start - Device - - - - False - True - 0 - - + True + in + True + True - + True False - 10 - - True - False - center - True - Device: - 0 - - - - False - True - 0 - - - - - True - False - - - - False - True - 2 - - - - - False - False - 1 - - - - - True - False - center - 20 - 20 - - - True - False - start - center - 48 - qubes-unplug - - - False - True - 0 - - - - - True - True - start - This device is not currently connected. The assignment cannot be modified. - True - True - - - False - True - 1 - - - - - - False - False - 2 - - - - - True - False - center - True - Apply rule to devices matching: - 0 - - - - False - True - 3 - - - - - True - False - - - Backend qube: - True - False - True - False - Backend qube cannot be changed dynamically. Select a device from desired backend qube. - True - True - - - - False - True - 0 - - - - - - - - False - True - 4 - - - - - True - False - - - Device class: - True - False - True - False - Device class cannot be set independently. Select a device from desired class. - True - True - - - - False - True - 0 - - - - - True - False - center - True - - 0 - - - - False - True - 1 - - - - - False - True - 5 - - - - - True - False - - - Device identity: - True - True - False - start - start - True - - - - False - True - 0 - - - - - True - False - start - 2 - True - 0 - - - - False - True - 2 - - - - - False - True - 6 - - - - - True - False - - - Port: - True - True - False - True - - - - False - True - 0 - - - - - True - False - center - True - 0 - - - - False - True - 1 - - - - - False - True - 7 - - - - - True - False - <b>Port</b> refers to the port this device is currently plugged in. If you select matching the port, the device <b>must</b> be plugged into this port to be automatically attached. If you select port and not device identity, all devices plugged into this port will be automatically attached. - True - True - 0 - - - - False - True - 8 - - - - - True - False - start - Action - - - - False - True - 9 - - - - - True - False - center - True - Select what happens when the device becomes available. - 0 - - - - False - True - 10 - - - - - True - True - False - True - True - - + True False vertical @@ -683,18 +345,335 @@ True False - center - True - Automatically attach the device to the selected qube + start + Device + + + + False + True + 0 + + + + + True + False + 10 + + + True + False + center + True + Device: + 0 + + + + False + True + 0 + + + + + True + False + + + + False + True + 2 + + + + + False + False + 1 + + + + + True + False + center + 20 + 20 + + + True + False + start + center + 48 + qubes-unplug + + + False + True + 0 + + + + + True + True + start + This device is not currently connected. The assignment cannot be modified. + True + True + + + False + True + 1 + + + + + + False + False + 2 + + + + + True + False + center + True + Apply rule to devices matching: + 0 + + + + False + True + 3 + + + + + True + False + + + Backend qube: + True + False + True + False + Backend qube cannot be changed dynamically. Select a device from desired backend qube. + True + True + + + + False + True + 0 + + + + + + + + False + True + 4 + + + + + True + False + + + Device class: + True + False + True + False + Device class cannot be set independently. Select a device from desired class. + True + True + + + + False + True + 0 + + + + + True + False + center + True + + 0 + + + + False + True + 1 + + + + + False + True + 5 + + + + + True + False + + + Device identity: + True + True + False + start + start + True + + + + False + True + 0 + + + + + True + False + start + 2 + True + 0 + + + + False + True + 2 + + + + + False + True + 6 + + + + + True + False + + + Port: + True + True + False + True + + + + False + True + 0 + + + + + True + False + center + True + 0 + + + + False + True + 1 + + + + + False + True + 7 + + + + + True + False + <b>Port</b> refers to the port this device is currently plugged in. If you select matching the port, the device <b>must</b> be plugged into this port to be automatically attached. If you select port and not device identity, all devices plugged into this port will be automatically attached. + True + True 0 False True - 0 + 8 + + + + + True + False + start + Action + + + + False + True + 9 @@ -703,8 +682,7 @@ False center True - If multiple qubes are selected and running when the device is connected, you will be asked to choose one of them. - True + Select what happens when the device becomes available. 0 - - - False - True - 12 - - - - - Read-only (only for block devices) - True - True - False - 10 - True - - - False - True - 13 - - - - - True - False - start - Qubes - - - - False - True - 14 - - - - - True - False - vertical - - - True - False - 40 - - - - False - True - 0 - - - - - True - False - 5 - 10 - + True - False - center - 5 - 5 - True - - + True + False + True + True + + + True False + vertical + + + True + False + center + True + Automatically attach the device to the selected qube + 0 + + + + False + True + 0 + + + + + True + False + center + True + If multiple qubes are selected and running when the device is connected, you will be asked to choose one of them. + True + 0 + + + + False + True + 1 + + + + + False + True + 11 + + + + + Ask to attach to one of the selected qubes + True + True + False + True + edit_device_auto_radio False True - 0 + 12 - + + Read-only (only for block devices) True True - True + False + 10 + True + + + False + True + 13 + + + + + True + False start - none + Qubes + + + + False + True + 14 + + + + + True + False + vertical - + + True + False + 40 + + + + False + True + 0 + + + + True False + 5 10 - + True False - qubes-icon-add + center + 5 + 5 + True + False @@ -848,13 +846,50 @@ - + True - False - 5 - Add qube + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + 5 + Add qube + + + + False + True + 1 + + + + @@ -864,53 +899,44 @@ + + False + True + 1 + - False True - 1 + 15 - - - False - True - 1 - - - - - False - True - 15 - - - - - False - A device cannot be attached to its own backend qube. - True - True - 0 - + + + False + A device cannot be attached to its own backend qube. + True + True + 0 + + + + False + True + 16 + + + + + - - False - True - 16 - - False @@ -9330,339 +9356,321 @@ to global clipboard - + True - False - vertical - - - True - False - start - Device - - - - False - True - 0 - - - - - True - False - 10 - - - True - False - center - True - Device: - 0 - - - - False - True - 0 - - - - - True - False - - - - False - True - 2 - - - - - False - False - 1 - - - - - True - False - Select available PCI or block device. - True - 0 - - - - False - True - 2 - - - - - True - False - True - center - 20 - 20 - - - True - False - start - center - 48 - qubes-unplug - - - False - True - 0 - - - - - True - False - start - This device is not currently connected. The assignment cannot be modified. - True - True - - - False - True - 1 - - - - - - False - False - 3 - - - - - True - False - start - 2 - True - Device class: -Other description: - 0 - - - - False - True - 4 - - - - - True - False - start - Options - - - - False - True - 5 - - - - - True - False - <b>No strict reset</b> and <b>permissive</b> options should only be used in case of compatibility problems. They can lead to security issues. - True - True - 0 - - - - False - True - 6 - - - - - No strict reset - True - True - False - True - - - - False - True - 7 - - - - - Permissive - True - True - False - True - - - - False - True - 8 - - - - - Read-only - True - True - False - True - - - - False - True - 9 - - - - - Require port (device will match only when plugged into the currently used port) - True - True - False - True - - - - False - True - 10 - - - - - True - False - start - Qubes - - - - False - True - 11 - - + True + in + True + True - + True False - vertical - - - True - False - 40 - - - - False - True - 0 - - - + True False - 5 - 10 + vertical + + + True + False + start + Device + + + + False + True + 0 + + + + + True + False + 10 + + + True + False + center + True + Device: + 0 + + + + False + True + 0 + + + + + True + False + + + + False + True + 2 + + + + + False + False + 1 + + - + True False - center - 5 - 5 - True - - - True + Select available PCI or block device. + True + 0 + + + + False + True + 2 + + + + + True + False + True + center + 20 + 20 + + + True + False + start + center + 48 + qubes-unplug + + + False + True + 0 + + + + + True + False + start + This device is not currently connected. The assignment cannot be modified. + True + True + + False + True + 1 + + + + False + False + 3 + + + + + True + False + start + 2 + True + Device class: +Other description: + 0 + False True - 0 + 4 + + + + + True + False + start + Options + + + + False + True + 5 + + + + + True + False + <b>No strict reset</b> and <b>permissive</b> options should only be used in case of compatibility problems. They can lead to security issues. + True + True + 0 + + + + False + True + 6 + + + + + No strict reset + True + True + False + True + + + + False + True + 7 + + + + + Permissive + True + True + False + True + + + + False + True + 8 + + + + + Read-only + True + True + False + True + + + + False + True + 9 - + + Require port (device will match only when plugged into the currently used port) True True - True + False + True + + + + False + True + 10 + + + + + True + False start - none + Qubes + + + + False + True + 11 + + + + + True + False + vertical - + + True + False + 40 + + + + False + True + 0 + + + + True False + 5 10 - + True False - qubes-icon-add + center + 5 + 5 + True + False @@ -9671,13 +9679,50 @@ Other description: - + True - False - 5 - Add qube + True + True + start + none + + + True + False + 10 + + + True + False + qubes-icon-add + + + False + True + 0 + + + + + True + False + 5 + Add qube + + + + False + True + 1 + + + + @@ -9687,53 +9732,44 @@ Other description: + + False + True + 1 + + + + False + True + 13 + + + + + False + A device cannot be attached to its own backend qube. + True + True + 0 False True - 1 + 14 + - - False - True - 1 - - - False - True - 13 - - - - - False - A device cannot be attached to its own backend qube. - True - True - 0 - - - - False - True - 14 - - False diff --git a/qubes_config/global_config/device_attachments.py b/qubes_config/global_config/device_attachments.py index c0954255..70451fea 100644 --- a/qubes_config/global_config/device_attachments.py +++ b/qubes_config/global_config/device_attachments.py @@ -509,6 +509,7 @@ def run_for_new(self, new_row_function: Callable) -> DevPolicyRow: self._init_check(self.no_strict_check, False, False, "not selected") self._init_check(self.port_check, False, False, "not selected") + self.dev_combo.set_active_id(None) self.unknown_box.set_visible(False) self.qube_handler.reset() self.validate() @@ -989,7 +990,7 @@ def __init__( prefix="devices_auto", device_policy_manager=self.device_manager, classes=["block", "mic", "usb"], - assignment_filter=self._filter_required, + assignment_filter=self._filter_auto, edit_dialog_class=AutoDeviceDialog, ) self.required_devices_handler = AttachmentHandler( @@ -998,7 +999,7 @@ def __init__( prefix="devices_required", device_policy_manager=self.device_manager, classes=["block", "pci"], - assignment_filter=self._filter_auto, + assignment_filter=self._filter_required, edit_dialog_class=RequiredDeviceDialog, ) diff --git a/qubes_config/global_config/device_widgets.py b/qubes_config/global_config/device_widgets.py index 54c3c552..45faa403 100644 --- a/qubes_config/global_config/device_widgets.py +++ b/qubes_config/global_config/device_widgets.py @@ -25,7 +25,7 @@ import gi -from ..widgets.gtk_utils import ask_question +from ..widgets.gtk_utils import ask_question, resize_window_to_reasonable gi.require_version("Gtk", "3.0") from gi.repository import Gtk @@ -146,6 +146,11 @@ def __init__( self.ok_button.connect("clicked", self._save_changes) self.cancel_button.connect("clicked", self._cancel) + self.dialog.connect("size-allocate", self._resize_window) + + def _resize_window(self, *_args): + resize_window_to_reasonable(self.dialog) + def _cancel(self, *_args): if self.remove_on_cancel and self.current_row: self.current_row.get_parent().remove(self.current_row) @@ -176,6 +181,7 @@ def run_for_new(self, new_row_function: Callable) -> DevPolicyRow: self.remove_on_cancel = True self.dialog.show() + self._resize_window() return new_row @@ -185,6 +191,7 @@ def run_for_existing(self, row: DevPolicyRow): self.validate() self.remove_on_cancel = False self.dialog.show() + self._resize_window() def validate(self, *_args): """Connect this function to any events that should trigger diff --git a/qubes_config/global_config/global_config.py b/qubes_config/global_config/global_config.py index 697548ef..2ea395e8 100644 --- a/qubes_config/global_config/global_config.py +++ b/qubes_config/global_config/global_config.py @@ -35,6 +35,7 @@ show_dialog_with_icon, load_theme, is_theme_light, + resize_window_to_reasonable, ) from ..widgets.gtk_widgets import ProgressBarDialog, ViewportHandler from ..widgets.utils import open_url_in_disposable @@ -58,7 +59,7 @@ import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, GObject, Gio, Gdk +from gi.repository import Gtk, GLib, GObject, Gio logger = logging.getLogger("qubes-global-config") @@ -299,27 +300,7 @@ def do_activate(self, *args, **kwargs): self.perform_setup() assert self.main_window self.main_window.show() - # resize to screen size - if ( - self.main_window.get_allocated_width() - > self.main_window.get_screen().get_width() - ): - width = int(self.main_window.get_screen().get_width() * 0.9) - else: - # try to have at least 1100 pixels - width = min(int(self.main_window.get_screen().get_width() * 0.9), 1100) - if ( - self.main_window.get_allocated_height() - > self.main_window.get_screen().get_height() * 0.9 - ): - height = int(self.main_window.get_screen().get_height() * 0.9) - else: - height = self.main_window.get_allocated_height() - self.main_window.resize(width, height) - self.main_window.set_position(Gtk.WindowPosition.CENTER_ALWAYS) - self.main_window.set_gravity(Gdk.Gravity.CENTER) - self.main_window.move(0, 0) - self.main_window.set_position(Gtk.WindowPosition.CENTER) + resize_window_to_reasonable(self.main_window) # open at specified location if self.open_at: diff --git a/qubes_config/global_config/vm_flowbox.py b/qubes_config/global_config/vm_flowbox.py index a0df4479..03e88fd2 100644 --- a/qubes_config/global_config/vm_flowbox.py +++ b/qubes_config/global_config/vm_flowbox.py @@ -138,6 +138,8 @@ def __init__( filter_function=filter_function, ) + self.add_qube_model.connect_change_callback(self._check_for_add_validity) + self.flowbox.set_sort_func(self._sort_flowbox) self.placeholder = PlaceholderText() self.flowbox.add(self.placeholder) @@ -151,6 +153,12 @@ def __init__( self.add_button.connect("clicked", self._add_confirm_clicked) self.flowbox.connect("child-removed", self._vm_removed) + def _check_for_add_validity(self, *_args): + if self.add_qube_model.get_selected() is None: + self.add_button.set_sensitive(False) + else: + self.add_button.set_sensitive(True) + @staticmethod def _sort_flowbox(child_1, child_2): vm_1 = str(child_1) diff --git a/qubes_config/widgets/gtk_utils.py b/qubes_config/widgets/gtk_utils.py index 93175218..9dad5d07 100644 --- a/qubes_config/widgets/gtk_utils.py +++ b/qubes_config/widgets/gtk_utils.py @@ -275,3 +275,22 @@ def copy_to_global_clipboard(text: str): source.write("dom0") with open(XEVENT, "w", encoding="ascii") as timestamp: timestamp.write(str(Gtk.get_current_event_time())) + + +def resize_window_to_reasonable(window: Gtk.Window, min_width: int = 1100): + """Make sure the provided window is visible and does not exceed available screen + space.""" + if window.get_allocated_width() > window.get_screen().get_width(): + width = int(window.get_screen().get_width() * 0.9) + else: + # try to have at least min_width pixels + width = min(int(window.get_screen().get_width() * 0.9), min_width) + if window.get_allocated_height() > window.get_screen().get_height() * 0.9: + height = int(window.get_screen().get_height() * 0.9) + else: + height = window.get_allocated_height() + window.resize(width, height) + window.set_position(Gtk.WindowPosition.CENTER_ALWAYS) + window.set_gravity(Gdk.Gravity.CENTER) + window.move(0, 0) + window.set_position(Gtk.WindowPosition.CENTER)