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