dangerzone/dangerzone/gui/__init__.py
Alexis Métaireau d91a09a299
Split updater GUI code from the code checking for release updates
The code making the actual requests and checks now lives in the
`updater.releases` module. The code should be easier to read and to
reason about.

Tests have been updated to reflect this.
2025-04-22 12:55:44 +02:00

198 lines
6.8 KiB
Python

import enum
import logging
import os
import platform
import signal
import sys
import typing
from typing import List, Optional
import click
import colorama
# 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
from .. import args, errors
from ..document import Document
from ..isolation_provider.container import Container
from ..isolation_provider.dummy import Dummy
from ..isolation_provider.qubes import Qubes, is_qubes_native_conversion
from ..updater import errors as updater_errors
from ..updater import releases
from ..util import get_resource_path, get_version
from .logic import DangerzoneGui
from .main_window import MainWindow
from .updater import UpdaterThread
log = logging.getLogger(__name__)
class OSColorMode(enum.Enum):
"""
Operating system color mode, e.g. Light or Dark Mode on macOS 10.14+ or Windows 10+.
The enum values are used as the names of Qt properties that will be selected by QSS
property selectors to set color-mode-specific style rules.
"""
LIGHT = "light"
DARK = "dark"
class Application(QtWidgets.QApplication):
document_selected = QtCore.Signal(list)
application_activated = QtCore.Signal()
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
super(Application, self).__init__(*args, **kwargs)
self.setQuitOnLastWindowClosed(False)
with get_resource_path("dangerzone.css").open("r") as f:
style = f.read()
self.setStyleSheet(style)
# Needed under certain windowing systems to match the application to the
# desktop entry in order to display the correct application name and icon
# and to allow identifying windows that belong to the application (e.g.
# under Wayland it sets the correct app ID). The value is the name of the
# Dangerzone .desktop file.
self.setDesktopFileName("press.freedom.dangerzone")
# In some combinations of window managers and OSes, if we don't set an
# application name, then the window manager may report it as `python3` or
# `__init__.py`. Always set this to `dangerzone`, which corresponds to the
# executable name as well.
# See: https://github.com/freedomofpress/dangerzone/issues/402
self.setApplicationName("dangerzone")
self.original_event = self.event
def monkeypatch_event(arg__1: QtCore.QEvent) -> bool:
event = arg__1 # oddly Qt calls internally event by "arg__1"
# In macOS, handle the file open event
if isinstance(event, QtGui.QFileOpenEvent):
# Skip file open events in dev mode
if not hasattr(sys, "dangerzone_dev"):
self.document_selected.emit([event.file()])
return True
elif event.type() == QtCore.QEvent.ApplicationActivate:
self.application_activated.emit()
return True
return self.original_event(event)
self.event = monkeypatch_event # type: ignore [method-assign]
self.os_color_mode = self.infer_os_color_mode()
log.debug(f"Inferred system color scheme as {self.os_color_mode}")
def infer_os_color_mode(self) -> OSColorMode:
"""
Qt 6.5+ explicitly provides the OS color scheme via QStyleHints.colorScheme(),
but we still need to support PySide2/Qt 5, so instead we infer the OS color
scheme from the default palette.
"""
text_color, window_color = (
self.palette().color(role)
for role in (QtGui.QPalette.WindowText, QtGui.QPalette.Window)
)
if text_color.lightness() > window_color.lightness():
return OSColorMode.DARK
return OSColorMode.LIGHT
@click.command()
@click.option(
"--unsafe-dummy-conversion", "dummy_conversion", flag_value=True, hidden=True
)
@click.argument(
"filenames",
required=False,
nargs=-1,
type=click.UNPROCESSED,
callback=args.validate_input_filenames,
)
@click.version_option(version=get_version(), message="%(version)s")
@errors.handle_document_errors
def gui_main(dummy_conversion: bool, filenames: Optional[List[str]]) -> bool:
setup_logging()
if platform.system() == "Darwin":
# Required for macOS Big Sur: https://stackoverflow.com/a/64878899
os.environ["QT_MAC_WANTS_LAYER"] = "1"
# Make sure /usr/local/bin is in the path
os.environ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin"
# Don't show ANSI colors from stdout output, to prevent terminal
# colors from breaking the macOS GUI app
colorama.deinit()
# Create the Qt app
app = Application()
# Common objects
if getattr(sys, "dangerzone_dev", False) and dummy_conversion:
dummy = Dummy()
dangerzone = DangerzoneGui(app, isolation_provider=dummy)
elif is_qubes_native_conversion():
qubes = Qubes()
dangerzone = DangerzoneGui(app, isolation_provider=qubes)
else:
container = Container()
dangerzone = DangerzoneGui(app, isolation_provider=container)
# Allow Ctrl-C to smoothly quit the program instead of throwing an exception
signal.signal(signal.SIGINT, signal.SIG_DFL)
def open_files(filenames: List[str] = []) -> None:
documents = [Document(filename) for filename in filenames]
window.content_widget.doc_selection_widget.documents_selected.emit(documents)
window = MainWindow(dangerzone)
# Check for updates
log.debug("Setting up Dangerzone updater")
updater = UpdaterThread(dangerzone)
window.register_update_handler(updater.finished)
log.debug("Consulting updater settings before checking for updates")
should_check = updater.should_check_for_updates()
if should_check:
log.debug("Checking for updates")
updater.start()
else:
log.debug("Will not check for updates, based on updater settings")
window.toggle_updates_action.setChecked(should_check)
if filenames:
open_files(filenames)
# MacOS: Open a new window, if all windows are closed
def application_activated() -> None:
window.show()
# If we get a file open event, open it
app.document_selected.connect(open_files)
# If the application is activated and all windows are closed, open a new one
app.application_activated.connect(application_activated)
# Launch the GUI
ret = app.exec_()
sys.exit(ret)
def setup_logging() -> None:
logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(message)s")
args.override_parser_and_check_suspicious_options(gui_main)