dangerzone/dangerzone/gui/logic.py
Alex Pyrgiotis d5ca6bb422
updater: Move "Ok" button to the right
Move the "Ok" button in the prompt that asks users if they want to
enable update checks to the right, to further reinforce that this is
the default action.
2023-07-28 19:57:46 +03:00

361 lines
12 KiB
Python

import logging
import os
import platform
import shlex
import subprocess
import typing
from pathlib import Path
from typing import Dict, Optional
from colorama import Fore
# FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details.
if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtGui, QtWidgets
else:
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
if platform.system() == "Linux":
from xdg.DesktopEntry import DesktopEntry
from ..isolation_provider.base import IsolationProvider
from ..logic import DangerzoneCore
from ..settings import Settings
from ..util import get_resource_path
log = logging.getLogger(__name__)
class DangerzoneGui(DangerzoneCore):
"""
Singleton of shared state / functionality for the GUI and core app logic
"""
def __init__(
self, app: QtWidgets.QApplication, isolation_provider: IsolationProvider
) -> None:
super().__init__(isolation_provider)
# Qt app
self.app = app
# Only one output dir is supported in the GUI
self.output_dir: str = ""
# Preload font
self.fixed_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
# Preload list of PDF viewers on computer
self.pdf_viewers = self._find_pdf_viewers()
# Are we done waiting (for Docker Desktop to be installed, or for container to install)
self.is_waiting_finished = False
def get_window_icon(self) -> QtGui.QIcon:
if platform.system() == "Windows":
path = get_resource_path("dangerzone.ico")
else:
path = get_resource_path("icon.png")
return QtGui.QIcon(path)
def open_pdf_viewer(self, filename: str) -> None:
if platform.system() == "Darwin":
# Open in Preview
args = ["open", "-a", "Preview.app", filename]
# Run
args_str = " ".join(shlex.quote(s) for s in args)
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.run(args)
elif platform.system() == "Windows":
os.startfile(Path(filename)) # type: ignore [attr-defined]
elif platform.system() == "Linux":
# Get the PDF reader command
args = shlex.split(self.pdf_viewers[self.settings.get("open_app")])
# %f, %F, %u, and %U are filenames or URLS -- so replace with the file to open
for i in range(len(args)):
if (
args[i] == "%f"
or args[i] == "%F"
or args[i] == "%u"
or args[i] == "%U"
):
args[i] = filename
# Open as a background process
args_str = " ".join(shlex.quote(s) for s in args)
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.Popen(args)
def _find_pdf_viewers(self) -> Dict[str, str]:
pdf_viewers: Dict[str, str] = {}
if platform.system() == "Linux":
# Find all .desktop files
for search_path in [
"/usr/share/applications",
"/usr/local/share/applications",
os.path.expanduser("~/.local/share/applications"),
]:
try:
for filename in os.listdir(search_path):
full_filename = os.path.join(search_path, filename)
if os.path.splitext(filename)[1] == ".desktop":
# See which ones can open PDFs
desktop_entry = DesktopEntry(full_filename)
if (
"application/pdf" in desktop_entry.getMimeTypes()
and desktop_entry.getName() != "dangerzone"
):
pdf_viewers[
desktop_entry.getName()
] = desktop_entry.getExec()
except FileNotFoundError:
pass
return pdf_viewers
class Dialog(QtWidgets.QDialog):
def __init__(
self,
dangerzone: DangerzoneGui,
title: str,
ok_text: str = "Ok",
has_cancel: bool = True,
cancel_text: str = "Cancel",
extra_button_text: Optional[str] = None,
) -> None:
super().__init__()
self.dangerzone = dangerzone
self.setWindowTitle(title)
self.setWindowIcon(self.dangerzone.get_window_icon())
self.setModal(True)
flags = (
QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowSystemMenuHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
message_layout = self.create_layout()
self.ok_button = QtWidgets.QPushButton(ok_text)
self.ok_button.clicked.connect(self.clicked_ok)
self.extra_button: Optional[QtWidgets.QPushButton] = None
if extra_button_text:
self.extra_button = QtWidgets.QPushButton(extra_button_text)
self.extra_button.clicked.connect(self.clicked_extra)
self.cancel_button: Optional[QtWidgets.QPushButton] = None
if has_cancel:
self.cancel_button = QtWidgets.QPushButton(cancel_text)
self.cancel_button.clicked.connect(self.clicked_cancel)
buttons_layout = self.create_buttons_layout()
layout = QtWidgets.QVBoxLayout()
layout.addLayout(message_layout)
layout.addSpacing(10)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def create_buttons_layout(self) -> QtWidgets.QHBoxLayout:
buttons_layout = QtWidgets.QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.ok_button)
if self.extra_button:
buttons_layout.addWidget(self.extra_button)
if self.cancel_button:
buttons_layout.addWidget(self.cancel_button)
return buttons_layout
def create_layout(self) -> QtWidgets.QBoxLayout:
raise NotImplementedError("Dangerzone dialogs must implement this method")
def clicked_ok(self) -> None:
self.done(int(QtWidgets.QDialog.Accepted))
def clicked_extra(self) -> None:
self.done(2)
def clicked_cancel(self) -> None:
self.done(int(QtWidgets.QDialog.Rejected))
def launch(self) -> int:
return self.exec_()
class Alert(Dialog):
def __init__( # type: ignore [no-untyped-def]
self,
*args,
message: str = "",
**kwargs,
) -> None:
self.message = message
kwargs.setdefault("title", "dangerzone")
super().__init__(*args, **kwargs)
def create_layout(self) -> QtWidgets.QBoxLayout:
logo = QtWidgets.QLabel()
logo.setPixmap(
QtGui.QPixmap.fromImage(QtGui.QImage(get_resource_path("icon.png")))
)
label = QtWidgets.QLabel()
label.setText(self.message)
label.setWordWrap(True)
label.setOpenExternalLinks(True)
message_layout = QtWidgets.QHBoxLayout()
message_layout.addWidget(logo)
message_layout.addSpacing(10)
message_layout.addWidget(label, stretch=1)
return message_layout
class UpdateDialog(Dialog):
def __init__( # type: ignore [no-untyped-def]
self,
*args,
intro_msg: Optional[str] = None,
middle_widget: Optional[QtWidgets.QWidget] = None,
epilogue_msg: Optional[str] = None,
**kwargs,
) -> None:
self.intro_msg = intro_msg
self.middle_widget = middle_widget
self.epilogue_msg = epilogue_msg
super().__init__(*args, **kwargs)
def create_layout(self) -> QtWidgets.QBoxLayout:
self.setMinimumWidth(500)
message_layout = QtWidgets.QVBoxLayout()
if self.intro_msg is not None:
intro = QtWidgets.QLabel()
intro.setText(self.intro_msg)
intro.setWordWrap(True)
intro.setAlignment(QtCore.Qt.AlignCenter)
intro.setOpenExternalLinks(True)
message_layout.addWidget(intro)
message_layout.addSpacing(10)
if self.middle_widget is not None:
self.middle_widget.setParent(self)
message_layout.addWidget(self.middle_widget)
message_layout.addSpacing(10)
if self.epilogue_msg is not None:
epilogue = QtWidgets.QLabel()
epilogue.setText(self.epilogue_msg)
epilogue.setWordWrap(True)
epilogue.setOpenExternalLinks(True)
message_layout.addWidget(epilogue)
message_layout.addSpacing(10)
return message_layout
class CollapsibleBox(QtWidgets.QWidget):
"""Create a widget that can show/hide its contents when you click on it.
The credits for this code go to eyllanesc's answer in StackOverflow:
https://stackoverflow.com/a/52617714. We have made the following improvements:
1. Adapt the code to PySide.
2. Resize the window once the box uncollapses.
3. Add type hints.
"""
def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None):
super(CollapsibleBox, self).__init__(parent)
self.toggle_button = QtWidgets.QToolButton(
text=title,
checkable=True,
checked=False,
)
self.toggle_button.setStyleSheet("QToolButton { border: none; }")
self.toggle_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
self.toggle_button.clicked.connect(self.on_click)
self.toggle_animation = QtCore.QParallelAnimationGroup(self)
self.content_area = QtWidgets.QScrollArea(maximumHeight=0, minimumHeight=0)
self.content_area.setSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed
)
self.content_area.setFrameShape(QtWidgets.QFrame.NoFrame)
lay = QtWidgets.QVBoxLayout(self)
lay.setSpacing(0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.toggle_button)
lay.addWidget(self.content_area)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"minimumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"maximumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
)
self.toggle_animation.finished.connect(self.on_animation_finished)
def on_click(self) -> None:
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow
)
self.toggle_animation.setDirection(
QtCore.QAbstractAnimation.Forward
if checked
else QtCore.QAbstractAnimation.Backward
)
self.toggle_animation.start()
def on_animation_finished(self) -> None:
if not self.toggle_button.isChecked():
content_height = self.content_area.layout().sizeHint().height()
parent = self.parent()
assert isinstance(parent, QtWidgets.QWidget)
parent.resize(parent.width(), parent.height() - content_height)
def setContentLayout(self, layout: QtWidgets.QBoxLayout) -> None:
lay = self.content_area.layout()
del lay
self.content_area.setLayout(layout)
collapsed_height = self.sizeHint().height() - self.content_area.maximumHeight()
content_height = layout.sizeHint().height()
for i in range(self.toggle_animation.animationCount()):
animation = self.toggle_animation.animationAt(i)
assert isinstance(animation, QtCore.QPropertyAnimation)
animation.setDuration(60)
animation.setStartValue(collapsed_height)
animation.setEndValue(collapsed_height + content_height)
content_animation = self.toggle_animation.animationAt(
self.toggle_animation.animationCount() - 1
)
assert isinstance(content_animation, QtCore.QPropertyAnimation)
content_animation.setDuration(60)
content_animation.setStartValue(0)
content_animation.setEndValue(content_height)