Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat : Add download update feature in user app. Issue #755 #782

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
13 changes: 13 additions & 0 deletions build_scripts/get_version.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
"""Get the version of the package."""

import importlib.metadata
import requests
import json

APP_VERSION_URL = (
"https://api.github.com/repos/OpenAdaptAI/OpenAdapt/releases?per_page=10&page=1"
)


def get_version() -> str:
"""Get the version of the package."""
return importlib.metadata.version("openadapt")


def get_latest_version() -> str:
"""Get the latest version of the app available."""
response = requests.get(APP_VERSION_URL, stream=True)
data = json.loads(response.text)
return data[0]["tag_name"].replace("v", "")
abrichr marked this conversation as resolved.
Show resolved Hide resolved


if __name__ == "__main__":
print(get_version())
169 changes: 166 additions & 3 deletions openadapt/app/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
import multiprocessing
import os
import sys
abrichr marked this conversation as resolved.
Show resolved Hide resolved
import threading
import time

from loguru import logger
from packaging.version import Version
from pyqttoast import Toast, ToastButtonAlignment, ToastIcon, ToastPosition, ToastPreset
from PySide6.QtCore import QMargins, QObject, QSize, Qt, QThread, Signal
from PySide6.QtCore import QMargins, QObject, QSize, Qt, QThread, Signal, Slot
from PySide6.QtGui import QAction, QColor, QFont, QIcon, QPixmap
from PySide6.QtWidgets import (
QApplication,
Expand All @@ -33,7 +36,10 @@
QVBoxLayout,
QWidget,
)
from tqdm import tqdm
import requests

from build_scripts.get_version import get_latest_version, get_version
from openadapt.app.cards import quick_record, stop_record
from openadapt.app.dashboard.run import cleanup as cleanup_dashboard
from openadapt.app.dashboard.run import run as run_dashboard
Expand All @@ -43,13 +49,18 @@
from openadapt.models import Recording
from openadapt.replay import replay
from openadapt.strategies.base import BaseReplayStrategy
from openadapt.update_utils import unzip_file
from openadapt.utils import get_posthog_instance
from openadapt.visualize import main as visualize

# ensure all strategies are registered
import openadapt.strategies # noqa: F401

ICON_PATH = os.path.join(FPATH, "assets", "logo.png")
APP_DOWNLOAD_BASE_URL = "https://github.com/OpenAdaptAI/OpenAdapt/releases/download"
CURRENT_VERSION = get_version()
LATEST_VERSION = get_latest_version()
DOWNLOAD_TOAST_UPDATE_TIME = 3


class TrackedQAction(QAction):
Expand Down Expand Up @@ -98,7 +109,7 @@ def run(self) -> None:
self.data.emit(data)


class SystemTrayIcon:
class SystemTrayIcon(QObject):
"""System tray icon for OpenAdapt."""

recording = False
Expand All @@ -107,9 +118,13 @@ class SystemTrayIcon:

# storing actions is required to prevent garbage collection
recording_actions = {"visualize": [], "replay": []}
download_complete_signal = Signal(str)
download_start_toast_signal = Signal(str)
unzipping_started_toast_signal = Signal(str)

def __init__(self) -> None:
"""Initialize the system tray icon."""
super().__init__()
self.app = QApplication([])

if sys.platform == "darwin":
Expand All @@ -122,6 +137,7 @@ def __init__(self) -> None:
)

self.app.setQuitOnLastWindowClosed(False)
self.cancel_download_event = threading.Event()

# currently required for pyqttoast
# TODO: remove once https://github.com/niklashenning/pyqt-toast/issues/9
Expand Down Expand Up @@ -155,7 +171,33 @@ def __init__(self) -> None:
# self.app_action.triggered.connect(self.show_app)
# self.menu.addAction(self.app_action)

self.dashboard_action = TrackedQAction("Launch Dashboard")
self.dashboard_action.triggered.connect(self.launch_dashboard)
self.menu.addAction(self.dashboard_action)

current_version = Version(CURRENT_VERSION)
latest_version = Version(LATEST_VERSION)
logger.info(f"{current_version=} {latest_version=}")

self.download_button_text = (
f"Download Latest Version v{LATEST_VERSION}"
if current_version < latest_version
else "No updates available"
)
self.download_update_action = TrackedQAction(self.download_button_text)
abrichr marked this conversation as resolved.
Show resolved Hide resolved
self.download_update_action.triggered.connect(self.download_latest_app_version)
# Connect download_complete_signal signal to show_toast slot
self.download_complete_signal.connect(self.download_complete_slot)
# Connect download_start_toast_signal signal to download_start_toast_slot
self.download_start_toast_signal.connect(self.download_start_toast_slot)
self.unzipping_started_toast_signal.connect(self.unzipping_started_slot)

self.menu.addAction(self.download_update_action)
self.download_progress_toast = None

self.quit = TrackedQAction("Quit")
if current_version >= latest_version:
self.download_update_action.setEnabled(False)

def _quit() -> None:
"""Quit the application."""
Expand Down Expand Up @@ -185,6 +227,126 @@ def _quit() -> None:

self.launch_dashboard()

def stop_download(self) -> None:
abrichr marked this conversation as resolved.
Show resolved Hide resolved
"""Stops download when button clicked."""
self.cancel_download_event.set()
self.show_toast("Stopping download...", duration=4000)

@Slot(str)
def download_start_toast_slot(self, message: str) -> None:
"""Shows download start toast depending on emitted signal."""
if self.download_progress_toast:
self.download_progress_toast.hide()
self.download_progress_toast = self.show_toast(
message,
duration=DOWNLOAD_TOAST_UPDATE_TIME * 1000,
always_on_main_screen=True,
reset_duration_on_hover=False,
fade_in_duration=0,
fade_out_duration=0,
show_duration_bar=False,
)

