client/securedrop_client/gui/conversation/export/export_wizard.py (157 lines of code) (raw):

import logging from gettext import gettext as _ from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtGui import QIcon, QKeyEvent from PyQt5.QtWidgets import ( QAbstractButton, QApplication, QWidget, QWizard, QWizardPage, ) from securedrop_client.export import Export from securedrop_client.export_status import ExportStatus from securedrop_client.gui.base import SecureQLabel from securedrop_client.gui.conversation.export.export_wizard_constants import Pages from securedrop_client.gui.conversation.export.export_wizard_page import ( ErrorPage, ExportWizardPage, FinalPage, InsertUSBPage, PassphraseWizardPage, PreflightPage, ) from securedrop_client.resources import load_movie, load_relative_css logger = logging.getLogger(__name__) class ExportWizard(QWizard): """ Guide user through the steps of exporting to a USB. """ PASSPHRASE_LABEL_SPACING = 0.5 NO_MARGIN = 0 FILENAME_WIDTH_PX = 260 FILE_OPTIONS_FONT_SPACING = 1.6 # If the drive is unlocked, we don't need a passphrase; if we do need one, # it's populated later. PASS_PLACEHOLDER_FIELD = "" def __init__( self, export: Export, summary_text: str, filepaths: list[str], parent: QWidget | None = None, ) -> None: # Normally, the active window is the right parent, but if the wizard is launched # via another element (a modal dialog, such as the "Some files may not be exported" # modal), the parent will be the modal dialog and the wizard layout will be affected. # In those cases we want to be able to specify a different parent. if not parent: parent = QApplication.activeWindow() super().__init__(parent) self.export = export self.summary_text = SecureQLabel( summary_text, wordwrap=False, max_length=self.FILENAME_WIDTH_PX ).text() self.filepaths = filepaths self.current_status: ExportStatus | None = None # Signal from qrexec command runner self.export.export_state_changed.connect(self.on_status_received) # Sends cleanup signal to export if wizard is closed or completed. # (Avoid orphaned QProcess) self.finished.connect(self.export.end_process) self._style_buttons() self._set_layout() self._set_pages() self.adjustSize() def keyPressEvent(self, event: QKeyEvent) -> None: """ Allow for keyboard navigation of wizard buttons. """ if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: if self.cancel_button.hasFocus(): self.cancel_button.click() elif self.back_button.hasFocus(): self.back_button.click() else: self.next_button.click() else: super().keyPressEvent(event) def _style_buttons(self) -> None: """ Style QWizard buttons and connect "Next" button click event to request_export slot. """ # Activestate animation self.button_animation = load_movie("activestate-wide.gif") self.button_animation.setScaledSize(QSize(32, 32)) self.button_animation.frameChanged.connect(self._animate_activestate) button_stylesheet = load_relative_css(__file__, "wizard_button.css") # Buttons self.next_button: QAbstractButton = self.button(QWizard.WizardButton.NextButton) self.cancel_button: QAbstractButton = self.button(QWizard.WizardButton.CancelButton) self.back_button: QAbstractButton = self.button(QWizard.WizardButton.BackButton) self.finish_button: QAbstractButton = self.button(QWizard.WizardButton.FinishButton) self.next_button.setObjectName("QWizardButton_PrimaryButton") self.next_button.setStyleSheet(button_stylesheet) self.next_button.setMinimumSize(QSize(142, 40)) self.next_button.setMaximumHeight(40) self.next_button.setIconSize(QSize(21, 21)) self.next_button.clicked.connect(self.request_export) self.next_button.setFixedSize(QSize(142, 40)) self.cancel_button.setObjectName("QWizardButton_GenericButton") self.cancel_button.setStyleSheet(button_stylesheet) self.cancel_button.setMinimumSize(QSize(142, 40)) self.cancel_button.setMaximumHeight(40) self.cancel_button.setFixedSize(QSize(142, 40)) self.back_button.setObjectName("QWizardButton_GenericButton") self.back_button.setStyleSheet(button_stylesheet) self.back_button.setMinimumSize(QSize(142, 40)) self.back_button.setMaximumHeight(40) self.back_button.setFixedSize(QSize(142, 40)) self.finish_button.setObjectName("QWizardButton_GenericButton") self.finish_button.setStyleSheet(button_stylesheet) self.finish_button.setMinimumSize(QSize(142, 40)) self.finish_button.setMaximumHeight(40) self.finish_button.setFixedSize(QSize(142, 40)) self.setButtonText(QWizard.WizardButton.NextButton, _("CONTINUE")) self.setButtonText(QWizard.WizardButton.CancelButton, _("CANCEL")) self.setButtonText(QWizard.WizardButton.FinishButton, _("DONE")) self.setButtonText(QWizard.WizardButton.BackButton, _("BACK")) def _animate_activestate(self) -> None: self.next_button.setIcon(QIcon(self.button_animation.currentPixmap())) def _start_animate_activestate(self) -> None: self.button_animation.start() def _stop_animate_activestate(self) -> None: self.next_button.setIcon(QIcon()) self.button_animation.stop() def _set_layout(self) -> None: title = f"Export {self.summary_text}" self.setWindowTitle(title) self.setObjectName("QWizard_export") self.setStyleSheet(load_relative_css(__file__, "wizard.css")) self.setModal(False) self.setOptions( QWizard.NoBackButtonOnLastPage | QWizard.NoCancelButtonOnLastPage | QWizard.NoBackButtonOnStartPage ) def _set_pages(self) -> None: for id, page in [ (Pages.PREFLIGHT, self._create_preflight()), (Pages.ERROR, self._create_errorpage()), (Pages.INSERT_USB, self._create_insert_usb()), (Pages.UNLOCK_USB, self._create_passphrase_prompt()), (Pages.EXPORT_DONE, self._create_done()), ]: self.setPage(id, page) self.adjustSize() @pyqtSlot() def request_export(self) -> None: """ Handler for "next" button clicks. Start animation and request export. (The export proceeds only as far as it's able, which is why it's possible to trigger the same method on every dialog page). The Preflight QWizardPage triggers the preflight check itself when it is created, so there is no corresponding `request_export_preflight` method. """ logger.debug("Request export") # While we're waiting for the results to come back, stay on the same page. # This prevents the dialog from briefly flashing one page and then # advancing to a subsequent page (for example, flashing the "Insert a USB" # page before detecting the USB and advancing to the "Unlock USB" page) page = self.currentPage() if isinstance(page, ExportWizardPage): page.set_complete(False) self._start_animate_activestate() # Disable the continue button until the qProcess completes self.next_button.setEnabled(False) # Registered fields let us access the passphrase field # of the PassphraseRequestPage from the wizard parent passphrase_untrusted = self.field("passphrase") if str(passphrase_untrusted) is not None: self.export.export(self.filepaths, str(passphrase_untrusted)) else: self.export.export(self.filepaths, self.PASS_PLACEHOLDER_FIELD) @pyqtSlot(object) def on_status_received(self, status: ExportStatus) -> None: """ Receive status update from export process in order to update the animation. Child QWizardPages also implement this listener in order to update their own UI and store a reference to the current status. Adjusting the QWizard control flow based on ExportStatus is handled by each child page. """ # Release the page (page was held during "next" button click event) page = self.currentPage() if isinstance(page, ExportWizardPage): page.set_complete(True) self._stop_animate_activestate() self.current_status = status # Button was disabled when the previous request was made; re-enable it self.next_button.setEnabled(True) def _create_preflight(self) -> QWizardPage: return PreflightPage(self.export, self.summary_text) def _create_errorpage(self) -> QWizardPage: return ErrorPage(self.export) def _create_insert_usb(self) -> QWizardPage: return InsertUSBPage(self.export, self.summary_text) def _create_passphrase_prompt(self) -> QWizardPage: return PassphraseWizardPage(self.export) def _create_done(self) -> QWizardPage: return FinalPage(self.export)