dangerzone/dangerzone/gui/logic.py
Alexis Métaireau 0c9f426b68
Do not throw on malformed Desktop Entries on Linux.
This just skips the malformed entry when it's found.

Fixes #899
2024-09-10 15:25:45 +02:00

401 lines
14 KiB
Python

from __future__ import annotations
import logging
import os
import platform
import shlex
import subprocess
import typing
from collections import OrderedDict
from pathlib import Path
from typing import 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
from . import Application
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, ParsingError
from ..isolation_provider.base import IsolationProvider
from ..logic import DangerzoneCore
from ..util import get_resource_path, replace_control_chars
log = logging.getLogger(__name__)
class DangerzoneGui(DangerzoneCore):
"""
Singleton of shared state / functionality for the GUI and core app logic
"""
def __init__(
self, app: "Application", 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 ordered list of PDF viewers on computer, starting with default
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 = replace_control_chars(" ".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 = replace_control_chars(" ".join(shlex.quote(s) for s in args))
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.Popen(args)
def _find_pdf_viewers(self) -> OrderedDict[str, str]:
pdf_viewers: OrderedDict[str, str] = OrderedDict()
if platform.system() == "Linux":
# Opportunistically query for default pdf handler
default_pdf_viewer = None
try:
default_pdf_viewer = subprocess.check_output(
["xdg-mime", "query", "default", "application/pdf"]
).decode()
except (FileNotFoundError, subprocess.CalledProcessError) as e:
# Log it and continue
log.info(
"xdg-mime query failed, default PDF handler could not be found."
)
log.debug(f"xdg-mime query failed: {e}")
# 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
try:
desktop_entry = DesktopEntry(full_filename)
except ParsingError:
# Do not stop when encountering malformed desktop entries
continue
except Exception:
log.exception(
"Encountered the following exception while processing desktop entry %s",
full_filename,
)
else:
desktop_entry_name = desktop_entry.getName()
if (
"application/pdf" in desktop_entry.getMimeTypes()
and "dangerzone" not in desktop_entry_name.lower()
):
pdf_viewers[desktop_entry_name] = (
desktop_entry.getExec()
)
# Put the default entry first
if filename == default_pdf_viewer:
try:
pdf_viewers.move_to_end(
desktop_entry_name, last=False
)
except KeyError as e:
# Should be unreachable
log.error(
f"Problem reordering applications: {e}"
)
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.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value)
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)