Add drag and drop support for document selection

This commit is contained in:
deeplow 2024-03-20 18:44:21 +00:00 committed by Alexis Métaireau
parent 7744cd55ec
commit d0e1df5546
No known key found for this signature in database
GPG key ID: C65C7A89A8FFC56E
7 changed files with 322 additions and 44 deletions

View file

@ -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

View file

@ -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)_

View file

@ -53,6 +53,60 @@ about updates.</p>
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()

View file

@ -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)_

View file

@ -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

1
share/document.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="20.308" xmlns="http://www.w3.org/2000/svg" height="24" id="screenshot-629d13e7-3095-8022-8003-fd3e19dbffbc" viewBox="10023.346 5693.5 20.308 24" style="-webkit-print-color-adjust: exact;" fill="none" version="1.1"><g id="shape-629d13e7-3095-8022-8003-fd3e19dbffbc"><g class="fills" id="fills-629d13e7-3095-8022-8003-fd3e19dbffbc"><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" rx="0" ry="0" d="M10026.115,5695.346C10025.606,5695.346,10025.192,5695.759,10025.192,5696.269L10025.192,5714.731C10025.192,5715.240,10025.606,5715.654,10026.115,5715.654L10040.885,5715.654C10041.394,5715.654,10041.808,5715.240,10041.808,5714.731L10041.808,5702.731L10035.346,5702.731C10034.836,5702.731,10034.423,5702.317,10034.423,5701.808L10034.423,5695.346L10026.115,5695.346ZZM10036.269,5696.652L10040.502,5700.885L10036.269,5700.885L10036.269,5696.652ZZM10023.346,5696.269C10023.346,5694.740,10024.586,5693.500,10026.115,5693.500L10035.346,5693.500C10035.591,5693.500,10035.826,5693.597,10035.999,5693.770L10043.384,5701.155C10043.557,5701.328,10043.654,5701.563,10043.654,5701.808L10043.654,5714.731C10043.654,5716.260,10042.414,5717.500,10040.885,5717.500L10026.115,5717.500C10024.586,5717.500,10023.346,5716.260,10023.346,5714.731L10023.346,5696.269ZZ" style="fill: rgb(153, 153, 153); fill-opacity: 1;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

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