From 279f7e3bf2c11394cecab6d2a441692d6a8e4979 Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Fri, 5 Jun 2026 10:29:37 +0500 Subject: [PATCH] epm play update: add tray updater for play apps (eterbug #19129) Signed-off-by: Boris Yumankulov --- .gitignore | 1 + bin/epm | 4 + bin/epm-play_update_tray | 121 ++++++++++++ bin/epm-play_update_tray.py | 357 ++++++++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100755 bin/epm-play_update_tray create mode 100755 bin/epm-play_update_tray.py diff --git a/.gitignore b/.gitignore index 0af2fd6d7..67ec931f5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .gigaide .idea tests/test.spec +-__pycache__ diff --git a/bin/epm b/bin/epm index 4b066b8ad..a6f3061cf 100755 --- a/bin/epm +++ b/bin/epm @@ -521,6 +521,10 @@ check_command() epm_cmd=play direct_args=1 ;; + play-update-tray) # HELPCMD: system tray applet for epm play updates + epm_cmd=play_update_tray + direct_args=1 + ;; create-fake) # HELPCMD: create fake rpm epm_cmd=create_fake direct_args=1 diff --git a/bin/epm-play_update_tray b/bin/epm-play_update_tray new file mode 100755 index 000000000..d123f39d0 --- /dev/null +++ b/bin/epm-play_update_tray @@ -0,0 +1,121 @@ +#!/bin/sh +# +# Copyright (C) 2026 Etersoft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +epm_play_update_tray() +{ + local autostart_mode="" + local remove_autostart_mode="" + local help_mode="" + + while [ -n "$1" ] ; do + case "$1" in + -h|--help) + help_mode="1" + shift + ;; + -a|--autostart) + autostart_mode="1" + shift + ;; + -r|--remove-autostart) + remove_autostart_mode="1" + shift + ;; + *) + echo "Unknown option: $1" >&2 + echo "Run 'epm play-update-tray --help' for help." >&2 + return 1 + ;; + esac + done + + if [ -n "$help_mode" ] ; then + message ' +Usage: epm play-update-tray [options] + +System tray applet for checking and applying epm play updates. + +Options: + -a, --autostart Configure the applet to run automatically on login + -r, --remove-autostart Remove the autostart configuration + -h, --help Show this help message +' + return 0 + fi + + local autostart_dir="$HOME/.config/autostart" + local desktop_file="$autostart_dir/epm-play-update-tray.desktop" + + if [ -n "$autostart_mode" ] ; then + mkdir -p "$autostart_dir" + cat <"$desktop_file" +[Desktop Entry] +Type=Application +Name=EPM Update Tray +Comment=System tray applet for epm play updates +Exec=epm play-update-tray +Icon=system-software-update +Terminal=false +Categories=System;Monitor; +EOF + echo "Autostart configured: $desktop_file" + return 0 + fi + + if [ -n "$remove_autostart_mode" ] ; then + if [ -f "$desktop_file" ] ; then + rm -f "$desktop_file" + echo "Autostart configuration removed." + else + echo "No autostart configuration found." + fi + return 0 + fi + + if ! python3 -c "import PySide6" 2>/dev/null && \ + ! python3 -c "import PyQt6" 2>/dev/null ; then + echo "Error: PySide6 or PyQt6 is required to run the tray applet." >&2 + if [ -t 0 ] ; then + printf "Would you like to install python3-module-pyside6 now? [y/N]: " + read -r answer + case "$answer" in + [yY]|[yY][eE][sS]) + epm install python3-module-pyside6 || return 1 + ;; + *) + echo "Please install python3-module-pyside6 manually." >&2 + return 1 + ;; + esac + else + echo "Please install python3-module-pyside6 to run the tray applet." >&2 + return 1 + fi + fi + + export EPM_BIN + if [ -x "$PROGDIR/epm" ] ; then + EPM_BIN="$PROGDIR/epm" + else + EPM_BIN="epm" + fi + + local tray_script="$SHAREDIR/epm-play_update_tray.py" + [ -r "$tray_script" ] || tray_script="$PROGDIR/epm-play_update_tray.py" + python3 "$tray_script" +} diff --git a/bin/epm-play_update_tray.py b/bin/epm-play_update_tray.py new file mode 100755 index 000000000..05516c8f2 --- /dev/null +++ b/bin/epm-play_update_tray.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2026 Etersoft +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +import os +import shutil +import subprocess +import sys +import tempfile + +try: + from PySide6.QtCore import QDateTime, QProcess, QTimer + from PySide6.QtGui import QAction, QIcon + from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon +except ImportError: + try: + from PyQt6.QtCore import QDateTime, QProcess, QTimer + from PyQt6.QtGui import QAction, QIcon + from PyQt6.QtWidgets import QApplication, QMenu, QSystemTrayIcon + except ImportError: + print("Error: PySide6 or PyQt6 is required to run the tray applet.", file=sys.stderr) + sys.exit(1) + + +CHECK_INTERVAL_MS = 60 * 60 * 1000 +AUTOSTART_FILE = os.path.expanduser("~/.config/autostart/epm-play-update-tray.desktop") +UPDATE_ICON = "software-update-available" + + +def icon_from_theme(*names): + fallback = QIcon() + for name in reversed(names): + fallback = QIcon.fromTheme(name, fallback) + return fallback + + +def message_icon(): + return QSystemTrayIcon.MessageIcon.Information + + +class EpmUpdateTray(QSystemTrayIcon): + def __init__(self, app): + super().__init__() + self.app = app + self.epm_path = os.environ.get("EPM_BIN", "epm") + self.process = None + self.last_check = None + self.next_check = None + self.play_updates = [] + self.was_empty = True + + self.setup_menu() + self.set_clean_state() + + self.timer = QTimer() + self.timer.timeout.connect(self.check_updates_auto) + self.timer.start(CHECK_INTERVAL_MS) + + self.check_updates(manual=False) + + def setup_menu(self): + self.menu = QMenu() + + self.status_action = QAction("Checking updates...", self) + self.status_action.setEnabled(False) + self.menu.addAction(self.status_action) + + self.play_menu = QMenu("EPM Play (0)") + self.menu.addMenu(self.play_menu) + self.menu.addSeparator() + + self.last_check_action = QAction("Last check: never", self) + self.last_check_action.setEnabled(False) + self.menu.addAction(self.last_check_action) + + self.next_check_action = QAction("Next check: unknown", self) + self.next_check_action.setEnabled(False) + self.menu.addAction(self.next_check_action) + self.menu.addSeparator() + + self.update_action = QAction("Run EPM Play Update", self) + self.update_action.triggered.connect(self.apply_updates) + self.menu.addAction(self.update_action) + + self.check_action = QAction("Check for updates", self) + self.check_action.triggered.connect(self.check_updates_manual) + self.menu.addAction(self.check_action) + + self.autostart_action = QAction("", self) + self.autostart_action.triggered.connect(self.toggle_autostart) + self.menu.addAction(self.autostart_action) + self.update_autostart_text() + + self.quit_action = QAction("Exit", self) + self.quit_action.triggered.connect(self.app.quit) + self.menu.addAction(self.quit_action) + + self.setContextMenu(self.menu) + self.activated.connect(self.on_activated) + + def is_autostart_configured(self): + return os.path.exists(AUTOSTART_FILE) + + def update_autostart_text(self): + if self.is_autostart_configured(): + self.autostart_action.setText("Remove from autostart") + else: + self.autostart_action.setText("Add to autostart") + + def toggle_autostart(self): + if self.is_autostart_configured(): + try: + os.remove(AUTOSTART_FILE) + except OSError as err: + print(f"Error removing autostart: {err}", file=sys.stderr) + else: + try: + os.makedirs(os.path.dirname(AUTOSTART_FILE), exist_ok=True) + with open(AUTOSTART_FILE, "w", encoding="utf-8") as desktop: + desktop.write("""[Desktop Entry] +Type=Application +Name=EPM Update Tray +Comment=System tray applet for epm play updates +Exec=epm play-update-tray +Icon=system-software-update +Terminal=false +Categories=System;Monitor; +""") + except OSError as err: + print(f"Error configuring autostart: {err}", file=sys.stderr) + self.update_autostart_text() + + def set_clean_state(self): + self.setIcon(icon_from_theme( + "system-software-update-installed", + "update-none", + "preferences-system-software-updates", + )) + self.setToolTip("EPM updates: system is up to date") + + def set_update_state(self, count): + self.setIcon(icon_from_theme( + UPDATE_ICON, + "system-software-update", + "preferences-system-software-updates", + )) + self.setToolTip(f"EPM updates: {count} updates available") + + def check_updates_auto(self): + self.check_updates(manual=False) + + def check_updates_manual(self): + self.check_updates(manual=True) + + def check_updates(self, manual=False): + if self.process and self.process.state() != QProcess.ProcessState.NotRunning: + return + self.manual_check = manual + self.status_action.setText("Checking updates...") + self.check_action.setEnabled(False) + self.update_action.setEnabled(False) + self.process = QProcess() + self.process.finished.connect(self.on_check_finished) + self.process.start(self.epm_path, ["play", "--list-updates", "--quiet"]) + + def on_check_finished(self, exit_code, *args): + output = bytes(self.process.readAllStandardOutput()).decode(errors="replace") + stderr = bytes(self.process.readAllStandardError()).decode(errors="replace").strip() + self.play_updates = self.parse_updates(output) + + self.last_check = QDateTime.currentDateTime() + self.next_check = self.last_check.addMSecs(CHECK_INTERVAL_MS) + self.check_action.setEnabled(True) + + count = self.update_count() + if exit_code != 0 and count == 0: + self.status_action.setText("Update check failed") + self.update_action.setEnabled(False) + self.setIcon(icon_from_theme("dialog-warning", "software-update-urgent")) + self.setToolTip("EPM updates: check failed") + if self.manual_check: + self.notify("EPM Updates", stderr or "Update check failed") + self.update_menu() + return + + if count: + self.status_action.setText(f"{count} updates available") + self.update_action.setEnabled(True) + self.set_update_state(count) + if self.manual_check or self.was_empty: + self.notify("EPM Updates", self.summary_text()) + self.was_empty = False + else: + self.status_action.setText("System is up to date") + self.update_action.setEnabled(False) + self.set_clean_state() + if self.manual_check: + self.notify("EPM Updates", "System is up to date") + self.was_empty = True + + self.update_menu() + + def parse_updates(self, output): + play = [] + + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line: + continue + parts = line.split() + if len(parts) >= 3 and self.is_app_name(parts[0]): + play.append(f"{parts[0]}: {parts[1]} -> {parts[2]}") + + return play + + def update_count(self): + return len(self.play_updates) + + def update_menu(self): + self.play_menu.setTitle(f"EPM Play ({len(self.play_updates)})") + + self.fill_menu(self.play_menu, self.play_updates) + + if self.last_check: + self.last_check_action.setText( + "Last check: " + self.last_check.toString("dd MMM HH:mm:ss") + ) + if self.next_check: + seconds = max(0, QDateTime.currentDateTime().secsTo(self.next_check)) + self.next_check_action.setText("Next check in " + self.format_interval(seconds)) + + def fill_menu(self, menu, rows): + menu.clear() + if not rows: + empty_action = QAction("No updates", self) + empty_action.setEnabled(False) + menu.addAction(empty_action) + return + + for row in rows[:30]: + action = QAction(row, self) + app_name = self.app_name_from_row(row) + action.triggered.connect(lambda checked=False, app_name=app_name: self.apply_updates(app_name)) + menu.addAction(action) + + hidden_count = len(rows) - 30 + if hidden_count > 0: + more_action = QAction(f"... and {hidden_count} more", self) + more_action.setEnabled(False) + menu.addAction(more_action) + + def format_interval(self, seconds): + minutes = seconds // 60 + if minutes >= 60: + hours = minutes // 60 + minutes = minutes % 60 + return f"{hours}h {minutes}m" + return f"{minutes}m {seconds % 60}s" + + def summary_text(self): + parts = [] + if self.play_updates: + parts.append(f"EPM Play: {len(self.play_updates)}") + rows = self.play_updates + return ", ".join(parts) + "\n" + "\n".join(rows[:6]) + + def app_name_from_row(self, row): + return row.split(":", 1)[0].strip() + + def is_app_name(self, name): + allowed_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.+@-") + return bool(name) and all(char in allowed_chars for char in name) + + def notify(self, title, text): + if shutil.which("notify-send"): + try: + subprocess.Popen(["notify-send", f"--icon={UPDATE_ICON}", title, text]) + return + except OSError: + pass + self.showMessage(title, text, message_icon(), 10000) + + def on_activated(self, reason): + if reason in ( + QSystemTrayIcon.ActivationReason.Trigger, + QSystemTrayIcon.ActivationReason.DoubleClick, + ): + self.notify("EPM Updates", self.summary_text() if self.update_count() else "System is up to date") + + def apply_updates(self, app_name=None): + if not shutil.which("gio"): + print("Could not find gio to launch EPM Play Update.", file=sys.stderr) + return + + update_args = "--update all" + update_name = "EPM Play Update" + if app_name: + update_args = f"--update {app_name}" + update_name = f"EPM Play Update: {app_name}" + + desktop_file = None + try: + fd, desktop_file = tempfile.mkstemp(prefix="epm-play-update-", suffix=".desktop") + with os.fdopen(fd, "w", encoding="utf-8") as desktop: + desktop.write(f"""[Desktop Entry] +Type=Application +Name={update_name} +Comment=Update applications installed via epm play +Exec={self.epm_path} play {update_args} +Icon=system-software-update +Terminal=true +Categories=System; +""") + os.chmod(desktop_file, 0o755) + subprocess.run(["gio", "launch", desktop_file], check=False) + except OSError as err: + print(f"Could not launch EPM Play Update: {err}", file=sys.stderr) + finally: + if desktop_file: + try: + os.unlink(desktop_file) + except OSError: + pass + + +def main(): + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + if not QSystemTrayIcon.isSystemTrayAvailable(): + print("System tray is not available on this system.", file=sys.stderr) + sys.exit(1) + + tray = EpmUpdateTray(app) + tray.show() + + if hasattr(app, "exec"): + sys.exit(app.exec()) + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main()