mirror of
https://github.com/freedomofpress/dangerzone.git
synced 2025-04-29 18:22:37 +02:00
401 lines
14 KiB
Python
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)
|