diff --git a/CHANGELOG.md b/CHANGELOG.md index c1662f3..60fc9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or * Prevent attacker from becoming root within the container ([#224](https://github.com/freedomofpress/dangerzone/issues/224)) * Use a restricted seccomp profile ([#225](https://github.com/freedomofpress/dangerzone/issues/225)) * Make use of user namespaces ([#228](https://github.com/freedomofpress/dangerzone/issues/228)) +- Files can now be drag-n-dropped to Dangerzone ([issue #409](https://github.com/freedomofpress/dangerzone/issues/409)) ### Fixed diff --git a/RELEASE.md b/RELEASE.md index 1c69a24..b724f60 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -209,21 +209,26 @@ Run Dangerzone against a list of documents, and tick all options. Ensure that: location. * The original files have been saved in the `unsafe/` directory. -#### 8. Dangerzone CLI succeeds in converting multiple documents +#### 8. Dangerzone is able to handle drag-n-drop + +Run Dangerzone against a set of documents that you drag-n-drop. Files should be +added and conversion should run without issue. + +#### 9. Dangerzone CLI succeeds in converting multiple documents _(Only for Windows and Linux)_ Run Dangerzone CLI against a list of documents. Ensure that conversions happen sequentially, are completed successfully, and we see their progress. -#### 9. Dangerzone can open a document for conversion via right-click -> "Open With" +#### 10. Dangerzone can open a document for conversion via right-click -> "Open With" _(Only for Windows, MacOS and Qubes)_ Go to a directory with office documents, right-click on one, and click on "Open With". We should be able to open the file with Dangerzone, and then convert it. -#### 10. Dangerzone shows helpful errors for setup issues on Qubes +#### 11. Dangerzone shows helpful errors for setup issues on Qubes _(Only for Qubes)_ diff --git a/dangerzone/gui/main_window.py b/dangerzone/gui/main_window.py index acc9b96..4c739d0 100644 --- a/dangerzone/gui/main_window.py +++ b/dangerzone/gui/main_window.py @@ -53,6 +53,60 @@ about updates.

HAMBURGER_MENU_SIZE = 30 +def load_svg_image(filename: str, width: int, height: int) -> QtGui.QPixmap: + """Load an SVG image from a filename. + + This answer is basically taken from: https://stackoverflow.com/a/25689790 + """ + path = get_resource_path(filename) + svg_renderer = QtSvg.QSvgRenderer(path) + image = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32) + # Set the ARGB to 0 to prevent rendering artifacts + image.fill(0x00000000) + svg_renderer.render(QtGui.QPainter(image)) + pixmap = QtGui.QPixmap.fromImage(image) + return pixmap + + +def get_supported_extensions() -> List[str]: + supported_ext = [ + ".pdf", + ".docx", + ".doc", + ".docm", + ".xlsx", + ".xls", + ".pptx", + ".ppt", + ".odt", + ".odg", + ".odp", + ".ods", + ".epub", + ".jpg", + ".jpeg", + ".gif", + ".png", + ".tif", + ".tiff", + ".bmp", + ".pnm", + ".pbm", + ".ppm", + ".svg", + ] + + # XXX: We disable loading HWP/HWPX files on Qubes, because H2ORestart does not work there. + # See: + # + # https://github.com/freedomofpress/dangerzone/issues/494 + hwp_filters = [".hwp", ".hwpx"] + if is_qubes_native_conversion(): + supported_ext += hwp_filters + + return supported_ext + + class MainWindow(QtWidgets.QMainWindow): def __init__(self, dangerzone: DangerzoneGui) -> None: super(MainWindow, self).__init__() @@ -87,7 +141,7 @@ class MainWindow(QtWidgets.QMainWindow): self.hamburger_button = QtWidgets.QToolButton() self.hamburger_button.setPopupMode(QtWidgets.QToolButton.InstantPopup) self.hamburger_button.setIcon( - QtGui.QIcon(self.load_svg_image("hamburger_menu.svg")) + QtGui.QIcon(load_svg_image("hamburger_menu.svg", width=64, height=64)) ) self.hamburger_button.setFixedSize(HAMBURGER_MENU_SIZE, HAMBURGER_MENU_SIZE) self.hamburger_button.setIconSize( @@ -168,20 +222,6 @@ class MainWindow(QtWidgets.QMainWindow): self.show() - def load_svg_image(self, filename: str) -> QtGui.QPixmap: - """Load an SVG image from a filename. - - This answer is basically taken from: https://stackoverflow.com/a/25689790 - """ - path = get_resource_path(filename) - svg_renderer = QtSvg.QSvgRenderer(path) - image = QtGui.QImage(64, 64, QtGui.QImage.Format_ARGB32) - # Set the ARGB to 0 to prevent rendering artifacts - image.fill(0x00000000) - svg_renderer.render(QtGui.QPainter(image)) - pixmap = QtGui.QPixmap.fromImage(image) - return pixmap - def show_update_success(self) -> None: """Inform the user about a new Dangerzone release.""" version = self.dangerzone.settings.get("updater_latest_version") @@ -262,13 +302,21 @@ class MainWindow(QtWidgets.QMainWindow): return self.hamburger_button.setIcon( - QtGui.QIcon(self.load_svg_image("hamburger_menu_update_error.svg")) + QtGui.QIcon( + load_svg_image( + "hamburger_menu_update_error.svg", width=64, height=64 + ) + ) ) sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0]) # FIXME: Add red bubble next to the text. error_action = QAction("Update error", hamburger_menu) error_action.setIcon( - QtGui.QIcon(self.load_svg_image("hamburger_menu_update_dot_error.svg")) + QtGui.QIcon( + load_svg_image( + "hamburger_menu_update_dot_error.svg", width=64, height=64 + ) + ) ) error_action.triggered.connect(self.show_update_error) hamburger_menu.insertAction(sep, error_action) @@ -283,14 +331,20 @@ class MainWindow(QtWidgets.QMainWindow): self.dangerzone.settings.save() self.hamburger_button.setIcon( - QtGui.QIcon(self.load_svg_image("hamburger_menu_update_success.svg")) + QtGui.QIcon( + load_svg_image( + "hamburger_menu_update_success.svg", width=64, height=64 + ) + ) ) sep = hamburger_menu.insertSeparator(hamburger_menu.actions()[0]) success_action = QAction("New version available", hamburger_menu) success_action.setIcon( QtGui.QIcon( - self.load_svg_image("hamburger_menu_update_dot_available.svg") + load_svg_image( + "hamburger_menu_update_dot_available.svg", width=64, height=64 + ) ) ) success_action.triggered.connect(self.show_update_success) @@ -455,6 +509,10 @@ class ContentWidget(QtWidgets.QWidget): # Doc selection widget self.doc_selection_widget = DocSelectionWidget(self.dangerzone) self.doc_selection_widget.documents_selected.connect(self.documents_selected) + self.doc_selection_wrapper = DocSelectionDropFrame( + self.dangerzone, self.doc_selection_widget + ) + self.doc_selection_wrapper.documents_selected.connect(self.documents_selected) # Settings self.settings_widget = SettingsWidget(self.dangerzone) @@ -475,7 +533,7 @@ class ContentWidget(QtWidgets.QWidget): layout = QtWidgets.QVBoxLayout() layout.addWidget(self.settings_widget, stretch=1) layout.addWidget(self.documents_list, stretch=1) - layout.addWidget(self.doc_selection_widget, stretch=1) + layout.addWidget(self.doc_selection_wrapper, stretch=1) self.setLayout(layout) def documents_selected(self, docs: List[Document]) -> None: @@ -505,7 +563,7 @@ class ContentWidget(QtWidgets.QWidget): for doc in docs: self.dangerzone.add_document(doc) - self.doc_selection_widget.hide() + self.doc_selection_wrapper.hide() self.settings_widget.show() if len(docs) > 0: @@ -551,20 +609,8 @@ class DocSelectionWidget(QtWidgets.QWidget): self.file_dialog = QtWidgets.QFileDialog() self.file_dialog.setWindowTitle("Open Documents") self.file_dialog.setFileMode(QtWidgets.QFileDialog.ExistingFiles) - - # XXX: We disable loading HWP/HWPX files on Qubes, because H2ORestart does not work there. - # See: - # - # https://github.com/freedomofpress/dangerzone/issues/494 - hwp_filters = "*.hwp *.hwpx" - if is_qubes_native_conversion(): - hwp_filters = "" self.file_dialog.setNameFilters( - [ - "Documents (*.pdf *.docx *.doc *.docm *.xlsx *.xls *.pptx *.ppt *.odt" - f" *.odg *.odp *.ods {hwp_filters} *.epub *.jpg *.jpeg *.gif *.png" - " *.tif *.tiff *.bmp *.pnm *.pbm *.ppm *.svg)" - ] + ["Documents (*" + " *".join(get_supported_extensions()) + ")"] ) def dangerous_doc_button_clicked(self) -> None: @@ -584,6 +630,103 @@ class DocSelectionWidget(QtWidgets.QWidget): pass +class DocSelectionDropFrame(QtWidgets.QFrame): + """ + HACK Docs selecting widget "drag-n-drop" border widget + The border frame doesn't show around the whole widget + unless there is another widget wrapping it + """ + + documents_selected = QtCore.Signal(list) + + def __init__( + self, dangerzone: DangerzoneGui, docs_selection_widget: DocSelectionWidget + ) -> None: + super().__init__() + + self.dangerzone = dangerzone + self.docs_selection_widget = docs_selection_widget + + # Drag and drop functionality + self.setAcceptDrops(True) + + self.document_image_text = QtWidgets.QLabel( + "Drag and drop\ndocuments here\n\nor" + ) + self.document_image_text.setAlignment(QtCore.Qt.AlignCenter) + self.document_image = QtWidgets.QLabel() + self.document_image.setAlignment(QtCore.Qt.AlignCenter) + self.document_image.setPixmap( + load_svg_image("document.svg", width=20, height=24) + ) + + self.center_layout = QtWidgets.QVBoxLayout() + self.center_layout.addWidget(self.document_image) + self.center_layout.addWidget(self.document_image_text) + self.center_layout.addWidget(self.docs_selection_widget) + + self.drop_layout = QtWidgets.QVBoxLayout() + self.drop_layout.addStretch() + self.drop_layout.addLayout(self.center_layout) + self.drop_layout.addStretch() + + self.setLayout(self.drop_layout) + + def dragEnterEvent(self, ev: QtGui.QDragEnterEvent) -> None: + ev.accept() + + def dragLeaveEvent(self, ev: QtGui.QDragLeaveEvent) -> None: + ev.accept() + + def dropEvent(self, ev: QtGui.QDropEvent) -> None: + ev.setDropAction(QtCore.Qt.CopyAction) + documents = [] + supported_exts = get_supported_extensions() + for url_path in ev.mimeData().urls(): + doc_path = url_path.toLocalFile() + doc_ext = os.path.splitext(doc_path)[1] + if doc_ext in supported_exts: + documents += [Document(doc_path)] + + # Ignore anything dropped that's not a file (e.g. text) + if len(documents) == 0: + return + + # Ignore when all dropped files are unsupported + total_dragged_docs = len(ev.mimeData().urls()) + num_unsupported_docs = total_dragged_docs - len(documents) + + if num_unsupported_docs == total_dragged_docs: + return + + # Confirm with user when _some_ docs were ignored + if num_unsupported_docs > 0: + if not self.prompt_continue_without(num_unsupported_docs): + return + self.documents_selected.emit(documents) + + def prompt_continue_without(self, num_unsupported_docs: int) -> int: + """ + Prompt the user if they want to convert even though some files are not + supported. + """ + if num_unsupported_docs == 1: + text = "1 file is not supported." + ok_text = "Continue without this file" + else: # plural + text = f"{num_unsupported_docs} files are not supported." + ok_text = "Continue without these files" + + alert_widget = Alert( + self.dangerzone, + message=f"{text}\nThe supported extensions are: " + + ", ".join(get_supported_extensions()), + ok_text=ok_text, + ) + + return alert_widget.exec_() + + class SettingsWidget(QtWidgets.QWidget): start_clicked = QtCore.Signal() change_docs_clicked = QtCore.Signal() diff --git a/dev_scripts/qa.py b/dev_scripts/qa.py index ac3c4c9..5199426 100755 --- a/dev_scripts/qa.py +++ b/dev_scripts/qa.py @@ -153,21 +153,26 @@ Run Dangerzone against a list of documents, and tick all options. Ensure that: location. * The original files have been saved in the `unsafe/` directory. -#### 8. Dangerzone CLI succeeds in converting multiple documents +#### 8. Dangerzone is able to handle drag-n-drop + +Run Dangerzone against a set of documents that you drag-n-drop. Files should be +added and conversion should run without issue. + +#### 9. Dangerzone CLI succeeds in converting multiple documents _(Only for Windows and Linux)_ Run Dangerzone CLI against a list of documents. Ensure that conversions happen sequentially, are completed successfully, and we see their progress. -#### 9. Dangerzone can open a document for conversion via right-click -> "Open With" +#### 10. Dangerzone can open a document for conversion via right-click -> "Open With" _(Only for Windows, MacOS and Qubes)_ Go to a directory with office documents, right-click on one, and click on "Open With". We should be able to open the file with Dangerzone, and then convert it. -#### 10. Dangerzone shows helpful errors for setup issues on Qubes +#### 11. Dangerzone shows helpful errors for setup issues on Qubes _(Only for Qubes)_ diff --git a/share/dangerzone.css b/share/dangerzone.css index 393b993..45fdc2b 100644 --- a/share/dangerzone.css +++ b/share/dangerzone.css @@ -13,6 +13,11 @@ QDialog[OSColorMode="light"] QWidget { color: black; } +DocSelectionDropFrame{ + border: 2px dashed rgb(193, 193, 193); + border-radius: 5px; + margin: 5px; +} /* * QLabel left-adjacent to a QLineEdit to give the illusion * that it is part of it, but just not editable diff --git a/share/document.svg b/share/document.svg new file mode 100644 index 0000000..e9caf2f --- /dev/null +++ b/share/document.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/gui/test_main_window.py b/tests/gui/test_main_window.py index 16f23fd..2fe29d9 100644 --- a/tests/gui/test_main_window.py +++ b/tests/gui/test_main_window.py @@ -2,17 +2,22 @@ import os import pathlib import shutil import time +from typing import List -from PySide6 import QtCore from pytest import MonkeyPatch, fixture from pytest_mock import MockerFixture from pytestqt.qtbot import QtBot +from dangerzone.document import Document from dangerzone.gui import MainWindow from dangerzone.gui import main_window as main_window_module from dangerzone.gui import updater as updater_module from dangerzone.gui.logic import DangerzoneGui -from dangerzone.gui.main_window import ContentWidget +from dangerzone.gui.main_window import ( # import Pyside related objects from here to avoid duplicating import logic. + ContentWidget, + QtCore, + QtGui, +) from dangerzone.gui.updater import UpdateReport, UpdaterThread from .test_updater import assert_report_equal, default_updater_settings @@ -33,6 +38,51 @@ def content_widget(qtbot: QtBot, mocker: MockerFixture) -> ContentWidget: return w +def drag_files_event(mocker: MockerFixture, files: List[str]) -> QtGui.QDropEvent: + ev = mocker.MagicMock(spec=QtGui.QDropEvent) + ev.accept.return_value = True + + urls = [QtCore.QUrl.fromLocalFile(x) for x in files] + ev.mimeData.return_value.has_urls.return_value = True + ev.mimeData.return_value.urls.return_value = urls + return ev + + +@fixture +def drag_valid_files_event( + mocker: MockerFixture, sample_doc: str, sample_pdf: str +) -> QtGui.QDropEvent: + return drag_files_event(mocker, [sample_doc, sample_pdf]) + + +@fixture +def drag_1_invalid_file_event( + mocker: MockerFixture, sample_doc: str, tmp_path: pathlib.Path +) -> QtGui.QDropEvent: + unsupported_file_path = tmp_path / "file.unsupported" + shutil.copy(sample_doc, unsupported_file_path) + return drag_files_event(mocker, [str(unsupported_file_path)]) + + +@fixture +def drag_1_invalid_and_2_valid_files_event( + mocker: MockerFixture, tmp_path: pathlib.Path, sample_doc: str, sample_pdf: str +) -> QtGui.QDropEvent: + unsupported_file_path = tmp_path / "file.unsupported" + shutil.copy(sample_doc, unsupported_file_path) + return drag_files_event( + mocker, [sample_doc, sample_pdf, str(unsupported_file_path)] + ) + + +@fixture +def drag_text_event(mocker: MockerFixture) -> QtGui.QDropEvent: + ev = mocker.MagicMock() + ev.accept.return_value = True + ev.mimeData.return_value.has_urls.return_value = False + return ev + + def test_default_menu( qtbot: QtBot, updater: UpdaterThread, @@ -121,7 +171,7 @@ def test_update_detected( window = MainWindow(qt_updater.dangerzone) window.register_update_handler(qt_updater.finished) handle_updates_spy = mocker.spy(window, "handle_updates") - load_svg_spy = mocker.spy(window, "load_svg_image") + load_svg_spy = mocker.spy(main_window_module, "load_svg_image") menu_actions_before = window.hamburger_button.menu().actions() @@ -231,7 +281,7 @@ def test_update_error( window = MainWindow(qt_updater.dangerzone) window.register_update_handler(qt_updater.finished) handle_updates_spy = mocker.spy(window, "handle_updates") - load_svg_spy = mocker.spy(window, "load_svg_image") + load_svg_spy = mocker.spy(main_window_module, "load_svg_image") menu_actions_before = window.hamburger_button.menu().actions() @@ -374,3 +424,71 @@ def test_change_document_button( ] assert len(docs) == 1 assert docs[0] == str(tmp_sample_doc) + + +def test_drop_valid_documents( + content_widget: ContentWidget, + drag_valid_files_event: QtGui.QDropEvent, + qtbot: QtBot, +) -> None: + with qtbot.waitSignal( + content_widget.doc_selection_wrapper.documents_selected, + check_params_cb=lambda x: len(x) == 2 and isinstance(x[0], Document), + ): + content_widget.doc_selection_wrapper.dropEvent(drag_valid_files_event) + + +def test_drop_text( + content_widget: ContentWidget, + drag_text_event: QtGui.QDropEvent, + qtbot: QtBot, +) -> None: + with qtbot.assertNotEmitted( + content_widget.doc_selection_wrapper.documents_selected + ): + content_widget.doc_selection_wrapper.dropEvent(drag_text_event) + + +def test_drop_1_invalid_doc( + content_widget: ContentWidget, + drag_1_invalid_file_event: QtGui.QDropEvent, + qtbot: QtBot, +) -> None: + with qtbot.assertNotEmitted( + content_widget.doc_selection_wrapper.documents_selected + ): + content_widget.doc_selection_wrapper.dropEvent(drag_1_invalid_file_event) + + +def test_drop_1_invalid_2_valid_documents( + content_widget: ContentWidget, + drag_1_invalid_and_2_valid_files_event: QtGui.QDropEvent, + qtbot: QtBot, + monkeypatch: MonkeyPatch, +) -> None: + # If we accept to continue + monkeypatch.setattr( + content_widget.doc_selection_wrapper, "prompt_continue_without", lambda x: True + ) + + # Then the 2 valid docs will be selected + with qtbot.waitSignal( + content_widget.doc_selection_wrapper.documents_selected, + check_params_cb=lambda x: len(x) == 2 and isinstance(x[0], Document), + ): + content_widget.doc_selection_wrapper.dropEvent( + drag_1_invalid_and_2_valid_files_event + ) + + # If we refuse to continue + monkeypatch.setattr( + content_widget.doc_selection_wrapper, "prompt_continue_without", lambda x: False + ) + + # Then no docs will be selected + with qtbot.assertNotEmitted( + content_widget.doc_selection_wrapper.documents_selected, + ): + content_widget.doc_selection_wrapper.dropEvent( + drag_1_invalid_and_2_valid_files_event + )