-
Notifications
You must be signed in to change notification settings - Fork 175
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a new Python module called "updater", which contains the logic for prompting the user to enable updates, and checking our GitHub releases for new updates. This class has some light dependency to Qt functionality, since it needs to: * Show a prompt to the user, * Run update checks asynchronously in a Qt thread, * Provide the main window with the result of the update check Refs #189
- Loading branch information
Showing
3 changed files
with
427 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
"""A module that contains the logic for checking for updates.""" | ||
|
||
import logging | ||
import platform | ||
import typing | ||
from typing import Any, Dict, Optional | ||
|
||
if typing.TYPE_CHECKING: | ||
from PySide2 import QtCore | ||
else: | ||
try: | ||
from PySide6 import QtCore | ||
except ImportError: | ||
from PySide2 import QtCore | ||
|
||
import markdown | ||
import requests | ||
|
||
from ..util import get_version | ||
from .logic import Alert, DangerzoneGui | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
# TODO: Create a wiki page that explains how updates work for Dangerzone, before | ||
# merging this. | ||
MSG_CONFIRM_UPDATE_CHECKS = """\ | ||
Do you want to be notified about new Dangerzone releases? | ||
If "Yes", Dangerzone will check GitHub for new releases on startup. If "No", Dangerzone will make no network requests and won't inform you about new releases. | ||
If you prefer another way of getting notified about new releases, we suggest adding to your RSS reader our Mastodon feed: https://fosstodon.org/@dangerzone.rss. For more information about updates, check our wiki page: https://github.com/freedomofpress/dangerzone/wiki/Updates | ||
""" | ||
|
||
|
||
class Updater(QtCore.QThread): | ||
"""Check asynchronously for Dangerzone updates. | ||
The Updater class is mainly responsible for the following: | ||
1. Asking the user if they want to enable update checks or not. | ||
2. Determining when it's the right time to check for updates. | ||
3. Hitting the GitHub releases API and learning about updates. | ||
Since checking for updates is a task that may take some time, we perform it | ||
asynchronously, in a Qt thread. This thread then triggers a signal, and informs | ||
whoever has connected to it. | ||
""" | ||
|
||
finished = QtCore.Signal(dict) | ||
|
||
GH_RELEASE_URL = ( | ||
"https://api.github.com/repos/freedomofpress/dangerzone/releases/latest" | ||
) | ||
|
||
def __init__(self, dangerzone: DangerzoneGui): | ||
super().__init__() | ||
self.dangerzone = dangerzone | ||
|
||
########### | ||
# Helpers for updater settings | ||
# | ||
# These helpers make it easy to retrieve specific updater-related settings, as well | ||
# as save the settings file, only when necessary. | ||
|
||
def _save_me_maybe(self, key: str, val: Any) -> None: | ||
"""Check the new settings value with the old one, and optionally save it.""" | ||
old_val = self.dangerzone.settings.updater_get(key) | ||
if val == old_val: | ||
return | ||
|
||
self.dangerzone.settings.updater_set(key, val) | ||
self.dangerzone.settings.save() | ||
|
||
@property | ||
def first_run(self) -> bool: | ||
return self.dangerzone.settings.updater_get("first_run") | ||
|
||
@first_run.setter | ||
def first_run(self, val: bool) -> None: | ||
self._save_me_maybe("first_run", val) | ||
|
||
@property | ||
def check(self) -> Optional[bool]: | ||
return self.dangerzone.settings.updater_get("check") | ||
|
||
@check.setter | ||
def check(self, val: bool) -> None: | ||
self._save_me_maybe("check", val) | ||
|
||
def prompt_for_checks(self) -> bool: | ||
"""Ask the user if they want to be informed about Dangerzone updates.""" | ||
log.debug("Prompting the user for update checks") | ||
# TODO: Render HTML links properly | ||
# FIXME: Handle the case where a user clicks on "X", instead of explicity making | ||
# a choice. We should probably ask them again on the next run. | ||
check = Alert( | ||
self.dangerzone, | ||
message=MSG_CONFIRM_UPDATE_CHECKS, | ||
ok_text="Yes", | ||
cancel_text="No", | ||
).launch() | ||
return bool(check) | ||
|
||
def should_check_for_updates(self) -> bool: | ||
"""Determine if we can check for updates based on settings and user prefs.""" | ||
log.debug("Checking platform type") | ||
# TODO: Disable updates for Homebrew installations. | ||
# TODO: Add a flag that will force-check for updates, so that we can test it on | ||
# Linux. Once we have this flag, uncomment the following lines. | ||
# if platform.system() == "Linux" and os.environ(): | ||
# log.debug("Running on Linux, disabling updates") | ||
# self.check = False | ||
# return False | ||
|
||
log.debug("Checking if first run of Dangerzone") | ||
if self.first_run: | ||
log.debug("Dangerzone is running for the first time, updates are stalled") | ||
self.first_run = False | ||
return False | ||
|
||
log.debug("Checking if user has already expressed their preference") | ||
if self.check is None: | ||
log.debug("User has not been asked yet for update checks") | ||
self.check = self.prompt_for_checks() | ||
# TODO: Should we always check for updates in the third run, or can we check | ||
# them immediately, if the user wants to enable them? | ||
return False | ||
elif not self.check: | ||
log.debug("User has expressed that they don't want to check for updates") | ||
return False | ||
|
||
return True | ||
|
||
def can_update(self, cur_version: str, latest_version: str) -> bool: | ||
if cur_version == latest_version: | ||
return False | ||
elif cur_version > latest_version: | ||
# FIXME: This is a sanity check, but we should improve its wording. | ||
raise Exception("Received version is older than the latest version") | ||
else: | ||
return True | ||
|
||
def create_update_report(self, version: str, changelog: str) -> dict: | ||
return { | ||
"version": version, | ||
"changelog": changelog, | ||
"error": None, | ||
} | ||
|
||
def get_latest_info(self) -> dict: | ||
"""Get the latest release info from GitHub. | ||
Also, render the changelog from Markdown format to HTML, so that we can show it | ||
to the users. | ||
""" | ||
try: | ||
# TODO: Set timeout. | ||
info = requests.get(self.GH_RELEASE_URL).json() | ||
except Exception as e: | ||
# TODO:: Handle errors. | ||
raise | ||
|
||
version = info["tag_name"].lstrip("v") | ||
changelog = markdown.markdown(info["body"]) | ||
|
||
return self.create_update_report(version, changelog) | ||
|
||
# XXX: This happens in parallel with other tasks. DO NOT alter global state! | ||
def _check_for_updates(self) -> Dict[Any, Any]: | ||
"""Check for updates locally and remotely. | ||
Check for updates in two places: | ||
1. In our settings, in case we have cached the latest version/changelog from a | ||
previous run. | ||
2. In GitHub, by hitting the latest releases API. | ||
""" | ||
log.debug(f"Checking for Dangerzone updates") | ||
latest_version = self.dangerzone.settings.updater_get("latest_version") | ||
if self.can_update(get_version(), latest_version): | ||
log.debug(f"Determined that there is an update due to cached results") | ||
return self.create_update_report( | ||
latest_version, | ||
self.dangerzone.settings.updater_get("latest_changelog"), | ||
) | ||
|
||
# TODO: We must have a back-off period (e.g., 12 hours) between each check. | ||
log.debug(f"Checking the latest GitHub release") | ||
report = self.get_latest_info() | ||
log.debug(f"Latest version in GitHub is {report['version']}") | ||
if self.can_update(latest_version, report["version"]): | ||
log.debug( | ||
f"Determined that there is an update due to a new GitHub version:" | ||
f" {latest_version} < {report['version']}" | ||
) | ||
return report | ||
|
||
log.debug(f"No need to update") | ||
return {} | ||
|
||
################## | ||
# Logic for running update checks asynchronously | ||
|
||
def check_for_updates(self) -> dict: | ||
"""Check for updates and return a report with the findings: | ||
There are three scenarios when we check for updates, and each scenario returns a | ||
slightly different answer: | ||
1. No new updates: Return an empty dictionary. | ||
2. Updates are available: Return a dictionary with the latest version and | ||
changelog, in HTML format. | ||
3. Update check failed: Return a dictionary that holds just the error message. | ||
""" | ||
try: | ||
res = self._check_for_updates() | ||
except Exception as e: | ||
log.exception("Encountered an error while checking for upgrades") | ||
res = {"error": str(e)} | ||
|
||
return res | ||
|
||
def run(self) -> None: | ||
self.finished.emit(self.check_for_updates()) |
Oops, something went wrong.