From d0e1df5546eb3f486e7685f1c47fdfb897a6dae4 Mon Sep 17 00:00:00 2001
From: deeplow
Date: Wed, 20 Mar 2024 18:44:21 +0000
Subject: [PATCH] Add drag and drop support for document selection
---
CHANGELOG.md | 1 +
RELEASE.md | 11 +-
dangerzone/gui/main_window.py | 211 ++++++++++++++++++++++++++++------
dev_scripts/qa.py | 11 +-
share/dangerzone.css | 5 +
share/document.svg | 1 +
tests/gui/test_main_window.py | 126 +++++++++++++++++++-
7 files changed, 322 insertions(+), 44 deletions(-)
create mode 100644 share/document.svg
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
+ )