From abc72ffe0e46b57dffc64a49b820a776e44057f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Thu, 28 Nov 2024 17:48:31 +0100 Subject: [PATCH 1/3] Warn users if the minimum version of Docker Desktop is not met This only happens on Windows and macOS. Fixes #693 --- dangerzone/gui/main_window.py | 48 ++++++++++++ dangerzone/isolation_provider/container.py | 30 +++++++- tests/gui/test_main_window.py | 54 ++++++++++++++ tests/isolation_provider/test_container.py | 87 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 2 deletions(-) diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py index d03300a17..6aad2c12f 100644 --- a/dangerzone/gui/main_window.py +++ b/dangerzone/gui/main_window.py @@ -124,6 +124,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None: self.setWindowTitle("Dangerzone") self.setWindowIcon(self.dangerzone.get_window_icon()) + self.alert: Optional[Alert] = None self.setMinimumWidth(600) if platform.system() == "Darwin": @@ -226,6 +227,13 @@ def __init__(self, dangerzone: DangerzoneGui) -> None: # This allows us to make QSS rules conditional on the OS color mode. self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value) + if hasattr(self.dangerzone.isolation_provider, "check_docker_desktop_version"): + is_version_valid, version = ( + self.dangerzone.isolation_provider.check_docker_desktop_version() + ) + if not is_version_valid: + self.handle_docker_desktop_version_check(is_version_valid, version) + self.show() def show_update_success(self) -> None: @@ -279,6 +287,46 @@ def toggle_updates_triggered(self) -> None: self.dangerzone.settings.set("updater_check", check) self.dangerzone.settings.save() + def handle_docker_desktop_version_check( + self, is_version_valid: bool, version: str + ) -> None: + hamburger_menu = self.hamburger_button.menu() + sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0]) + upgrade_action = QAction("Docker Desktop should be upgraded", hamburger_menu) + upgrade_action.setIcon( + QtGui.QIcon( + load_svg_image( + "hamburger_menu_update_dot_error.svg", width=64, height=64 + ) + ) + ) + + message = """ +

A new version of Docker Desktop is available. Please upgrade your system.

+

Visit the Docker Desktop website to download the latest version.