@Slot(str)
def download_complete_slot(self, message: str) -> None:
"""Shows download start toast and update button text based on signal."""
self.show_toast(message)
self.download_update_action.setText(self.download_button_text)
self.cancel_download_event.clear()

@Slot(str)
def unzipping_started_slot(self, message: str) -> None:
"""Shows unzip started toast."""
self.show_toast(message)
self.cancel_download_event.clear()

def download_latest_version(self, base_url: str, latest_version: str) -> None:
"""Download latest version of the app."""
if sys.platform == "darwin":
file_ext = ".app.zip"
else:
file_ext = ".zip"

file_base_name = f"OpenAdapt-v{latest_version}"
file_name = f"{file_base_name}{file_ext}"
download_url = base_url + f"/v{latest_version}/{file_name}"

downloads_path = os.path.join(os.path.expanduser("~"), "Downloads")
local_filename = os.path.join(downloads_path, file_name)

response = requests.get(download_url, stream=True)
total_size = response.headers.get("content-length")
total_size = int(total_size) if total_size else None
block_size = 1024 # 1 Kilobyte
start_time = time.time()
last_update_time = start_time
try:
with open(local_filename, "wb") as file, tqdm(
total=total_size, unit="B", unit_scale=True, desc=file_name
) as progress_bar:
for data in response.iter_content(block_size):
if self.cancel_download_event.is_set():
self.cancel_download_event.clear()
return
file.write(data)
progress_bar.update(len(data))
current_time = time.time()

if (
current_time - last_update_time > DOWNLOAD_TOAST_UPDATE_TIME
and progress_bar.n > 0
and progress_bar.n < progress_bar.total
):
elapsed_time = current_time - start_time
last_update_time = current_time
rate = progress_bar.n / elapsed_time
eta_seconds_actual = (
progress_bar.total - progress_bar.n
) / rate
# convert to minutes
eta_minutes = int(eta_seconds_actual // 60)
eta_seconds_formatted = eta_seconds_actual % 60
eta_formatted = (
f"{eta_minutes}:{int(eta_seconds_formatted):02d}"
)

progress_message = (
f"Estimated time remaining: {eta_formatted} minutes"
)
self.download_start_toast_signal.emit(progress_message)

self.unzipping_started_toast_signal.emit("Unzipping Started")
unzip_file(local_filename, file_base_name)
self.download_complete_signal.emit("Download Complete")
except Exception as e:
logger.error(e)
self.download_complete_signal.emit("Download Failed")

def check_and_download_latest_version(self) -> None:
"""Checks and Download latest version."""
if Version(CURRENT_VERSION) >= Version(LATEST_VERSION):
self.show_toast("You are already on the latest version.")
return

self.show_toast("Downloading the latest version...", duration=4000)
download_thread = threading.Thread(
target=self.download_latest_version,
args=(APP_DOWNLOAD_BASE_URL, LATEST_VERSION),
)
download_thread.start()

def download_latest_app_version(self) -> None:
"""Main function called when download button is clicked.

This calls the necessary function according to the condition.
"""
if self.download_update_action.text() == "Stop Download":
self.stop_download()
self.download_update_action.setText(self.download_button_text)
elif not self.cancel_download_event.is_set():
self.download_update_action.setText("Stop Download")
self.check_and_download_latest_version()

def handle_recording_signal(
self,
signal: dict,
Expand Down Expand Up @@ -648,7 +810,8 @@ def show_toast(
toast.setDurationBarColor(duration_bar_color)
toast.setIconColor(icon_color)
toast.setIconSeparatorColor(icon_separator_color)
toast.setShowDurationBar(show_duration_bar)

toast.setShowDurationBar(show_duration_bar)

# Font settings are typically safe to apply regardless of preset
toast.setTitleFont(title_font)
Expand Down
38 changes: 38 additions & 0 deletions openadapt/update_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Utility functions for the download app updates."""

import os
import shutil

from loguru import logger


def set_permissions(path: str) -> None:
"""Set the permissions of all files to make the executable."""
for root, dirs, files in os.walk(path):
for dir in dirs:
dir_path = os.path.join(root, dir)
try:
os.chmod(os.path.join(root, dir), 0o755)
except PermissionError:
logger.info(f"Skipping directory due to PermissionError: {dir_path}")
except Exception as e:
logger.info(f"An error occurred for directory {dir_path}: {e}")
for file in files:
file_path = os.path.join(root, file)
try:
os.chmod(os.path.join(root, file), 0o755)
except PermissionError:
logger.info(f"Skipping file due to PermissionError: {file_path}")
except Exception as e:
logger.info(f"An error occurred for file {file_path}: {e}")


def unzip_file(file_path: str, base_file_name: str) -> None:
"""Unzip a file to the given directory."""
if os.path.exists(file_path):
unzipping_directory_path = f"{os.path.dirname(file_path)}/{base_file_name}"
if not os.path.exists(unzipping_directory_path):
os.makedirs(unzipping_directory_path)
shutil.unpack_archive(file_path, unzipping_directory_path)
set_permissions(unzipping_directory_path)
logger.info("Unzipped")
25 changes: 12 additions & 13 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ posthog = "^3.5.0"

wheel = "^0.43.0"
cython = "^3.0.10"
packaging = "^24.1"
urllib3 = "^2.2.2"
[tool.pytest.ini_options]
filterwarnings = [
# suppress warnings starting from "setuptools>=67.3"
Expand Down
Loading