From ad76dc4d77ab8b25a4d3442d29de7260a5961df0 Mon Sep 17 00:00:00 2001 From: 4ntsy <228124712+4ntsy@users.noreply.github.com> Date: Fri, 24 Oct 2025 00:46:09 -0300 Subject: [PATCH] feat: Built-in automatic update script Signed-off-by: 4ntsy <228124712+4ntsy@users.noreply.github.com> --- p3/app/main.py | 17 --- p3/app/term_view.py | 6 + p3/app/updater/__init__.py | 1 + p3/app/updater/update_dialog.py | 263 ++++++++++++++++++++++++++++++++ p3/app/updater/update_helper.py | 121 +++++++++++++++ p3/app/window.py | 22 ++- 6 files changed, 412 insertions(+), 18 deletions(-) create mode 100644 p3/app/updater/__init__.py create mode 100644 p3/app/updater/update_dialog.py create mode 100644 p3/app/updater/update_helper.py diff --git a/p3/app/main.py b/p3/app/main.py index c6776105..c79cdaaa 100644 --- a/p3/app/main.py +++ b/p3/app/main.py @@ -77,23 +77,6 @@ def run(): dialog.run() dialog.destroy() sys.exit(1) - - # In GUI mode, use the new GitHub API-based update checker - # This works for both git-cloned and packaged versions - # Run update check asynchronously to prevent blocking on Hyprland/Wayland - try: - import threading - def async_update_check(): - try: - run_update_check(show_dialog=True, verbose=False, translations=translations) - except Exception as e: - print(f"Update check failed: {e}") - - # Run update check in background thread to prevent blocking - update_thread = threading.Thread(target=async_update_check, daemon=True) - update_thread.start() - except Exception as e: - print(f"Update check thread failed: {e}") # Run kernel update check for psycachy kernels (debian/ubuntu only) try: diff --git a/p3/app/term_view.py b/p3/app/term_view.py index 13b05c7f..a0bf5903 100644 --- a/p3/app/term_view.py +++ b/p3/app/term_view.py @@ -2,6 +2,7 @@ from . import get_icon_path from . import dev_mode import os, sys +from .updater.update_dialog import DialogRestart class InfosHead(Gtk.Box): @@ -114,6 +115,9 @@ def on_button_run_clicked(self, widget): self._run_next_script() def on_child_exit(self, term, status): + if self._self_update: + DialogRestart(parent=self.get_toplevel()).show() + self.scripts_executed += 1 progress = self.scripts_executed / self.total_scripts self.vbox_main.progress_bar.set_fraction(progress) @@ -142,6 +146,8 @@ def _run_next_script(self): if current_script.get('reboot') == "yes": self.parent.reboot_required = True + self._self_update = current_script.get('self_update', False) + script_dir = str(os.path.join(os.path.dirname(os.path.dirname(__file__)))) shell_exec = ["/bin/bash", f"{script_path}"] diff --git a/p3/app/updater/__init__.py b/p3/app/updater/__init__.py new file mode 100644 index 00000000..c81c5b4f --- /dev/null +++ b/p3/app/updater/__init__.py @@ -0,0 +1 @@ +__version__ = "5.5.1" \ No newline at end of file diff --git a/p3/app/updater/update_dialog.py b/p3/app/updater/update_dialog.py new file mode 100644 index 00000000..d517e4cc --- /dev/null +++ b/p3/app/updater/update_dialog.py @@ -0,0 +1,263 @@ +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, Pango, Gdk, GLib +import webbrowser, json, re, subprocess, threading, sys, os +from . import __version__ + + +class DialogBase(Gtk.MessageDialog): + def __init__(self, parent, title, message, buttons, message_type): + super().__init__( + title=title, + parent=parent, + flags=0, + buttons=Gtk.ButtonsType.NONE, + message_type=message_type, + modal=True + ) + self.set_markup(message) + self.add_buttons(buttons) + self.connect("response", self._on_response) + self.show_all() + + def add_buttons(self, buttons): + for button_text, response_type in buttons: + button = self.add_button(button_text, response_type) + if response_type == Gtk.ResponseType.OK: + button.get_style_context().add_class("suggested-action") + + def _on_response(self, dialog, response_id): + raise NotImplementedError("Response Not Implemented") + + +class DialogRestart(DialogBase): + def __init__(self, parent): + super().__init__( + parent, "Update complete!", + "Restart the app to access the newest features and improvements.", + [("Restart", Gtk.ResponseType.OK), ("Cancel", Gtk.ResponseType.CANCEL)], + Gtk.MessageType.OTHER + ) + + def _on_response(self, dialog, response_id): + if response_id == Gtk.ResponseType.OK: + self.close() + os.execv(sys.executable, ["python"] + sys.argv) + elif response_id == Gtk.ResponseType.CANCEL: + self.destroy() + + +class DialogError(DialogBase): + def __init__(self, parent, error_message): + super().__init__( + parent, "Error", + f"An error occurred during the update process.\n{error_message}", + [("OK", Gtk.ResponseType.OK)], + Gtk.MessageType.ERROR + ) + + def _on_response(self, dialog, response_id): + self.destroy() + + +class UpdateDialog(Gtk.Dialog): + def __init__(self, changelog, parent): + super().__init__(title="Update Available") + self.set_default_size(450, 350) + self.set_decorated(True) + self.set_property("skip-taskbar-hint", True) + self.link_tags = {} + self.changelog = changelog or "{'tag_name': '', 'body': ''}" + self.parent = parent + + self.add_button("Install Update", Gtk.ResponseType.OK).get_style_context().add_class("suggested-action") + self.add_button("Ignore", Gtk.ResponseType.NO) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + + self._labels = [ + f"A new version {self.changelog.get('tag_name', '0.0.0')} of LinuxToys is available.", + f"Current version: {__version__}" + ] + + for _l in self._labels: + _label = Gtk.Label() + _label.set_use_markup(True) + _label.set_markup(f"{_l}") + _label.set_line_wrap(True) + _label.set_halign(Gtk.Align.CENTER) + _label.get_style_context() + + vbox.pack_start(_label, False, False, 0) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_hexpand(True) + scrolled.set_vexpand(True) + + self.textview = Gtk.TextView() + self.textview.set_editable(False) + self.textview.set_cursor_visible(False) + self.textview.set_wrap_mode(Gtk.WrapMode.WORD) + self.textview.set_border_width(5) + + self.textview.add_events(Gdk.EventMask.BUTTON_RELEASE_MASK) + self.textview.connect("button-release-event", self._on_event_after) + self.textview.add_events(Gdk.EventMask.POINTER_MOTION_MASK) + self.textview.connect("motion-notify-event", self._on_motion_notify) + + self._update_buffer() + + scrolled.add(self.textview) + + vbox.pack_start(scrolled, True, True, 10) + + self.get_content_area().add(vbox) + + self.connect("response", self._on_response) + self.show_all() + + def _run_process(self): + self.destroy() + try: + with open("/tmp/.self_update_lt", 'w') as f: + script_content = '''#!/bin/bash\nsource "$SCRIPT_DIR/libs/linuxtoys.lib"\nsudo_rq\ncurl -fsSL https://linux.toys/install.sh | sh\n''' + f.write(script_content) + + self.parent.open_term_view([{ + 'icon': "linuxtoys.svg", + 'name': "Update LinuxToys", + 'description': "Update to new version of LinuxToys.", + 'repo': "https://github.com/psygreg/linuxtoys/releases", + 'path': "/tmp/.self_update_lt", + 'self_update': True, + 'is_script': True + }]) + except Exception as e: + DialogError(self.parent, str(e)).show() + + def _on_response(self, dialog, response_id): + if response_id == Gtk.ResponseType.OK: + GLib.idle_add(self._run_process) + self.destroy() + + elif response_id == Gtk.ResponseType.NO: + self.destroy() + + def _on_motion_notify(self, textview, event): + x, y = textview.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, int(event.x), int(event.y) + ) + success, iter_at_location = textview.get_iter_at_location(x, y) + if not success: + textview.get_window(Gtk.TextWindowType.TEXT).set_cursor(None) + return False + + tags = iter_at_location.get_tags() + over_link = any( + "link" in t.get_property("name") + for t in tags + if t.get_property("name") + ) + + window = textview.get_window(Gtk.TextWindowType.TEXT) + display = Gdk.Display.get_default() + if over_link: + cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.HAND2) + window.set_cursor(cursor) + else: + window.set_cursor(None) + + return False + + def _on_event_after(self, widget, event): + if event.type == Gdk.EventType.BUTTON_RELEASE and event.button == 1: + x, y = self.textview.window_to_buffer_coords( + Gtk.TextWindowType.TEXT, int(event.x), int(event.y) + ) + iter_at_location = self.textview.get_iter_at_location(x, y)[1] + for tag in iter_at_location.get_tags(): + if tag in self.link_tags: + url = self.link_tags[tag] + webbrowser.open(url) + return True + return False + + def _update_buffer(self): + body_text = self.changelog.get("body", "No changelog available.") + buff = self._markdown_to_textbuffer(body_text) + self.textview.set_buffer(buff) + + def _markdown_to_textbuffer(self, md_text): + """ + Convert simplified Markdown to Gtk.TextBuffer with tags. + Supports: + - **bold** + - _italic_ + - - lists + - [link](url) -> clickable + """ + buffer = Gtk.TextBuffer() + + # Create tags + tag_bold = buffer.create_tag("bold", weight=Pango.Weight.BOLD) + tag_italic = buffer.create_tag("italic", style=Pango.Style.ITALIC) + links_counter = 0 + + def insert_with_tag(text, tag=None): + end_iter = buffer.get_end_iter() + if tag: + buffer.insert_with_tags(end_iter, text, tag) + else: + buffer.insert(end_iter, text) + + # Split by lines + for line in md_text.splitlines(): + # Convert lists + line = re.sub(r"^\s*[-*]\s+", "• ", line) + + pos = 0 + while pos < len(line): + # Search for bold, italic, link + m_bold = re.search(r"\*\*(.+?)\*\*", line[pos:]) + m_italic = re.search(r"_(.+?)_", line[pos:]) + m_link = re.search(r"\[([^\]]+)\]\(([^)]+)\)", line[pos:]) + m_title = re.search(r"^(#{1,6})\s*(.+)", line[pos:]) + + matches = [m for m in [m_bold, m_italic, m_link, m_title] if m] + + if not matches: + insert_with_tag(line[pos:]) + break + + m_first = min(matches, key=lambda x: x.start()) + start, end = m_first.span() + insert_with_tag(line[pos : pos + start]) + + if m_first == m_title: + tag_title = buffer.create_tag( + None, + weight=Pango.Weight.BOLD, + scale=float(2.0 - (len(m_title.group(1)) - 1) * 0.2), + ) + insert_with_tag(m_title.group(2), tag_title) + elif m_first == m_bold: + insert_with_tag(m_first.group(1), tag_bold) + elif m_first == m_italic: + insert_with_tag(m_first.group(1), tag_italic) + elif m_first == m_link: + links_counter += 1 + tag_link = buffer.create_tag( + f"link-{links_counter}", + foreground="#4169E1", + underline=Pango.Underline.SINGLE, + ) + self.link_tags[tag_link] = m_first.group(2) + insert_with_tag(m_first.group(1), tag_link) + + pos += end + + insert_with_tag("\n") + + return buffer diff --git a/p3/app/updater/update_helper.py b/p3/app/updater/update_helper.py new file mode 100644 index 00000000..6c10c3ca --- /dev/null +++ b/p3/app/updater/update_helper.py @@ -0,0 +1,121 @@ +import json, subprocess, os, json, re +from . import __version__ +import urllib.request +import urllib.error + + +class UpdateHelper(): + def __init__(self): + self._current_ver = __version__ + self._latest_ver = {} + + def _update_available(self) -> bool: + if not os.environ.get('DEV_MODE') == '1': + if not self._is_from_repository(): + return self._check_for_updates() + + return False + + def _check_for_updates(self) -> bool: + self._latest_ver = self._get_latest_version() + + if self._compare_versions(self._current_ver, self._latest_ver.get("tag_name", "")): + return True + else: + return False + + def _compare_versions(self, current, latest) -> int: + """ + Compare two version strings. + Returns: + - 1 if latest > current (update available) + - 0 if latest == current (up to date) + - -1 if latest < current (current is newer) + """ + def version_tuple(v): + # Convert version string to tuple of integers for comparison + # e.g., "4.3.1" -> (4, 3, 1), "4.3" -> (4, 3, 0) + parts = v.split('.') + return tuple(int(part) for part in parts) + (0,) * (3 - len(parts)) + + try: + current_tuple = version_tuple(current) + latest_tuple = version_tuple(latest) + + if latest_tuple > current_tuple: + return 1 + elif latest_tuple == current_tuple: + return 0 + else: + return -1 + except (ValueError, TypeError): + # If version parsing fails, assume no update needed + return 0 + + def _get_latest_version(self, repo_owner="psygreg", repo_name="linuxtoys") -> dict: + """ + Fetch the latest release info from GitHub API. + Returns dict with tag_name and body if successful, None otherwise. + """ + try: + api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest" + request = urllib.request.Request(api_url) + """ + User-Agent: + https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api?apiVersion=2022-11-28#user-agent + """ + request.add_header('User-Agent', 'LinuxToys-UpdateChecker/1.0') + + with urllib.request.urlopen(request, timeout=10) as response: + if response.status == 200: + data = json.loads(response.read().decode('utf-8')) + return { + "tag_name": data.get('tag_name', '').lstrip('v'), + "body": data.get('body', '') + } + except Exception as e: + print(f"Error fetching latest release: {e}") + return {"tag_name": "", "body": ""} + + def __is_from_git(self, path='.') -> bool: + try: + return ( + subprocess.call( + ["git", "-C", path, "status"], + stderr=subprocess.STDOUT, + stdout=open(os.devnull, "w"), + ) + == 0 + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + + def __is_from_ppa(self) -> bool: + try: + return any( + list( + filter( + lambda f: "linuxtoys" in f, + os.listdir("/etc/apt/sources.list.d/"), + ) + ) + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + def __is_from_copr(self) -> bool: + try: + return any( + list( + filter( + lambda f: "linuxtoys" in f, + os.listdir("/etc/yum.repos.d/"), + ) + ) + ) + except (FileNotFoundError, subprocess.CalledProcessError): + return False + + def _is_from_repository(self): + return self.__is_from_ppa() or self.__is_from_copr() or self.__is_from_git(os.path.dirname(__file__)) diff --git a/p3/app/window.py b/p3/app/window.py index 2d4119cd..cd46f672 100644 --- a/p3/app/window.py +++ b/p3/app/window.py @@ -1,6 +1,6 @@ from .gtk_common import Gtk, GLib, Gdk, Vte, Pango from gi.repository import GdkPixbuf -import os, shutil +import os, shutil, threading from . import parser from . import header @@ -11,6 +11,9 @@ from . import get_icon_path from . import term_view from . import revealer +from .updater.update_helper import UpdateHelper +from .updater.update_dialog import UpdateDialog + class AppWindow(Gtk.ApplicationWindow): def __init__(self, application, translations, *args, **kwargs): @@ -127,6 +130,23 @@ def __init__(self, application, translations, *args, **kwargs): # Initialize drag-and-drop but don't enable it by default self._setup_drag_and_drop() + GLib.idle_add(self._check_updates) + + def _check_updates(self): + threading.Thread( + target=self._show_dialog_and_update, + daemon=True + ).start() + + def _show_dialog_and_update(self): + self._check = UpdateHelper() + if self._check._update_available(): + GLib.idle_add(self._open_update_dialog, self._check._latest_ver) + + def _open_update_dialog(self, latest_ver): + UpdateDialog(latest_ver, self).show() + return False + def _on_key_press(self, widget, event): keyval = event.keyval