+ Keeping Docker Desktop up to date allows you to have more confidence that your documents are processed safely. + """ + self.alert = Alert( + self.dangerzone, + title="Upgrade Docker Desktop", + message=message, + ok_text="Ok", + has_cancel=False, + ) + + def _launch_alert() -> None: + if self.alert: + self.alert.launch() + + upgrade_action.triggered.connect(_launch_alert) + hamburger_menu.insertAction(sep, upgrade_action) + + self.hamburger_button.setIcon( + QtGui.QIcon( + load_svg_image("hamburger_menu_update_error.svg", width=64, height=64) + ) + ) + def handle_updates(self, report: UpdateReport) -> None: """Handle update reports from the update checker thread. diff --git a/dangerzone/isolation_provider/container.py b/dangerzone/isolation_provider/container.py index 27383ac6d..d87a421fe 100644 --- a/dangerzone/isolation_provider/container.py +++ b/dangerzone/isolation_provider/container.py @@ -3,7 +3,7 @@ import platform import shlex import subprocess -from typing import List +from typing import List, Tuple from .. import container_utils, errors from ..document import Document @@ -11,7 +11,10 @@ from .base import IsolationProvider, terminate_process_group TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns. - +MINIMUM_DOCKER_DESKTOP = { + "Darwin": "4.36.0", + "Windows": "4.36.0", +} # Define startupinfo for subprocesses if platform.system() == "Windows": @@ -121,6 +124,7 @@ def should_wait_install() -> bool: def is_available() -> bool: container_runtime = container_utils.get_runtime() runtime_name = container_utils.get_runtime_name() + # Can we run `docker/podman image ls` without an error with subprocess.Popen( [container_runtime, "image", "ls"], @@ -135,6 +139,28 @@ def is_available() -> bool: ) return True + def check_docker_desktop_version(self) -> Tuple[bool, str]: + # On windows and darwin, check that the minimum version is met + version = "" + if platform.system() != "Linux": + with subprocess.Popen( + ["docker", "version", "--format", "{{.Server.Platform.Name}}"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + startupinfo=get_subprocess_startupinfo(), + ) as p: + stdout, stderr = p.communicate() + if p.returncode != 0: + # In the case where there were an error, consider that + # the check went trough, as we're checking for installation + # compatibiliy somewhere else already + return True, version + # The output is like "Docker Desktop 4.35.1 (173168)" + version = stdout.decode().replace("Docker Desktop", "").split()[0] + if version < MINIMUM_DOCKER_DESKTOP[platform.system()]: + return False, version + return True, version + def doc_to_pixels_container_name(self, document: Document) -> str: """Unique container name for the doc-to-pixels phase.""" return f"dangerzone-doc-to-pixels-{document.id}" diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index ccc5db15d..e4fc12732 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -587,3 +587,57 @@ def test_installation_failure_return_false(qtbot: QtBot, mocker: MockerFixture) assert "the following error occured" in widget.label.text() assert "The image cannot be found" in widget.traceback.toPlainText() + + +def test_up_to_date_docker_desktop_does_nothing( + qtbot: QtBot, mocker: MockerFixture +) -> None: + # Setup install to return False + mock_app = mocker.MagicMock() + dummy = mocker.MagicMock(spec=Container) + dummy.check_docker_desktop_version.return_value = (True, "1.0.0") + dz = DangerzoneGui(mock_app, dummy) + + window = MainWindow(dz) + qtbot.addWidget(window) + + menu_actions = window.hamburger_button.menu().actions() + assert "Docker Desktop should be upgraded" not in [ + a.toolTip() for a in menu_actions + ] + + +def test_outdated_docker_desktop_displays_warning( + qtbot: QtBot, mocker: MockerFixture +) -> None: + # Setup install to return False + mock_app = mocker.MagicMock() + dummy = mocker.MagicMock(spec=Container) + dummy.check_docker_desktop_version.return_value = (False, "1.0.0") + + dz = DangerzoneGui(mock_app, dummy) + + load_svg_spy = mocker.spy(main_window_module, "load_svg_image") + + window = MainWindow(dz) + qtbot.addWidget(window) + + menu_actions = window.hamburger_button.menu().actions() + assert menu_actions[0].toolTip() == "Docker Desktop should be upgraded" + + # Check that the hamburger icon has changed with the expected SVG image. + assert load_svg_spy.call_count == 4 + assert ( + load_svg_spy.call_args_list[2].args[0] == "hamburger_menu_update_dot_error.svg" + ) + + alert_spy = mocker.spy(window.alert, "launch") + + # Clicking the menu item should open a warning message + def _check_alert_displayed() -> None: + alert_spy.assert_any_call() + if window.alert: + window.alert.close() + + QtCore.QTimer.singleShot(0, _check_alert_displayed) + menu_actions[0].trigger() diff --git a/tests/isolation_provider/test_container.py b/tests/isolation_provider/test_container.py index 15a393ffa..a43e24b68 100644 --- a/tests/isolation_provider/test_container.py +++ b/tests/isolation_provider/test_container.py @@ -1,4 +1,5 @@ import os +import platform import pytest from pytest_mock import MockerFixture @@ -108,6 +109,92 @@ def test_install_raises_if_still_not_installed( with pytest.raises(errors.ImageNotPresentException): provider.install() + @pytest.mark.skipif( + platform.system() not in ("Windows", "Darwin"), + reason="macOS and Windows specific", + ) + def test_old_docker_desktop_version_is_detected( + self, mocker: MockerFixture, provider: Container, fp: FakeProcess + ) -> None: + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stdout="Docker Desktop 1.0.0 (173100)", + ) + + mocker.patch( + "dangerzone.isolation_provider.container.MINIMUM_DOCKER_DESKTOP", + {"Darwin": "1.0.1", "Windows": "1.0.1"}, + ) + assert (False, "1.0.0") == provider.check_docker_desktop_version() + + @pytest.mark.skipif( + platform.system() not in ("Windows", "Darwin"), + reason="macOS and Windows specific", + ) + def test_up_to_date_docker_desktop_version_is_detected( + self, mocker: MockerFixture, provider: Container, fp: FakeProcess + ) -> None: + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stdout="Docker Desktop 1.0.1 (173100)", + ) + + # Require version 1.0.1 + mocker.patch( + "dangerzone.isolation_provider.container.MINIMUM_DOCKER_DESKTOP", + {"Darwin": "1.0.1", "Windows": "1.0.1"}, + ) + assert (True, "1.0.1") == provider.check_docker_desktop_version() + + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stdout="Docker Desktop 2.0.0 (173100)", + ) + assert (True, "2.0.0") == provider.check_docker_desktop_version() + + @pytest.mark.skipif( + platform.system() not in ("Windows", "Darwin"), + reason="macOS and Windows specific", + ) + def test_docker_desktop_version_failure_returns_true( + self, mocker: MockerFixture, provider: Container, fp: FakeProcess + ) -> None: + fp.register_subprocess( + [ + "docker", + "version", + "--format", + "{{.Server.Platform.Name}}", + ], + stderr="Oopsie", + returncode=1, + ) + assert provider.check_docker_desktop_version() == (True, "") + + @pytest.mark.skipif( + platform.system() != "Linux", + reason="Linux specific", + ) + def test_linux_skips_desktop_version_check_returns_true( + self, mocker: MockerFixture, provider: Container + ) -> None: + assert (True, "") == provider.check_docker_desktop_version() + class TestContainerTermination(IsolationProviderTermination): pass From a7e39a04adaf724003939bc6bf77deede5967f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Wed, 18 Dec 2024 14:32:18 +0100 Subject: [PATCH 2/3] Bind `Alert` instances to the main window `alert` property --- dangerzone/gui/main_window.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py index 6aad2c12f..80301e1e5 100644 --- a/dangerzone/gui/main_window.py +++ b/dangerzone/gui/main_window.py @@ -413,7 +413,7 @@ def waiting_finished(self) -> None: self.content_widget.show() def closeEvent(self, e: QtGui.QCloseEvent) -> None: - alert_widget = Alert( + self.alert = Alert( self.dangerzone, message="Some documents are still being converted.\n Are you sure you want to quit?", ok_text="Abort conversions", @@ -427,7 +427,7 @@ def closeEvent(self, e: QtGui.QCloseEvent) -> None: else: self.dangerzone.app.exit(0) else: - accept_exit = alert_widget.launch() + accept_exit = self.alert.launch() if not accept_exit: e.ignore() return @@ -671,7 +671,7 @@ def __init__(self, dangerzone: DangerzoneGui) -> None: def documents_selected(self, docs: List[Document]) -> None: if self.conversion_started: - Alert( + self.alert = Alert( self.dangerzone, message="Dangerzone does not support adding documents after the conversion has started.", has_cancel=False, @@ -681,7 +681,7 @@ def documents_selected(self, docs: List[Document]) -> None: # Ensure all files in batch are in the same directory dirnames = {os.path.dirname(doc.input_filename) for doc in docs} if len(dirnames) > 1: - Alert( + self.alert = Alert( self.dangerzone, message="Dangerzone does not support adding documents from multiple locations.\n\n The newly added documents were ignored.", has_cancel=False, @@ -850,14 +850,14 @@ def prompt_continue_without(self, num_unsupported_docs: int) -> int: text = f"{num_unsupported_docs} files are not supported." ok_text = "Continue without these files" - alert_widget = Alert( + self.alert = Alert( self.dangerzone, message=f"{text}\nThe supported extensions are: " + ", ".join(get_supported_extensions()), ok_text=ok_text, ) - return alert_widget.exec_() + return self.alert.exec_() class SettingsWidget(QtWidgets.QWidget): From 48ad7499655211087681aae3e84bdf1482c7a094 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Wed, 18 Dec 2024 14:47:30 +0100 Subject: [PATCH 3/3] doc: bump the Docker Desktop version as part of the RELEASE procedure --- RELEASE.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 6e58338ca..75642d2d9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -8,12 +8,13 @@ Here is a list of tasks that should be done before issuing the release: - [ ] Create a new issue named **QA and Release for version \**, to track the general progress. You can generate its content with the the `poetry run ./dev_scripts/generate-release-tasks.py` command. -- [ ] [Add new Linux platforms and remove obsolete ones](https://github.com/freedomofpress/dangerzone/blob/main/RELEASE.md#add-new-platforms-and-remove-obsolete-ones) +- [ ] [Add new Linux platforms and remove obsolete ones](https://github.com/freedomofpress/dangerzone/blob/main/RELEASE.md#add-new-linux-platforms-and-remove-obsolete-ones) - [ ] Bump the Python dependencies using `poetry lock` - [ ] Update `version` in `pyproject.toml` - [ ] Update `share/version.txt` - [ ] Update the "Version" field in `install/linux/dangerzone.spec` - [ ] Bump the Debian version by adding a new changelog entry in `debian/changelog` +- [ ] [Bump the minimum Docker Desktop versions](https://github.com/freedomofpress/dangerzone/blob/main/RELEASE.md#bump-the-minimum-docker-desktop-version) in `isolation_provider/container.py` - [ ] Update screenshot in `README.md`, if necessary - [ ] CHANGELOG.md should be updated to include a list of all major changes since the last release - [ ] A draft release should be created. Copy the release notes text from the template at [`docs/templates/release-notes`](https://github.com/freedomofpress/dangerzone/tree/main/docs/templates/) @@ -46,6 +47,12 @@ In case of the removal of a version: * Consult the previous paragraph, but also `grep` your way around. 2. Add a notice in our `CHANGELOG.md` about the version removal. +## Bump the minimum Docker Desktop version + +We embed the minimum docker desktop versions inside Dangerzone, as an incentive for our macOS and Windows users to upgrade to the latests version. + +You can find the latest version at the time of the release by looking at [their release notes](https://docs.docker.com/desktop/release-notes/) + ## Large Document Testing Parallel to the QA process, the release candidate should be put through the large document tests in a dedicated machine to run overnight.