From b6e44cfc7d3c83351407ebe0ea3144932ec430a3 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:37:50 +0100 Subject: [PATCH 01/21] Auto updater system Adds an updating system that grabs the latest release from GitHub and prompts the user there is a new update and allows them to install if they want. --- ghost.py | 17 ++- gui/main.py | 58 ++++++---- gui/pages/__init__.py | 3 +- gui/pages/update.py | 155 ++++++++++++++++++++++++++ utils/__init__.py | 5 +- utils/config/__init__.py | 5 +- utils/updater.py | 234 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 gui/pages/update.py create mode 100644 utils/updater.py diff --git a/ghost.py b/ghost.py index 3742bb9..6112381 100755 --- a/ghost.py +++ b/ghost.py @@ -15,7 +15,7 @@ from utils.files import get_application_support from utils.config import Config -from utils import startup_check, check_fonts, console, load_fonts, is_admin, run_elevated, relaunch_normal, send_telemetry_ping +from utils import startup_check, check_fonts, console, load_fonts, is_admin, run_elevated, relaunch_normal, send_telemetry_ping, get_update_info from bot.controller import BotController @@ -34,13 +34,13 @@ def parse_args(): ) return parser.parse_args() -def run_gui(): +def run_gui(update_info=None): from gui.main import GhostGUI cfg = Config() controller = BotController() - GhostGUI(controller).run() + GhostGUI(controller, update_info=update_info).run() def run_cli(): @@ -70,6 +70,7 @@ def main(): headless = args.headless get_application_support() + update_info = get_update_info() startup_check.check() cfg = Config() cfg.check() @@ -82,11 +83,15 @@ def main(): console.info("Running in GUI mode.") + if update_info and update_info.has_update: + run_gui(update_info=update_info) + return + if cfg.get_skip_fonts(): cfg.set_skip_fonts(False) - run_gui() + run_gui(update_info=update_info) elif check_fonts(): - run_gui() + run_gui(update_info=update_info) elif args.install_fonts: load_fonts() if check_fonts(): @@ -96,7 +101,7 @@ def main(): run_elevated() return load_fonts() - run_gui() + run_gui(update_info=update_info) if __name__ == "__main__": main() diff --git a/gui/main.py b/gui/main.py index c47c9a0..b83b887 100644 --- a/gui/main.py +++ b/gui/main.py @@ -11,16 +11,18 @@ from utils.config import Config import utils.console as logging from utils.files import resource_path +from utils import UpdateInfo -from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage, ToolsPage +from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage, ToolsPage, UpdatePage from gui.components import Sidebar, Console, Titlebar, RoundedFrame from gui.helpers import Images, Layout, Style, apply_theme class GhostGUI: - def __init__(self, bot_controller): + def __init__(self, bot_controller, update_info=None): self.resize_grip_size = 5 self.size = (750, 530) self.bot_controller = bot_controller + self.update_info = update_info self.resize_grips = {} self.root = ttk.tk.Tk() @@ -84,6 +86,7 @@ def __init__(self, bot_controller): self.settings_page = SettingsPage(self.root, self.bot_controller, self.draw_settings) self.scripts_page = ScriptsPage(self, self.bot_controller, self.images) self.tools_page = ToolsPage(self.root, self.bot_controller, self.images, self.layout, self._position_resize_grips) + self.update_page = UpdatePage(self.root, self) logging.set_gui(self) @@ -249,6 +252,33 @@ def draw_onboarding(self): main = self.layout.main(scrollable=False) self.onboarding_page.draw(main) self.root.after(150, self._position_resize_grips) + + def draw_update(self): + self.sidebar.set_current_page("onboarding") + self.layout.clear() + main = self.layout.main(scrollable=False) + self.update_page.draw(main) + self.root.after(150, self._position_resize_grips) + + def _continue_after_update_prompt(self): + self.update_info = None + + if self.cfg.get("token") == "": + self.draw_onboarding() + return + + if not self.bot_controller.running: + self.bot_controller.start() + + self.root.after(25, self.sidebar.disable) + self.draw_home(start=True) + self.root.after(100, self._pre_load_images) + + if sys.platform == "win32": + self.root.after(50, lambda: hPyT.window_frame.restore(self.root)) + self.root.after(75, lambda: hPyT.window_frame.center(self.root)) + + self.root.after(100, self._check_bot_started) # def draw_console(self): # self.sidebar.set_current_page("console") @@ -328,29 +358,11 @@ def run(self, preserve_position=False): if not preserve_position: self.layout.center_window(self.size[0], self.size[1]) - if self.cfg.get("token") == "": - # if True: - self.draw_onboarding() + if self.update_info and self.update_info.has_update: + self.draw_update() self.root.mainloop() return - - if not self.bot_controller.running: - self.bot_controller.start() - - # self.layout.hide_titlebar() - # self.layout.stick_window() - # self.layout.resize(400, 90) - # self.layout.center_window(400, 90) - # self.loading_page.draw() - self.root.after(25, self.sidebar.disable) - self.draw_home(start=True) - self.root.after(100, self._pre_load_images) - - if sys.platform == "win32": - self.root.after(50, lambda: hPyT.window_frame.restore(self.root)) - self.root.after(75, lambda: hPyT.window_frame.center(self.root)) - - self.root.after(100, self._check_bot_started) + self._continue_after_update_prompt() self.root.mainloop() def quit(self): diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py index a6cfea0..24c3841 100644 --- a/gui/pages/__init__.py +++ b/gui/pages/__init__.py @@ -4,4 +4,5 @@ from .onboarding import OnboardingPage from .scripts import ScriptsPage from .script import ScriptPage -from .tools import ToolsPage \ No newline at end of file +from .tools import ToolsPage +from .update import UpdatePage \ No newline at end of file diff --git a/gui/pages/update.py b/gui/pages/update.py new file mode 100644 index 0000000..d531c1e --- /dev/null +++ b/gui/pages/update.py @@ -0,0 +1,155 @@ +import re +import webbrowser + +import ttkbootstrap as ttk + +from gui.components import RoundedFrame, RoundedButton +from gui.helpers import Style + +class UpdatePage: + def __init__(self, root, master): + self.root = root + self.master = master + + def _build_markdown_tags(self, text_widget): + base_font = ("Host Grotesk", 12) + text_widget.tag_configure("heading1", font=("Host Grotesk", 16, "bold"), spacing1=10, spacing3=8) + text_widget.tag_configure("heading2", font=("Host Grotesk", 14, "bold"), spacing1=8, spacing3=6) + text_widget.tag_configure("heading3", font=("Host Grotesk", 13, "bold"), spacing1=6, spacing3=4) + text_widget.tag_configure("bold", font=("Host Grotesk", 12, "bold")) + text_widget.tag_configure("italic", font=("Host Grotesk", 12, "italic")) + text_widget.tag_configure("underline", font=("Host Grotesk", 12, "underline")) + text_widget.tag_configure("inline_code", font=("JetBrains Mono", 11), background=self.root.style.colors.get("secondary"), foreground=self.root.style.colors.get("text")) + text_widget.tag_configure("blockquote", lmargin1=0, lmargin2=0, foreground="#b3b3b3") + text_widget.tag_configure("bullet", lmargin1=18, lmargin2=36) + text_widget.tag_configure("plain", font=base_font) + text_widget.tag_configure("warning", font=("Host Grotesk", 13, "bold"), foreground="#facc15", spacing1=6, spacing3=4) + text_widget.tag_configure("note", font=("Host Grotesk", 13, "bold"), foreground="#158bfa", spacing1=6, spacing3=4) + + def _insert_inline_markdown(self, text_widget, line, base_tags=()): + patterns = [ + (r"(.*?)", "underline"), + (r"`([^`]+)`", "inline_code"), + (r"\*\*([^*]+)\*\*", "bold"), + (r"(?\s*(WARNING|NOTE)\s*$", stripped) + if admonition: + tag = "warning" if admonition.group(1) == "WARNING" else "note" + text_widget.insert("end", admonition.group(1) + "\n", (tag,)) + continue + if stripped.startswith(("- ", "* ")): + prefix = "• " if stripped.startswith(("- ", "* ")) else "" + text_widget.insert("end", prefix) + self._insert_inline_markdown(text_widget, stripped[2:]) + text_widget.insert("end", "\n", ("bullet",)) + continue + if stripped.startswith(">"): + blockquote_content = stripped[1:].strip() + self._insert_inline_markdown(text_widget, blockquote_content, ("blockquote",)) + text_widget.insert("end", "\n", ("blockquote",)) + continue + + self._insert_inline_markdown(text_widget, stripped) + text_widget.insert("end", "\n", ("plain",)) + + def draw(self, wrapper): + update_info = getattr(self.master, "update_info", None) + + ttk.Label(wrapper, text="New Update Available", font=("Host Grotesk", 24, "bold")).pack(anchor="w", padx=24, pady=(24, 10)) + + version_text = "A new version is ready to install." + if update_info: + version_text = f"Version {update_info.latest_version} is ready to install." + + ttk.Label(wrapper, text=version_text, wraplength=500, justify="left").pack(anchor="w", padx=24) + + card = RoundedFrame(wrapper, radius=(18, 18, 0, 0), background=self.root.style.colors.get("secondary")) + card.pack(fill=ttk.BOTH, expand=True, padx=24, pady=24) + + changelog_label = ttk.Label(card, text="Changelog", font=("Host Grotesk", 14, "bold")) + changelog_label.configure(background=self.root.style.colors.get("secondary")) + changelog_label.pack(anchor="w", pady=(10, 10), padx=10) + + if update_info and update_info.changelog: + changelog_frame = RoundedFrame(card, radius=(0, 0, 18, 18), background=self.root.style.colors.get("dark"), parent_background=self.root.style.colors.get("bg")) + changelog_frame.pack(fill=ttk.BOTH, expand=True) + + changelog = ttk.tk.Text(changelog_frame, height=10, wrap="word", borderwidth=0, highlightthickness=0) + self._render_markdown(changelog, update_info.changelog.strip()) + changelog.configure(state="disabled", background=self.root.style.colors.get("dark"), foreground=self.root.style.colors.get("text"), borderwidth=0, highlightthickness=0, font=("Host Grotesk", 12)) + changelog.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) + + button_row = ttk.Frame(wrapper) + button_row.pack(fill=ttk.X, padx=24, pady=(8, 24)) + + full_changelog_button = RoundedButton( + button_row, + text="Full Changelog", + command=lambda e: webbrowser.open(update_info.full_changelog_url) if update_info else None, + ) + full_changelog_button.pack(side=ttk.LEFT) + + install_button = RoundedButton(button_row, text="Install Update", command=lambda e: update_info.install() if update_info else None) + install_button.pack(side=ttk.RIGHT) + + skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt()) + skip_button.pack(side=ttk.RIGHT, padx=(0, 10)) diff --git a/utils/__init__.py b/utils/__init__.py index d1feb39..11aba4a 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,9 +1,10 @@ from .startup_check import check from .notifier import Notifier from .webhook import Webhook -from .config import Config, RichPresence, Sniper, Theme, Token, VERSION, PRODUCTION, MOTD, CHANGELOG +from .config import Config, RichPresence, Sniper, Theme, Token, VERSION, PRODUCTION, MOTD, CHANGELOG, REPO from .console import get_formatted_time from .files import resource_path from .fonts import load_fonts, uninstall_fonts, check_fonts, get_fonts, is_admin, run_elevated, relaunch_normal from .defaults import DEFAULT_CONFIG, DEFAULT_THEME, DEFAULT_RPC, DEFAULT_SCRIPT -from .telemetry import send_telemetry_ping \ No newline at end of file +from .telemetry import send_telemetry_ping +from .updater import UpdateInfo, check_for_updates, get_update_info, install_update, should_update \ No newline at end of file diff --git a/utils/config/__init__.py b/utils/config/__init__.py index 0a50ddd..5e81bc7 100644 --- a/utils/config/__init__.py +++ b/utils/config/__init__.py @@ -4,7 +4,8 @@ from .config import Config from .token import Token -VERSION = "4.2.3-dev" +VERSION = "4.2.1" PRODUCTION = False MOTD = "lil hotfix cuz i forgor bugs" -CHANGELOG = """""" \ No newline at end of file +CHANGELOG = """""" +REPO = "https://github.com/ghostselfbot/ghost" \ No newline at end of file diff --git a/utils/updater.py b/utils/updater.py new file mode 100644 index 0000000..4da6a18 --- /dev/null +++ b/utils/updater.py @@ -0,0 +1,234 @@ +import os +from dataclasses import dataclass +import subprocess +import sys +import tempfile +import zipfile + +import requests + +from utils import VERSION, console, REPO, files + +MACOS = "https://github.com/ghostselfbot/ghost/releases/latest/download/Ghost-Mac.zip" +WINDOWS = "https://github.com/ghostselfbot/ghost/releases/latest/download/Ghost-Windows.zip" + + +def _compare_url(current_version, latest_version): + return f"{REPO}/compare/{_normalize_release_tag(current_version)}...{_normalize_release_tag(latest_version)}" + + +@dataclass +class UpdateInfo: + current_version: str + latest_version: str + changelog: str = "" + + @property + def full_changelog_url(self): + return _compare_url(self.current_version, self.latest_version) + + @property + def has_update(self): + return _should_update(self.current_version, self.latest_version) + + def install(self): + return install_update(self) + + +def _normalize_version(version): + version = str(version).strip() + if version.startswith(("v", "V")): + version = version[1:] + + base_version = version.split("-", 1)[0] + parts = [] + for part in base_version.split("."): + if part.isdigit(): + parts.append(int(part)) + else: + break + + return tuple(parts) + + +def _should_update(current_version, latest_version): + current_parts = _normalize_version(current_version) + latest_parts = _normalize_version(latest_version) + + max_len = max(len(current_parts), len(latest_parts)) + current_parts += (0,) * (max_len - len(current_parts)) + latest_parts += (0,) * (max_len - len(latest_parts)) + + return current_parts < latest_parts + + +def should_update(): + update_info = get_update_info() + return bool(update_info and update_info.has_update) + + +def _release_api_url(): + return REPO.replace("https://github.com/", "https://api.github.com/repos/") + "/releases/latest" + + +def _normalize_release_tag(tag_name): + tag_name = str(tag_name).strip() + if tag_name.startswith(("v", "V")): + return tag_name[1:] + return tag_name + + +def _fetch_release_metadata(): + response = requests.get(_release_api_url(), timeout=5, headers={"Accept": "application/vnd.github+json"}) + response.raise_for_status() + release_data = response.json() + latest_version = _normalize_release_tag(release_data.get("tag_name", VERSION)) + changelog = _strip_admonitions(release_data.get("body", "") or "") + return latest_version, changelog + + +def _strip_admonitions(changelog): + cleaned_lines = [] + + for line in changelog.splitlines(): + line = line.replace("[!NOTE]", "NOTE") + line = line.replace("[!WARNING]", "WARNING") + cleaned_lines.append(line) + + return "\n".join(cleaned_lines).strip() + + +def get_update_info(): + try: + latest_version, changelog = _fetch_release_metadata() + if _should_update(VERSION, latest_version): + return UpdateInfo(current_version=VERSION, latest_version=latest_version, changelog=changelog) + + return None + except requests.exceptions.RequestException as exc: + console.error(f"Failed to check for updates: {exc}") + return None + + +def check_for_updates(): + update_info = get_update_info() + if update_info: + console.info(f"A new version is available: {update_info.latest_version}. You are currently on {update_info.current_version}.") + return update_info + + console.success("You are using the latest version.") + return None + + +def _get_update_url(): + if sys.platform == "darwin": + return MACOS + if sys.platform == "win32": + return WINDOWS + raise RuntimeError("Unsupported platform for automatic updates.") + + +def _find_executable(extract_dir): + if sys.platform == "darwin": + app_paths = [] + for root, dirs, _ in os.walk(extract_dir): + for dirname in dirs: + if dirname.lower().endswith(".app"): + app_paths.append(os.path.join(root, dirname)) + return app_paths[0] if app_paths else None + + if sys.platform == "win32": + exe_paths = [] + for root, _, files in os.walk(extract_dir): + for filename in files: + if filename.lower().endswith(".exe"): + exe_paths.append(os.path.join(root, filename)) + return exe_paths[0] if exe_paths else None + + return None + + +def _prepare_macos_app(app_path): + if sys.platform != "darwin": + return + + subprocess.run(["xattr", "-cr", app_path], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["chmod", "-R", "u+rwX", app_path], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + macos_bin_dir = os.path.join(app_path, "Contents", "MacOS") + if os.path.isdir(macos_bin_dir): + for entry in os.listdir(macos_bin_dir): + executable_file = os.path.join(macos_bin_dir, entry) + subprocess.run(["chmod", "+x", executable_file], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _extract_update_archive(archive_path, extract_dir): + if sys.platform == "darwin": + subprocess.run(["ditto", "-x", "-k", archive_path, extract_dir], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + + with zipfile.ZipFile(archive_path, "r") as archive: + archive.extractall(extract_dir) + + +def _current_install_path(): + if not getattr(sys, "frozen", False): + return None + + if sys.platform == "darwin": + return os.path.realpath(os.path.dirname(os.path.dirname(os.path.dirname(sys.executable)))) + + if sys.platform == "win32": + return os.path.realpath(sys.executable) + + return None + + +def install_update(update_info=None): + try: + url = _get_update_url() + except RuntimeError as exc: + console.error(f"{exc}") + return False + + try: + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() + filename = os.path.basename(url) + update_dir = os.path.join(files.get_application_support(), ".ghost-updates") + os.makedirs(update_dir, exist_ok=True) + archive_path = os.path.join(update_dir, filename) + + with open(archive_path, "wb") as update_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + update_file.write(chunk) + + console.info(f"Downloaded update archive: {filename}") + + extract_dir = tempfile.mkdtemp(prefix="ghost-update-", dir=update_dir) + _extract_update_archive(archive_path, extract_dir) + + executable_path = _find_executable(extract_dir) + if not executable_path: + console.error("Could not find an executable inside the downloaded update archive.") + return False + + if sys.platform == "darwin" and executable_path.lower().endswith(".app"): + _prepare_macos_app(executable_path) + subprocess.Popen(["open", "-n", executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + subprocess.Popen([executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + console.info(f"Launched updated application from {executable_path}.") + raise SystemExit(0) + except requests.exceptions.RequestException as exc: + console.error(f"Failed to download update: {exc}") + return False + except (OSError, zipfile.BadZipFile) as exc: + console.error(f"Failed to install update: {exc}") + return False + + +def download_update(): + return install_update() \ No newline at end of file From 94a1ff5203c496233bf7a89b85a2f35b0d37593c Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:19:35 +0100 Subject: [PATCH 02/21] Changed button styles --- gui/pages/update.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gui/pages/update.py b/gui/pages/update.py index d531c1e..ba6654d 100644 --- a/gui/pages/update.py +++ b/gui/pages/update.py @@ -145,11 +145,12 @@ def draw(self, wrapper): button_row, text="Full Changelog", command=lambda e: webbrowser.open(update_info.full_changelog_url) if update_info else None, + bootstyle="secondary.TButton", ) full_changelog_button.pack(side=ttk.LEFT) - install_button = RoundedButton(button_row, text="Install Update", command=lambda e: update_info.install() if update_info else None) + install_button = RoundedButton(button_row, text="Install Update", command=lambda e: update_info.install() if update_info else None, bootstyle="primary.TButton") install_button.pack(side=ttk.RIGHT) - skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt()) + skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt(), bootstyle="secondary.TButton") skip_button.pack(side=ttk.RIGHT, padx=(0, 10)) From 280e48dfbba28b762f4cfcab453d63f07862b0e9 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:20:13 +0100 Subject: [PATCH 03/21] Added handoff script that replaces the original executable/process with the new update and then quits old process and runs new one! --- utils/updater.py | 56 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/utils/updater.py b/utils/updater.py index 4da6a18..4fb8b1a 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -1,5 +1,6 @@ import os from dataclasses import dataclass +import shlex import subprocess import sys import tempfile @@ -184,6 +185,50 @@ def _current_install_path(): return None +def _start_replacement_handoff(current_path, updated_path, update_dir): + current_pid = str(os.getpid()) + + if sys.platform == "darwin": + script_path = os.path.join(update_dir, "install-update.sh") + log_path = os.path.join(update_dir, "install-update.log") + updated_executable = os.path.join(updated_path, "Contents", "MacOS", os.path.basename(sys.executable)) + current_executable = os.path.join(current_path, os.path.relpath(updated_executable, updated_path)) + script = "\n".join([ + "#!/bin/sh", + f"exec >> {shlex.quote(log_path)} 2>&1", + f"while kill -0 {current_pid} 2>/dev/null; do sleep 1; done", + f"rm -rf {shlex.quote(current_path)}", + f"mv {shlex.quote(updated_path)} {shlex.quote(current_path)}", + "unset _PYI_APPLICATION_HOME_DIR _PYI_PARENT_PROCESS_LEVEL _PYI_SPLASH_IPC", + "export PYINSTALLER_RESET_ENVIRONMENT=1", + f"{shlex.quote(current_executable)} &", + f"rm -rf {shlex.quote(update_dir)}", + "exit 0", + ]) + with open(script_path, "w", encoding="utf-8") as script_file: + script_file.write(f"{script}\n") + os.chmod(script_path, 0o700) + subprocess.Popen(["/bin/sh", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + + if sys.platform == "win32": + script_path = os.path.join(update_dir, "install-update.cmd") + powershell_update_dir = update_dir.replace("'", "''") + script = "\r\n".join([ + "@echo off", + f"powershell -NoProfile -Command \"Wait-Process -Id {current_pid} -ErrorAction SilentlyContinue\"", + f"move /Y \"{updated_path}\" \"{current_path}\" >nul", + f"start \"\" \"{current_path}\"", + f"start \"\" /b powershell -NoProfile -WindowStyle Hidden -Command \"Start-Sleep -Seconds 2; Remove-Item -LiteralPath '{powershell_update_dir}' -Recurse -Force\"", + ]) + with open(script_path, "w", encoding="utf-8") as script_file: + script_file.write(f"{script}\r\n") + subprocess.Popen(["cmd", "/c", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + + raise RuntimeError("Unsupported platform for automatic updates.") + + def install_update(update_info=None): try: url = _get_update_url() @@ -214,13 +259,16 @@ def install_update(update_info=None): console.error("Could not find an executable inside the downloaded update archive.") return False + current_path = _current_install_path() + if not current_path: + console.error("Automatic updates are only available from an installed Ghost application.") + return False + if sys.platform == "darwin" and executable_path.lower().endswith(".app"): _prepare_macos_app(executable_path) - subprocess.Popen(["open", "-n", executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - else: - subprocess.Popen([executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - console.info(f"Launched updated application from {executable_path}.") + _start_replacement_handoff(current_path, executable_path, update_dir) + console.info(f"Installed update to {current_path}; restarting Ghost.") raise SystemExit(0) except requests.exceptions.RequestException as exc: console.error(f"Failed to download update: {exc}") From 28f2364bd0154217458594e65a3218bbf67db24d Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:37 +0100 Subject: [PATCH 04/21] Run installer in a background thread and add a loading bar --- gui/pages/update.py | 64 +++++++++++++++++++++++++++++++++++++++++---- utils/updater.py | 21 ++++++++++++--- 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/gui/pages/update.py b/gui/pages/update.py index ba6654d..c1b3dca 100644 --- a/gui/pages/update.py +++ b/gui/pages/update.py @@ -1,4 +1,5 @@ import re +import threading import webbrowser import ttkbootstrap as ttk @@ -10,6 +11,7 @@ class UpdatePage: def __init__(self, root, master): self.root = root self.master = master + self.installing = False def _build_markdown_tags(self, text_widget): base_font = ("Host Grotesk", 12) @@ -111,6 +113,53 @@ def _render_markdown(self, text_widget, markdown_text): self._insert_inline_markdown(text_widget, stripped) text_widget.insert("end", "\n", ("plain",)) + def _set_install_progress(self, status, progress): + if not self.installing: + return + + self.install_status.configure(text=status) + if progress is None: + if self.install_progress.cget("mode") != "indeterminate": + self.install_progress.configure(mode="indeterminate") + self.install_progress.start(12) + return + + if self.install_progress.cget("mode") == "indeterminate": + self.install_progress.stop() + self.install_progress.configure(mode="determinate") + self.install_progress.configure(value=progress) + + def _install_update(self, update_info): + self.installing = True + self.install_button.set_state("disabled") + self.skip_button.set_state("disabled") + self._set_install_progress("Starting update", 0) + + def report(status, progress): + self.root.after(0, self._set_install_progress, status, progress) + + def install(): + try: + installed = update_info.install(progress_callback=report) + except SystemExit: + return + except Exception as exc: + self.root.after(0, self._update_failed, str(exc)) + return + + if not installed: + self.root.after(0, self._update_failed, "Could not install the update. Check the console for details.") + + threading.Thread(target=install, daemon=True).start() + + def _update_failed(self, message): + self.installing = False + self.install_progress.stop() + self.install_progress.configure(mode="determinate", value=0) + self.install_status.configure(text=message) + self.install_button.set_state("normal") + self.skip_button.set_state("normal") + def draw(self, wrapper): update_info = getattr(self.master, "update_info", None) @@ -133,7 +182,7 @@ def draw(self, wrapper): changelog_frame = RoundedFrame(card, radius=(0, 0, 18, 18), background=self.root.style.colors.get("dark"), parent_background=self.root.style.colors.get("bg")) changelog_frame.pack(fill=ttk.BOTH, expand=True) - changelog = ttk.tk.Text(changelog_frame, height=10, wrap="word", borderwidth=0, highlightthickness=0) + changelog = ttk.tk.Text(changelog_frame, height=7, wrap="word", borderwidth=0, highlightthickness=0) self._render_markdown(changelog, update_info.changelog.strip()) changelog.configure(state="disabled", background=self.root.style.colors.get("dark"), foreground=self.root.style.colors.get("text"), borderwidth=0, highlightthickness=0, font=("Host Grotesk", 12)) changelog.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) @@ -141,6 +190,11 @@ def draw(self, wrapper): button_row = ttk.Frame(wrapper) button_row.pack(fill=ttk.X, padx=24, pady=(8, 24)) + self.install_status = ttk.Label(button_row, text="", font=("Host Grotesk", 11)) + self.install_status.pack(side=ttk.TOP, anchor="w", pady=(0, 6)) + self.install_progress = ttk.Progressbar(button_row, mode="determinate", maximum=100, value=0, bootstyle="primary") + self.install_progress.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 12)) + full_changelog_button = RoundedButton( button_row, text="Full Changelog", @@ -149,8 +203,8 @@ def draw(self, wrapper): ) full_changelog_button.pack(side=ttk.LEFT) - install_button = RoundedButton(button_row, text="Install Update", command=lambda e: update_info.install() if update_info else None, bootstyle="primary.TButton") - install_button.pack(side=ttk.RIGHT) + self.install_button = RoundedButton(button_row, text="Install Update", command=lambda e: self._install_update(update_info) if update_info else None, bootstyle="primary.TButton") + self.install_button.pack(side=ttk.RIGHT) - skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt(), bootstyle="secondary.TButton") - skip_button.pack(side=ttk.RIGHT, padx=(0, 10)) + self.skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt(), bootstyle="secondary.TButton") + self.skip_button.pack(side=ttk.RIGHT, padx=(0, 10)) diff --git a/utils/updater.py b/utils/updater.py index 4fb8b1a..0704c61 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -32,8 +32,8 @@ def full_changelog_url(self): def has_update(self): return _should_update(self.current_version, self.latest_version) - def install(self): - return install_update(self) + def install(self, progress_callback=None): + return install_update(self, progress_callback=progress_callback) def _normalize_version(version): @@ -229,7 +229,11 @@ def _start_replacement_handoff(current_path, updated_path, update_dir): raise RuntimeError("Unsupported platform for automatic updates.") -def install_update(update_info=None): +def install_update(update_info=None, progress_callback=None): + def report(status, progress=None): + if progress_callback: + progress_callback(status, progress) + try: url = _get_update_url() except RuntimeError as exc: @@ -237,20 +241,29 @@ def install_update(update_info=None): return False try: + report("Downloading update", 0) response = requests.get(url, stream=True, timeout=30) response.raise_for_status() filename = os.path.basename(url) update_dir = os.path.join(files.get_application_support(), ".ghost-updates") os.makedirs(update_dir, exist_ok=True) archive_path = os.path.join(update_dir, filename) + content_length = int(response.headers.get("content-length", 0)) + downloaded_bytes = 0 + if not content_length: + report("Downloading update", None) with open(archive_path, "wb") as update_file: for chunk in response.iter_content(chunk_size=8192): if chunk: update_file.write(chunk) + downloaded_bytes += len(chunk) + if content_length: + report("Downloading update", downloaded_bytes * 100 / content_length) console.info(f"Downloaded update archive: {filename}") + report("Preparing update", None) extract_dir = tempfile.mkdtemp(prefix="ghost-update-", dir=update_dir) _extract_update_archive(archive_path, extract_dir) @@ -265,8 +278,10 @@ def install_update(update_info=None): return False if sys.platform == "darwin" and executable_path.lower().endswith(".app"): + report("Preparing application", None) _prepare_macos_app(executable_path) + report("Restarting Ghost", None) _start_replacement_handoff(current_path, executable_path, update_dir) console.info(f"Installed update to {current_path}; restarting Ghost.") raise SystemExit(0) From 323d0a107e485877edeac258e24937a74726c7f5 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:28:45 +0100 Subject: [PATCH 05/21] Resize window to be slightly taller to accomodate new loading bar without sacraficing changelog height --- gui/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/main.py b/gui/main.py index b83b887..1892913 100644 --- a/gui/main.py +++ b/gui/main.py @@ -359,6 +359,8 @@ def run(self, preserve_position=False): self.layout.center_window(self.size[0], self.size[1]) if self.update_info and self.update_info.has_update: + self.layout.resize(self.size[0], self.size[1] + 50) + self.layout.center_window(self.size[0], self.size[1] + 50) self.draw_update() self.root.mainloop() return From 030b0cd586e68e434c80876e2c8541132072e0c2 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:38:37 +0100 Subject: [PATCH 06/21] Fix actually quitting the app and starting handoff script automatically after downloading update --- gui/pages/update.py | 12 ++++++++---- utils/updater.py | 10 ++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/gui/pages/update.py b/gui/pages/update.py index c1b3dca..b91108a 100644 --- a/gui/pages/update.py +++ b/gui/pages/update.py @@ -140,18 +140,22 @@ def report(status, progress): def install(): try: - installed = update_info.install(progress_callback=report) - except SystemExit: - return + installed = update_info.install(progress_callback=report, exit_on_success=False) except Exception as exc: self.root.after(0, self._update_failed, str(exc)) return - if not installed: + if installed: + self.root.after(0, self._finish_install) + else: self.root.after(0, self._update_failed, "Could not install the update. Check the console for details.") threading.Thread(target=install, daemon=True).start() + def _finish_install(self): + self.install_status.configure(text="Closing Ghost") + self.root.after(100, self.master.quit) + def _update_failed(self, message): self.installing = False self.install_progress.stop() diff --git a/utils/updater.py b/utils/updater.py index 0704c61..a6de0d5 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -32,8 +32,8 @@ def full_changelog_url(self): def has_update(self): return _should_update(self.current_version, self.latest_version) - def install(self, progress_callback=None): - return install_update(self, progress_callback=progress_callback) + def install(self, progress_callback=None, exit_on_success=True): + return install_update(self, progress_callback=progress_callback, exit_on_success=exit_on_success) def _normalize_version(version): @@ -229,7 +229,7 @@ def _start_replacement_handoff(current_path, updated_path, update_dir): raise RuntimeError("Unsupported platform for automatic updates.") -def install_update(update_info=None, progress_callback=None): +def install_update(update_info=None, progress_callback=None, exit_on_success=True): def report(status, progress=None): if progress_callback: progress_callback(status, progress) @@ -284,7 +284,9 @@ def report(status, progress=None): report("Restarting Ghost", None) _start_replacement_handoff(current_path, executable_path, update_dir) console.info(f"Installed update to {current_path}; restarting Ghost.") - raise SystemExit(0) + if exit_on_success: + raise SystemExit(0) + return True except requests.exceptions.RequestException as exc: console.error(f"Failed to download update: {exc}") return False From 39733a35af50641d26c276cc230413ca4c5b0554 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:54:21 +0100 Subject: [PATCH 07/21] Fix pyinstaller messing up handoff script on Windows --- utils/updater.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/utils/updater.py b/utils/updater.py index a6de0d5..9747686 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -218,6 +218,10 @@ def _start_replacement_handoff(current_path, updated_path, update_dir): "@echo off", f"powershell -NoProfile -Command \"Wait-Process -Id {current_pid} -ErrorAction SilentlyContinue\"", f"move /Y \"{updated_path}\" \"{current_path}\" >nul", + "set \"_PYI_APPLICATION_HOME_DIR=\"", + "set \"_PYI_PARENT_PROCESS_LEVEL=\"", + "set \"_PYI_SPLASH_IPC=\"", + "set \"PYINSTALLER_RESET_ENVIRONMENT=1\"", f"start \"\" \"{current_path}\"", f"start \"\" /b powershell -NoProfile -WindowStyle Hidden -Command \"Start-Sleep -Seconds 2; Remove-Item -LiteralPath '{powershell_update_dir}' -Recurse -Force\"", ]) From 5c4da509c1cf4bfb15aabb98692722f79234f650 Mon Sep 17 00:00:00 2001 From: zeyadhost Date: Tue, 28 Jul 2026 00:40:14 +0300 Subject: [PATCH 08/21] feat(updater): add file logging for update operations --- compile.py | 3 ++- utils/console.py | 24 ++++++++++++++++++++++-- utils/updater.py | 14 +++++++++++++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/compile.py b/compile.py index a2bcdfd..2b09205 100644 --- a/compile.py +++ b/compile.py @@ -1,4 +1,5 @@ import os +import sys import platform import subprocess import plistlib @@ -25,7 +26,7 @@ def build(): icon = "data/icon-win.png" if system == "Windows" else "data/icon.png" args = [ - "pyinstaller", + sys.executable, "-m", "PyInstaller", f"--name={name}", "--onefile", "--clean", diff --git a/utils/console.py b/utils/console.py index ac00eeb..beb38f2 100644 --- a/utils/console.py +++ b/utils/console.py @@ -1,13 +1,31 @@ import os import sys -import logging import datetime import colorama import pystyle from . import config -# handler = logging.FileHandler(filename='ghost.log', encoding='utf-8', mode='w') +_log_file = None + +def _setup_logger(): + global _log_file + try: + log_path = os.path.join(os.getcwd(), "ghost.log") + _log_file = open(log_path, "a", encoding="utf-8") + except: + pass + +def _file_log(text): + if _log_file is None: + _setup_logger() + if _log_file: + try: + _log_file.write(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {text}\n") + _log_file.flush() + except: + pass + gui = None banner = f""" ▄████ ██░ ██ ▒█████ ██████ ▄▄▄█████▓ ██▒ ▀█▒▓██░ ██▒▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒ @@ -75,7 +93,9 @@ def print_banner(): def _log_and_print(prefix, colour, text, gui_log=True): # print(f"[{prefix}] {text}") + line = f"[{get_formatted_time()}] [{prefix}] {text}" print(f"{colorama.Style.NORMAL}{colorama.Fore.WHITE}[{get_formatted_time()}] {colour}{colorama.Style.BRIGHT}[{prefix}]{colorama.Style.RESET_ALL} {text}") + _file_log(line) if gui_log: try: log_to_gui(prefix, text) diff --git a/utils/updater.py b/utils/updater.py index 9747686..90cf2eb 100644 --- a/utils/updater.py +++ b/utils/updater.py @@ -246,6 +246,7 @@ def report(status, progress=None): try: report("Downloading update", 0) + console.info(f"Starting download from {url}") response = requests.get(url, stream=True, timeout=30) response.raise_for_status() filename = os.path.basename(url) @@ -265,27 +266,38 @@ def report(status, progress=None): if content_length: report("Downloading update", downloaded_bytes * 100 / content_length) - console.info(f"Downloaded update archive: {filename}") + console.info(f"Downloaded update archive ({downloaded_bytes} bytes): {filename}") + console.info(f"Saving archive to: {archive_path}") report("Preparing update", None) extract_dir = tempfile.mkdtemp(prefix="ghost-update-", dir=update_dir) + console.info(f"Extracting update to: {extract_dir}") _extract_update_archive(archive_path, extract_dir) + console.info(f"Archive extracted to: {extract_dir}") + executable_path = _find_executable(extract_dir) if not executable_path: console.error("Could not find an executable inside the downloaded update archive.") return False + console.info(f"Found updated executable: {executable_path}") + current_path = _current_install_path() if not current_path: console.error("Automatic updates are only available from an installed Ghost application.") return False + console.info(f"Current install path: {current_path}") + if sys.platform == "darwin" and executable_path.lower().endswith(".app"): report("Preparing application", None) + console.info("Preparing macOS app bundle") _prepare_macos_app(executable_path) + console.info("macOS app bundle prepared") report("Restarting Ghost", None) + console.info(f"Starting replacement handoff: {current_path} <- {executable_path}") _start_replacement_handoff(current_path, executable_path, update_dir) console.info(f"Installed update to {current_path}; restarting Ghost.") if exit_on_success: From 156c915724e686d61666fb22cdb85a1dc8bd5fa5 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:49:47 +0100 Subject: [PATCH 09/21] Move log file to data dir in application support dir --- utils/console.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/utils/console.py b/utils/console.py index beb38f2..7606f17 100644 --- a/utils/console.py +++ b/utils/console.py @@ -5,13 +5,14 @@ import pystyle from . import config +from . import files _log_file = None def _setup_logger(): - global _log_file + global _log_filez try: - log_path = os.path.join(os.getcwd(), "ghost.log") + log_path = os.path.join(files.get_data_path(), "ghost.log") _log_file = open(log_path, "a", encoding="utf-8") except: pass From 3d8d8e0c11ab3b6d48da9484024e3ad2fc0fa4fd Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:02:37 +0100 Subject: [PATCH 10/21] Added custom rounded progressbar component --- gui/components/__init__.py | 1 + gui/components/rounded_progressbar.py | 125 ++++++++++++++++++++++++++ gui/pages/update.py | 4 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 gui/components/rounded_progressbar.py diff --git a/gui/components/__init__.py b/gui/components/__init__.py index 62ff3c8..5dd5071 100644 --- a/gui/components/__init__.py +++ b/gui/components/__init__.py @@ -1,5 +1,6 @@ from .rounded_frame import RoundedFrame from .rounded_button import RoundedButton +from .rounded_progressbar import RoundedProgressbar from .sidebar import Sidebar from .console import Console from .settings_frame import SettingsFrame diff --git a/gui/components/rounded_progressbar.py b/gui/components/rounded_progressbar.py new file mode 100644 index 0000000..f5b307e --- /dev/null +++ b/gui/components/rounded_progressbar.py @@ -0,0 +1,125 @@ +import ttkbootstrap as ttk + +from gui.components.rounded_frame import RoundedFrame + + +class RoundedProgressbar(ttk.Canvas): + def __init__(self, parent, radius=8, **kwargs): + self.parent = parent + self.root = parent.winfo_toplevel() + self.style = self.root.style + self.maximum = float(kwargs.pop("maximum", 100)) + self.value = float(kwargs.pop("value", 0)) + self.mode = kwargs.pop("mode", "determinate") + self.bootstyle = kwargs.pop("bootstyle", kwargs.pop("style", "primary")) + self.track_color = kwargs.pop("track_color", self.style.colors.get("secondary")) + self.bar_color = kwargs.pop("bar_color", self._get_bootstyle_color()) + self.radius = radius + self._animation_id = None + self._animation_value = 0 + + canvas_kwargs = { + key: value + for key, value in kwargs.items() + if key not in {"padx", "pady", "background", "foreground"} + } + canvas_kwargs.setdefault("height", 16) + super().__init__(parent, highlightthickness=0, bd=0, **canvas_kwargs) + super().configure(background=kwargs.get("background", self._get_parent_background())) + self.bind("", self._draw) + + def _get_bootstyle_color(self): + style_name = self.bootstyle.split(".")[0].lower() + return self.style.colors.get(style_name) or self.style.colors.get("primary") + + def _get_parent_background(self): + if isinstance(self.parent, RoundedFrame): + return self.parent.frame_background + try: + style_name = self.parent.cget("style") + return self.style.lookup(style_name, "background") or self.style.colors.get("bg") + except Exception: + return self.style.colors.get("bg") + + def _rounded_rectangle(self, left, top, right, bottom, color): + if right <= left or bottom <= top: + return + + radius = self.radius + radius = min(radius, (right - left) / 2, (bottom - top) / 2) + if radius == 0: + self.create_rectangle(left, top, right, bottom, fill=color, outline=color) + return + + self.create_rectangle(left + radius, top, right - radius, bottom, fill=color, outline=color) + self.create_rectangle(left, top + radius, right, bottom - radius, fill=color, outline=color) + self.create_arc(left, top, left + radius * 2, top + radius * 2, start=90, extent=90, fill=color, outline=color) + self.create_arc(right - radius * 2, top, right, top + radius * 2, start=0, extent=90, fill=color, outline=color) + self.create_arc(right - radius * 2, bottom - radius * 2, right, bottom, start=270, extent=90, fill=color, outline=color) + self.create_arc(left, bottom - radius * 2, left + radius * 2, bottom, start=180, extent=90, fill=color, outline=color) + + def _draw(self, event=None): + self.delete("all") + width = self.winfo_width() + height = self.winfo_height() + if width < 2 or height < 2: + return + + self._rounded_rectangle(0, 0, width, height, self.track_color) + if self.mode == "indeterminate": + segment_width = max(height * 2, width / 4) + left = (width + segment_width) * self._animation_value / 100 - segment_width + self._rounded_rectangle(left, 0, left + segment_width, height, self.bar_color) + return + + progress = min(max(self.value / self.maximum, 0), 1) if self.maximum else 0 + self._rounded_rectangle(0, 0, width * progress, height, self.bar_color) + + def configure(self, cnf=None, **kwargs): + options = dict(cnf or {}) + options.update(kwargs) + redraw = False + + for option in ("maximum", "value", "mode", "bootstyle", "track_color", "bar_color"): + if option not in options: + continue + value = options.pop(option) + if option in {"maximum", "value"}: + value = float(value) + if option == "bootstyle": + self.bootstyle = value + self.bar_color = self._get_bootstyle_color() + else: + setattr(self, option, value) + redraw = True + + result = super().configure(options) if options else None + if redraw: + self._draw() + return result + + config = configure + + def cget(self, key): + if key in {"maximum", "value", "mode", "bootstyle", "track_color", "bar_color"}: + return getattr(self, key) + return super().cget(key) + + def step(self, amount=1): + self.configure(value=self.value + amount) + + def start(self, interval=50): + self.stop() + self.mode = "indeterminate" + self._animation_interval = max(int(interval), 1) + self._animate() + + def _animate(self): + self._animation_value = (self._animation_value + 2) % 200 + self._draw() + self._animation_id = self.after(self._animation_interval, self._animate) + + def stop(self): + if self._animation_id is not None: + self.after_cancel(self._animation_id) + self._animation_id = None \ No newline at end of file diff --git a/gui/pages/update.py b/gui/pages/update.py index b91108a..9f4b447 100644 --- a/gui/pages/update.py +++ b/gui/pages/update.py @@ -4,7 +4,7 @@ import ttkbootstrap as ttk -from gui.components import RoundedFrame, RoundedButton +from gui.components import RoundedFrame, RoundedButton, RoundedProgressbar from gui.helpers import Style class UpdatePage: @@ -196,7 +196,7 @@ def draw(self, wrapper): self.install_status = ttk.Label(button_row, text="", font=("Host Grotesk", 11)) self.install_status.pack(side=ttk.TOP, anchor="w", pady=(0, 6)) - self.install_progress = ttk.Progressbar(button_row, mode="determinate", maximum=100, value=0, bootstyle="primary") + self.install_progress = RoundedProgressbar(button_row, mode="determinate", maximum=100, value=0, bootstyle="primary", radius=5) self.install_progress.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 12)) full_changelog_button = RoundedButton( From 4288fb88c2df53339a5aca0c67a36f9d87cb1080 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:38:55 +0100 Subject: [PATCH 11/21] Remove radius and use default --- gui/pages/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/pages/update.py b/gui/pages/update.py index 9f4b447..83b6e11 100644 --- a/gui/pages/update.py +++ b/gui/pages/update.py @@ -196,7 +196,7 @@ def draw(self, wrapper): self.install_status = ttk.Label(button_row, text="", font=("Host Grotesk", 11)) self.install_status.pack(side=ttk.TOP, anchor="w", pady=(0, 6)) - self.install_progress = RoundedProgressbar(button_row, mode="determinate", maximum=100, value=0, bootstyle="primary", radius=5) + self.install_progress = RoundedProgressbar(button_row, mode="determinate", maximum=100, value=0, bootstyle="primary") self.install_progress.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 12)) full_changelog_button = RoundedButton( From 5a93fd92977aa7854680b036b7fc916388722f0f Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:44:43 +0100 Subject: [PATCH 12/21] Replace os.system with subprocess with CREATE_NO_WINDOW flag --- bot/commands/abuse.py | 4 ++-- utils/console.py | 7 ++++--- utils/fonts.py | 2 +- utils/notifier.py | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/bot/commands/abuse.py b/bot/commands/abuse.py index fd0893e..ef315ca 100755 --- a/bot/commands/abuse.py +++ b/bot/commands/abuse.py @@ -3,8 +3,8 @@ import asyncio import random import faker -import os import threading +import subprocess from discord.ext import commands from utils import config @@ -154,7 +154,7 @@ def channelping_thread_middleman(channel, user, ping_amount, delay): print() console.print_info("All pings done! Clearing console in 15 seconds...") await asyncio.sleep(15) - os.system("clear") + subprocess.run("clear", shell=True, creationflags=subprocess.CREATE_NO_WINDOW) print() console.print_banner() diff --git a/utils/console.py b/utils/console.py index 7606f17..340ee93 100644 --- a/utils/console.py +++ b/utils/console.py @@ -3,6 +3,7 @@ import datetime import colorama import pystyle +import subprocess from . import config from . import files @@ -10,7 +11,7 @@ _log_file = None def _setup_logger(): - global _log_filez + global _log_file try: log_path = os.path.join(files.get_data_path(), "ghost.log") _log_file = open(log_path, "a", encoding="utf-8") @@ -59,14 +60,14 @@ def clear_gui(): def clear(): clear_gui() try: - os.system("cls" if sys.platform == "win32" else "clear") + subprocess.run("cls" if sys.platform == "win32" else "clear", shell=True, creationflags=subprocess.CREATE_NO_WINDOW) except: pass def resize(columns, rows): try: command = f"mode con cols={columns} lines={rows}" if sys.platform == "win32" else f"echo '\033[8;{rows};{columns}t'" - os.system(command) + subprocess.run(command, shell=True, creationflags=subprocess.CREATE_NO_WINDOW) except: pass diff --git a/utils/fonts.py b/utils/fonts.py index 2fe1bdf..2e49196 100644 --- a/utils/fonts.py +++ b/utils/fonts.py @@ -255,4 +255,4 @@ def relaunch_normal(): else: cmd = [sys.executable, sys.argv[0]] args = [arg for arg in sys.argv[1:] if arg != "--install-fonts"] - subprocess.Popen(cmd + args) + subprocess.Popen(cmd + args, creationflags=subprocess.CREATE_NO_WINDOW) diff --git a/utils/notifier.py b/utils/notifier.py index ada3c1c..2dd2ae7 100644 --- a/utils/notifier.py +++ b/utils/notifier.py @@ -11,9 +11,9 @@ def send(title: str, text :str) -> None: # TODO: add notifs for windows pass elif sys.platform == "linux": - subprocess.Popen(["notify-send", title, text]) + subprocess.Popen(["notify-send", title, text], creationflags=subprocess.CREATE_NO_WINDOW) elif sys.platform == "darwin": cmd = '''on run argv display notification (item 2 of argv) with title (item 1 of argv) end run''' - subprocess.call(['osascript', '-e', cmd, title, text]) \ No newline at end of file + subprocess.call(['osascript', '-e', cmd, title, text], creationflags=subprocess.CREATE_NO_WINDOW) \ No newline at end of file From 353d0af4b4d53f9903e33d1779cd56871e4f7c92 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:44:56 +0100 Subject: [PATCH 13/21] Remove --windowed from pyinstaller args --- compile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compile.py b/compile.py index 2b09205..5f47495 100644 --- a/compile.py +++ b/compile.py @@ -31,7 +31,7 @@ def build(): "--onefile", "--clean", "--noconfirm", - "--windowed", + # "--windowed", "--noconsole", f"--icon={icon}", "--hidden-import=discord", From 2964ab676604e60e2818dae481d3bbf73978b3da Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:05:41 +0100 Subject: [PATCH 14/21] Change strength bar to use new rounded progress bar --- gui/pages/tools/password_gen.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/pages/tools/password_gen.py b/gui/pages/tools/password_gen.py index ee437e0..31a390c 100644 --- a/gui/pages/tools/password_gen.py +++ b/gui/pages/tools/password_gen.py @@ -2,7 +2,7 @@ import string import random import ttkbootstrap as ttk -from gui.components import RoundedFrame, ToolPage, RoundedButton +from gui.components import RoundedFrame, ToolPage, RoundedProgressbar from gui.helpers import Style class PasswordGenPage(ToolPage): @@ -127,7 +127,7 @@ def draw_options(self, parent): self.strength_label = ttk.Label(strength_wrapper, text="Strong", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) self.strength_label.grid(row=0, column=1, sticky=ttk.W, padx=(15, 0)) - self.strength_bar = ttk.Progressbar(strength_wrapper, bootstyle="success", maximum=100, value=80) + self.strength_bar = RoundedProgressbar(strength_wrapper, bootstyle="success", maximum=100, value=80, radius=5) self.strength_bar.grid(row=0, column=0, sticky=ttk.EW) # password length From a01c433896f002ed8ff4100f9c3af053092981fc Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:08:27 +0100 Subject: [PATCH 15/21] Remove create now window flag from mac os notifier --- utils/notifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/notifier.py b/utils/notifier.py index 2dd2ae7..f417a41 100644 --- a/utils/notifier.py +++ b/utils/notifier.py @@ -16,4 +16,4 @@ def send(title: str, text :str) -> None: cmd = '''on run argv display notification (item 2 of argv) with title (item 1 of argv) end run''' - subprocess.call(['osascript', '-e', cmd, title, text], creationflags=subprocess.CREATE_NO_WINDOW) \ No newline at end of file + subprocess.call(['osascript', '-e', cmd, title, text]) \ No newline at end of file From d9b56a9fa9444ad7e64e6291eec27e5e295a0946 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:12:15 +0100 Subject: [PATCH 16/21] Add custom slider component --- gui/components/__init__.py | 1 + gui/components/rounded_slider.py | 101 +++++++++++++++++++++++++++++++ gui/pages/tools/password_gen.py | 10 ++- 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 gui/components/rounded_slider.py diff --git a/gui/components/__init__.py b/gui/components/__init__.py index 5dd5071..f3869fe 100644 --- a/gui/components/__init__.py +++ b/gui/components/__init__.py @@ -1,6 +1,7 @@ from .rounded_frame import RoundedFrame from .rounded_button import RoundedButton from .rounded_progressbar import RoundedProgressbar +from .rounded_slider import RoundedSlider from .sidebar import Sidebar from .console import Console from .settings_frame import SettingsFrame diff --git a/gui/components/rounded_slider.py b/gui/components/rounded_slider.py new file mode 100644 index 0000000..10982a0 --- /dev/null +++ b/gui/components/rounded_slider.py @@ -0,0 +1,101 @@ +import ttkbootstrap as ttk + +from gui.components.rounded_frame import RoundedFrame + + +class RoundedSlider(ttk.Canvas): + def __init__(self, parent, from_=0, to=100, value=None, command=None, parent_background=None, **kwargs): + self.parent = parent + self.root = parent.winfo_toplevel() + self.style = self.root.style + self.from_ = float(from_) + self.to = float(to) + self.value = self.from_ if value is None else float(value) + self.command = command + self.parent_background = parent_background or self._get_parent_background() + self.track_color = kwargs.pop("track_color", self.style.colors.get("secondary")) + self.fill_color = kwargs.pop("fill_color", self.style.colors.get("primary")) + self.thumb_color = kwargs.pop("thumb_color", self.fill_color) + self.track_height = kwargs.pop("track_height", 6) + self.thumb_radius = kwargs.pop("thumb_radius", 8) + + canvas_kwargs = { + key: value + for key, value in kwargs.items() + if key not in {"orient", "bootstyle", "style", "padx", "pady", "background", "foreground"} + } + canvas_kwargs.setdefault("height", self.thumb_radius * 2 + 4) + super().__init__(parent, highlightthickness=0, bd=0, **canvas_kwargs) + super().configure(background=self.parent_background) + self.bind("", self._draw) + self.bind("", self._update_from_event) + self.bind("", self._update_from_event) + + def _get_parent_background(self): + if isinstance(self.parent, RoundedFrame): + return self.parent.frame_background + try: + style_name = self.parent.cget("style") + return self.style.lookup(style_name, "background") or self.style.colors.get("bg") + except Exception: + return self.style.colors.get("bg") + + def _track_bounds(self): + padding = self.thumb_radius + return padding, self.winfo_width() - padding + + def _fraction(self): + if self.to == self.from_: + return 0 + return min(max((self.value - self.from_) / (self.to - self.from_), 0), 1) + + def _draw_rounded_rectangle(self, left, top, right, bottom, color): + radius = min((bottom - top) / 2, (right - left) / 2) + self.create_rectangle(left + radius, top, right - radius, bottom, fill=color, outline=color) + self.create_rectangle(left, top + radius, right, bottom - radius, fill=color, outline=color) + self.create_oval(left, top, left + radius * 2, bottom, fill=color, outline=color) + self.create_oval(right - radius * 2, top, right, bottom, fill=color, outline=color) + + def _draw(self, event=None): + self.delete("all") + width = self.winfo_width() + height = self.winfo_height() + if width < self.thumb_radius * 2 or height < self.track_height: + return + + left, right = self._track_bounds() + center_y = height / 2 + track_top = center_y - self.track_height / 2 + track_bottom = center_y + self.track_height / 2 + thumb_x = left + (right - left) * self._fraction() + + self._draw_rounded_rectangle(left, track_top, right, track_bottom, self.track_color) + self._draw_rounded_rectangle(left, track_top, thumb_x, track_bottom, self.fill_color) + self.create_oval( + thumb_x - self.thumb_radius, + center_y - self.thumb_radius, + thumb_x + self.thumb_radius, + center_y + self.thumb_radius, + fill=self.thumb_color, + outline=self.parent_background, + width=2, + ) + + def _update_from_event(self, event): + left, right = self._track_bounds() + fraction = min(max((event.x - left) / max(right - left, 1), 0), 1) + self.set(self.from_ + (self.to - self.from_) * fraction) + + def set(self, value): + self.value = min(max(float(value), self.from_), self.to) + self._draw() + if self.command: + self.command(str(self.value)) + + def get(self): + return self.value + + def set_parent_background(self, parent_background): + self.parent_background = parent_background + super().configure(background=parent_background) + self._draw() \ No newline at end of file diff --git a/gui/pages/tools/password_gen.py b/gui/pages/tools/password_gen.py index 31a390c..dd0699e 100644 --- a/gui/pages/tools/password_gen.py +++ b/gui/pages/tools/password_gen.py @@ -2,7 +2,7 @@ import string import random import ttkbootstrap as ttk -from gui.components import RoundedFrame, ToolPage, RoundedProgressbar +from gui.components import RoundedFrame, ToolPage, RoundedProgressbar, RoundedSlider from gui.helpers import Style class PasswordGenPage(ToolPage): @@ -133,7 +133,13 @@ def draw_options(self, parent): # password length self.length_label = ttk.Label(options_wrapper, text=f"Password Length {self.password_length}", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) self.length_label.grid(row=1, column=0, sticky=ttk.W, padx=18, pady=(5, 5)) - length_slider = ttk.Scale(options_wrapper, from_=4, to=48, orient=ttk.HORIZONTAL, command=self.update_password_length) + length_slider = RoundedSlider( + options_wrapper, + from_=4, + to=48, + command=self.update_password_length, + parent_background=self.root.style.colors.get("dark"), + ) length_slider.bind("", self.on_slider_release) length_slider.set(self.password_length) length_slider.grid(row=1, column=1, sticky=ttk.EW, padx=18, pady=(5, 5)) From 8ffe45397202111e64a324f7ea6594f060d9b792 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:22:56 +0100 Subject: [PATCH 17/21] Add custom switch component for better/cleaner style and design --- gui/components/__init__.py | 1 + gui/components/rounded_switch.py | 136 ++++++++++++++++++++ gui/components/settings/general.py | 6 +- gui/components/settings/rich_presence.py | 4 +- gui/components/settings/session_spoofing.py | 4 +- gui/components/settings/snipers.py | 7 +- gui/pages/tools/password_gen.py | 10 +- 7 files changed, 152 insertions(+), 16 deletions(-) create mode 100644 gui/components/rounded_switch.py diff --git a/gui/components/__init__.py b/gui/components/__init__.py index f3869fe..a4ef8c2 100644 --- a/gui/components/__init__.py +++ b/gui/components/__init__.py @@ -2,6 +2,7 @@ from .rounded_button import RoundedButton from .rounded_progressbar import RoundedProgressbar from .rounded_slider import RoundedSlider +from .rounded_switch import RoundedSwitch from .sidebar import Sidebar from .console import Console from .settings_frame import SettingsFrame diff --git a/gui/components/rounded_switch.py b/gui/components/rounded_switch.py new file mode 100644 index 0000000..9d8b0e1 --- /dev/null +++ b/gui/components/rounded_switch.py @@ -0,0 +1,136 @@ +import ttkbootstrap as ttk + +from gui.components.rounded_frame import RoundedFrame +from gui.helpers.style import Style + +class RoundedSwitch(ttk.Canvas): + def __init__(self, parent, variable=None, command=None, parent_background=None, **kwargs): + self.parent = parent + self.root = parent.winfo_toplevel() + self.style = self.root.style + self.variable = variable or ttk.BooleanVar(value=False) + self.command = command + self.parent_background = parent_background or self._get_parent_background() + self.on_color = kwargs.pop("on_color", self.style.colors.get("primary")) + self.off_color = kwargs.pop("off_color", Style.ENTRY_BG.value) + self.thumb_color = kwargs.pop("thumb_color", "#ffffff") + self.disabled_color = kwargs.pop("disabled_color", self.style.colors.get("dark")) + self.scale = float(kwargs.pop("scale", 1.15)) + if self.scale <= 0: + raise ValueError("scale must be greater than zero") + self._base_width = kwargs.pop("width", 28) + self._base_height = kwargs.pop("height", 16) + self._disabled = False + + canvas_kwargs = { + key: value + for key, value in kwargs.items() + if key not in {"text", "bootstyle", "style", "padx", "pady", "background", "foreground"} + } + canvas_kwargs["width"] = round(self._base_width * self.scale) + canvas_kwargs["height"] = round(self._base_height * self.scale) + super().__init__(parent, highlightthickness=0, bd=0, **canvas_kwargs) + super().configure(background=self.parent_background) + self.variable.trace_add("write", self._draw) + self.bind("", self._draw) + self.bind("", self.invoke) + + def _get_parent_background(self): + if isinstance(self.parent, RoundedFrame): + return self.parent.frame_background + try: + style_name = self.parent.cget("style") + return self.style.lookup(style_name, "background") or self.style.colors.get("bg") + except Exception: + return self.style.colors.get("bg") + + def _draw(self, *args): + self.delete("all") + width = self.winfo_width() + height = self.winfo_height() + if width < 2 or height < 2: + return + + width -= 1 + height -= 1 + + radius = height / 2 + selected = bool(self.variable.get()) + color = self.disabled_color if self._disabled else (self.on_color if selected else self.off_color) + self.create_rectangle(radius, 0, width - radius, height, fill=color, outline=color) + self.create_oval(0, 0, radius * 2, height, fill=color, outline=color) + self.create_oval(width - radius * 2, 0, width, height, fill=color, outline=color) + + thumb_radius = max(radius - (3 * self.scale), 1) + thumb_x = width - radius if selected else radius + self.create_oval( + thumb_x - thumb_radius, + radius - thumb_radius, + thumb_x + thumb_radius, + radius + thumb_radius, + fill=self.thumb_color, + outline=self.thumb_color, + ) + + def invoke(self, event=None): + if self._disabled: + return + self.variable.set(not bool(self.variable.get())) + if self.command: + self.command() + + def state(self, states=None): + if states is None: + return ("disabled",) if self._disabled else (("selected",) if self.variable.get() else ()) + + for state in states: + if state == "disabled": + self._disabled = True + elif state == "!disabled": + self._disabled = False + elif state == "selected": + self.variable.set(True) + elif state == "!selected": + self.variable.set(False) + self._draw() + + def instate(self, states): + state_set = set(states) + return ( + ("selected" not in state_set or bool(self.variable.get())) + and ("!selected" not in state_set or not bool(self.variable.get())) + and ("disabled" not in state_set or self._disabled) + and ("!disabled" not in state_set or not self._disabled) + ) + + def configure(self, cnf=None, **kwargs): + options = dict(cnf or {}) + options.update(kwargs) + if "variable" in options: + self.variable = options.pop("variable") + self.variable.trace_add("write", self._draw) + if "command" in options: + self.command = options.pop("command") + if "parent_background" in options: + self.set_parent_background(options.pop("parent_background")) + if "scale" in options: + self.set_scale(options.pop("scale")) + return super().configure(options) if options else None + + config = configure + + def set_parent_background(self, parent_background): + self.parent_background = parent_background + super().configure(background=parent_background) + self._draw() + + def set_scale(self, scale): + scale = float(scale) + if scale <= 0: + raise ValueError("scale must be greater than zero") + self.scale = scale + super().configure( + width=round(self._base_width * scale), + height=round(self._base_height * scale), + ) + self._draw() \ No newline at end of file diff --git a/gui/components/settings/general.py b/gui/components/settings/general.py index a6e7433..2645207 100644 --- a/gui/components/settings/general.py +++ b/gui/components/settings/general.py @@ -1,7 +1,7 @@ import sys import ttkbootstrap as ttk import utils.console as console -from gui.components import SettingsPanel, DropdownMenu, RoundedButton +from gui.components import SettingsPanel, DropdownMenu, RoundedButton, RoundedSwitch from gui.helpers import apply_theme, get_themes class GeneralPanel(SettingsPanel): @@ -118,7 +118,7 @@ def draw(self): edit_og_msg_label.configure(background=self.root.style.colors.get("dark")) edit_og_msg_label.grid(row=0, column=0, sticky=ttk.NW, padx=(10, 0), pady=(2, 10)) - self.edit_og_msg_entry = ttk.Checkbutton(checkboxes_frame, command=self._save_cfg, style="success-round-toggle") + self.edit_og_msg_entry = RoundedSwitch(checkboxes_frame, command=self._save_cfg, parent_background=self.root.style.colors.get("dark")) self.edit_og_msg_entry.configure(variable=ttk.BooleanVar(value=self.cfg.get("message_settings.edit_og"))) self.edit_og_msg_entry.grid(row=0, column=1, sticky=ttk.E, padx=(10, 10), pady=(2, 10)) @@ -127,7 +127,7 @@ def draw(self): telemetry_label.configure(background=self.root.style.colors.get("dark")) telemetry_label.grid(row=1, column=0, sticky=ttk.NW, padx=(10, 0), pady=(2, 10)) - self.telemetry_entry = ttk.Checkbutton(checkboxes_frame, command=self._save_cfg, style="success-round-toggle") + self.telemetry_entry = RoundedSwitch(checkboxes_frame, command=self._save_cfg, parent_background=self.root.style.colors.get("dark")) self.telemetry_entry.configure(variable=ttk.BooleanVar(value=self.cfg.get("telemetry"))) self.telemetry_entry.grid(row=1, column=1, sticky=ttk.E, padx=(10, 10), pady=(2, 10)) diff --git a/gui/components/settings/rich_presence.py b/gui/components/settings/rich_presence.py index 678a58d..8bf8281 100644 --- a/gui/components/settings/rich_presence.py +++ b/gui/components/settings/rich_presence.py @@ -2,7 +2,7 @@ import ttkbootstrap as ttk from gui.helpers.style import get_current_theme_str import utils.console as console -from gui.components import SettingsPanel, RoundedButton, RoundedFrame +from gui.components import SettingsPanel, RoundedButton, RoundedFrame, RoundedSwitch from gui.helpers import Style class RichPresencePanel(SettingsPanel): @@ -207,7 +207,7 @@ def draw(self): toggle_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=10) toggle_label.bind("", lambda e: self.toggle_checkbox.invoke()) - self.toggle_checkbox = ttk.Checkbutton(toggle_wrapper, text="", style="success-round-toggle") + self.toggle_checkbox = RoundedSwitch(toggle_wrapper, parent_background=self.root.style.colors.get("dark")) self.toggle_checkbox.grid(row=0, column=1, sticky=ttk.E, padx=(0, 10), pady=10) self.toggle_checkbox.configure(command=self._save_rpc) diff --git a/gui/components/settings/session_spoofing.py b/gui/components/settings/session_spoofing.py index 1b39401..61f305c 100644 --- a/gui/components/settings/session_spoofing.py +++ b/gui/components/settings/session_spoofing.py @@ -1,7 +1,7 @@ import sys import ttkbootstrap as ttk import utils.console as console -from gui.components import SettingsPanel, RoundedFrame, DropdownMenu +from gui.components import SettingsPanel, RoundedFrame, DropdownMenu, RoundedSwitch class SessionSpoofingPanel(SettingsPanel): def __init__(self, root, parent, images, config, width=None): @@ -36,7 +36,7 @@ def draw(self): toggle_label.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=(15, 5)) toggle_label.bind("", lambda e: self.checkbox.invoke()) - self.checkbox = ttk.Checkbutton(self.body, text="", style="success-round-toggle") + self.checkbox = RoundedSwitch(self.body, parent_background=self.root.style.colors.get("dark")) # self.checkbox.grid(row=0, column=0, columnspan=2, sticky=ttk.W, padx=(13, 0), pady=(15, 0)) self.checkbox.grid(row=0, column=1, sticky=ttk.E, padx=(0, 10), pady=(10, 5)) self.checkbox.configure(command=self._save_session_spoofing) diff --git a/gui/components/settings/snipers.py b/gui/components/settings/snipers.py index 8531ee6..0f6d9a8 100644 --- a/gui/components/settings/snipers.py +++ b/gui/components/settings/snipers.py @@ -1,7 +1,7 @@ import sys import ttkbootstrap as ttk import utils.console as console -from gui.components import SettingsPanel, RoundedFrame +from gui.components import SettingsPanel, RoundedFrame, RoundedSwitch class SnipersPanel(SettingsPanel): def __init__(self, root, parent, images, config, width=None): @@ -69,12 +69,11 @@ def _draw_card(self, sniper): var = ttk.BooleanVar(value=entry["value"]) - checkbox = ttk.Checkbutton( + checkbox = RoundedSwitch( checkbox_wrapper, - style="success-round-toggle", variable=var, command=lambda sniper_name=sniper.name: self._save_sniper(sniper_name), - tristatevalue=None + parent_background=self.root.style.colors.get("secondary"), ) checkbox.grid(row=0, column=1, sticky=ttk.E, pady=10, padx=(0, 10)) checkbox_wrapper.grid_columnconfigure(0, weight=1) diff --git a/gui/pages/tools/password_gen.py b/gui/pages/tools/password_gen.py index dd0699e..467b493 100644 --- a/gui/pages/tools/password_gen.py +++ b/gui/pages/tools/password_gen.py @@ -2,7 +2,7 @@ import string import random import ttkbootstrap as ttk -from gui.components import RoundedFrame, ToolPage, RoundedProgressbar, RoundedSlider +from gui.components import RoundedFrame, ToolPage, RoundedProgressbar, RoundedSlider, RoundedSwitch from gui.helpers import Style class PasswordGenPage(ToolPage): @@ -149,28 +149,28 @@ def draw_options(self, parent): self.uppercase_var.trace_add("write", self.on_option_change) uppercase_label = ttk.Label(options_wrapper, text="Include Uppercase Letters", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) uppercase_label.grid(row=2, column=0, sticky=ttk.W, padx=(18, 0), pady=(5, 5)) - uppercase_check = ttk.Checkbutton(options_wrapper, variable=self.uppercase_var, bootstyle="success-round-toggle", command=self.on_option_change) + uppercase_check = RoundedSwitch(options_wrapper, variable=self.uppercase_var, command=self.on_option_change, parent_background=self.root.style.colors.get("dark")) uppercase_check.grid(row=2, column=1, sticky=ttk.E, padx=18, pady=(5, 5)) self.lowercase_var = ttk.BooleanVar(value=self.include_lowercase) self.lowercase_var.trace_add("write", self.on_option_change) lowercase_label = ttk.Label(options_wrapper, text="Include Lowercase Letters", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) lowercase_label.grid(row=3, column=0, sticky=ttk.W, padx=(18, 0), pady=(5, 5)) - lowercase_check = ttk.Checkbutton(options_wrapper, variable=self.lowercase_var, bootstyle="success-round-toggle", command=self.on_option_change) + lowercase_check = RoundedSwitch(options_wrapper, variable=self.lowercase_var, command=self.on_option_change, parent_background=self.root.style.colors.get("dark")) lowercase_check.grid(row=3, column=1, sticky=ttk.E, padx=18, pady=(5, 5)) self.numbers_var = ttk.BooleanVar(value=self.include_numbers) self.numbers_var.trace_add("write", self.on_option_change) numbers_label = ttk.Label(options_wrapper, text="Include Numbers", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) numbers_label.grid(row=4, column=0, sticky=ttk.W, padx=(18, 0), pady=(5, 5)) - numbers_check = ttk.Checkbutton(options_wrapper, variable=self.numbers_var, bootstyle="success-round-toggle", command=self.on_option_change) + numbers_check = RoundedSwitch(options_wrapper, variable=self.numbers_var, command=self.on_option_change, parent_background=self.root.style.colors.get("dark")) numbers_check.grid(row=4, column=1, sticky=ttk.E, padx=18, pady=(5, 5)) self.symbols_var = ttk.BooleanVar(value=self.include_symbols) self.symbols_var.trace_add("write", self.on_option_change) symbols_label = ttk.Label(options_wrapper, text="Include Symbols", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) symbols_label.grid(row=5, column=0, sticky=ttk.W, padx=(18, 0), pady=(5, 15)) - symbols_check = ttk.Checkbutton(options_wrapper, variable=self.symbols_var, bootstyle="success-round-toggle", command=self.on_option_change) + symbols_check = RoundedSwitch(options_wrapper, variable=self.symbols_var, command=self.on_option_change, parent_background=self.root.style.colors.get("dark")) symbols_check.grid(row=5, column=1, sticky=ttk.E, padx=18, pady=(5, 15)) # generate_button = RoundedButton(options_wrapper, text="Generate", bootstyle="success.TButton", command=self.generate_password) From 6887ace89f847100809fce163c8eeaf989f84b7f Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:36:14 +0100 Subject: [PATCH 18/21] Fix cut off in the corners due to height and width being a pixel to big --- gui/components/rounded_progressbar.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gui/components/rounded_progressbar.py b/gui/components/rounded_progressbar.py index f5b307e..3ee83ed 100644 --- a/gui/components/rounded_progressbar.py +++ b/gui/components/rounded_progressbar.py @@ -4,7 +4,7 @@ class RoundedProgressbar(ttk.Canvas): - def __init__(self, parent, radius=8, **kwargs): + def __init__(self, parent, radius=4, **kwargs): self.parent = parent self.root = parent.winfo_toplevel() self.style = self.root.style @@ -64,6 +64,9 @@ def _draw(self, event=None): height = self.winfo_height() if width < 2 or height < 2: return + + width -= 1 + height -= 1 self._rounded_rectangle(0, 0, width, height, self.track_color) if self.mode == "indeterminate": From 640a1e6c92477d889e79c374c1dec1290235ecc1 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:36:31 +0100 Subject: [PATCH 19/21] Change radius to use the smaller default progressbar radius --- gui/pages/tools/password_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/pages/tools/password_gen.py b/gui/pages/tools/password_gen.py index 467b493..73abf67 100644 --- a/gui/pages/tools/password_gen.py +++ b/gui/pages/tools/password_gen.py @@ -127,7 +127,7 @@ def draw_options(self, parent): self.strength_label = ttk.Label(strength_wrapper, text="Strong", font=("Host Grotesk", 12), background=self.root.style.colors.get("dark")) self.strength_label.grid(row=0, column=1, sticky=ttk.W, padx=(15, 0)) - self.strength_bar = RoundedProgressbar(strength_wrapper, bootstyle="success", maximum=100, value=80, radius=5) + self.strength_bar = RoundedProgressbar(strength_wrapper, bootstyle="success", maximum=100, value=80) self.strength_bar.grid(row=0, column=0, sticky=ttk.EW) # password length From a6dda11b924a116a8c4e78bd6e8d5fb48ffd2cf3 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:02:37 +0100 Subject: [PATCH 20/21] Added custom rounded progressbar component --- gui/components/rounded_progressbar.py | 125 ++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/gui/components/rounded_progressbar.py b/gui/components/rounded_progressbar.py index 3ee83ed..6322b4d 100644 --- a/gui/components/rounded_progressbar.py +++ b/gui/components/rounded_progressbar.py @@ -103,6 +103,131 @@ def configure(self, cnf=None, **kwargs): config = configure + def cget(self, key): + if key in {"maximum", "value", "mode", "bootstyle", "track_color", "bar_color"}: + return getattr(self, key) + return super().cget(key) + + def step(self, amount=1): + self.configure(value=self.value + amount) + + def start(self, interval=50): + self.stop() + self.mode = "indeterminate" + self._animation_interval = max(int(interval), 1) + self._animate() + + def _animate(self): + self._animation_value = (self._animation_value + 2) % 200 + self._draw() + self._animation_id = self.after(self._animation_interval, self._animate) + + def stop(self): + if self._animation_id is not None: + self.after_cancel(self._animation_id) + self._animation_id = None +import ttkbootstrap as ttk + +from gui.components.rounded_frame import RoundedFrame + + +class RoundedProgressbar(ttk.Canvas): + def __init__(self, parent, radius=8, **kwargs): + self.parent = parent + self.root = parent.winfo_toplevel() + self.style = self.root.style + self.maximum = float(kwargs.pop("maximum", 100)) + self.value = float(kwargs.pop("value", 0)) + self.mode = kwargs.pop("mode", "determinate") + self.bootstyle = kwargs.pop("bootstyle", kwargs.pop("style", "primary")) + self.track_color = kwargs.pop("track_color", self.style.colors.get("secondary")) + self.bar_color = kwargs.pop("bar_color", self._get_bootstyle_color()) + self.radius = radius + self._animation_id = None + self._animation_value = 0 + + canvas_kwargs = { + key: value + for key, value in kwargs.items() + if key not in {"padx", "pady", "background", "foreground"} + } + canvas_kwargs.setdefault("height", 16) + super().__init__(parent, highlightthickness=0, bd=0, **canvas_kwargs) + super().configure(background=kwargs.get("background", self._get_parent_background())) + self.bind("", self._draw) + + def _get_bootstyle_color(self): + style_name = self.bootstyle.split(".")[0].lower() + return self.style.colors.get(style_name) or self.style.colors.get("primary") + + def _get_parent_background(self): + if isinstance(self.parent, RoundedFrame): + return self.parent.frame_background + try: + style_name = self.parent.cget("style") + return self.style.lookup(style_name, "background") or self.style.colors.get("bg") + except Exception: + return self.style.colors.get("bg") + + def _rounded_rectangle(self, left, top, right, bottom, color): + if right <= left or bottom <= top: + return + + radius = self.radius + radius = min(radius, (right - left) / 2, (bottom - top) / 2) + if radius == 0: + self.create_rectangle(left, top, right, bottom, fill=color, outline=color) + return + + self.create_rectangle(left + radius, top, right - radius, bottom, fill=color, outline=color) + self.create_rectangle(left, top + radius, right, bottom - radius, fill=color, outline=color) + self.create_arc(left, top, left + radius * 2, top + radius * 2, start=90, extent=90, fill=color, outline=color) + self.create_arc(right - radius * 2, top, right, top + radius * 2, start=0, extent=90, fill=color, outline=color) + self.create_arc(right - radius * 2, bottom - radius * 2, right, bottom, start=270, extent=90, fill=color, outline=color) + self.create_arc(left, bottom - radius * 2, left + radius * 2, bottom, start=180, extent=90, fill=color, outline=color) + + def _draw(self, event=None): + self.delete("all") + width = self.winfo_width() + height = self.winfo_height() + if width < 2 or height < 2: + return + + self._rounded_rectangle(0, 0, width, height, self.track_color) + if self.mode == "indeterminate": + segment_width = max(height * 2, width / 4) + left = (width + segment_width) * self._animation_value / 100 - segment_width + self._rounded_rectangle(left, 0, left + segment_width, height, self.bar_color) + return + + progress = min(max(self.value / self.maximum, 0), 1) if self.maximum else 0 + self._rounded_rectangle(0, 0, width * progress, height, self.bar_color) + + def configure(self, cnf=None, **kwargs): + options = dict(cnf or {}) + options.update(kwargs) + redraw = False + + for option in ("maximum", "value", "mode", "bootstyle", "track_color", "bar_color"): + if option not in options: + continue + value = options.pop(option) + if option in {"maximum", "value"}: + value = float(value) + if option == "bootstyle": + self.bootstyle = value + self.bar_color = self._get_bootstyle_color() + else: + setattr(self, option, value) + redraw = True + + result = super().configure(options) if options else None + if redraw: + self._draw() + return result + + config = configure + def cget(self, key): if key in {"maximum", "value", "mode", "bootstyle", "track_color", "bar_color"}: return getattr(self, key) From dc1c72e4cf24f033dc5e1d6104ec9f69b52b8b62 Mon Sep 17 00:00:00 2001 From: Benny <83777519+bennyscripts@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:50:04 +0100 Subject: [PATCH 21/21] Change install btn text --- gui/pages/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/pages/update.py b/gui/pages/update.py index 83b6e11..0eeae64 100644 --- a/gui/pages/update.py +++ b/gui/pages/update.py @@ -207,7 +207,7 @@ def draw(self, wrapper): ) full_changelog_button.pack(side=ttk.LEFT) - self.install_button = RoundedButton(button_row, text="Install Update", command=lambda e: self._install_update(update_info) if update_info else None, bootstyle="primary.TButton") + self.install_button = RoundedButton(button_row, text="Install & Restart", command=lambda e: self._install_update(update_info) if update_info else None, bootstyle="primary.TButton") self.install_button.pack(side=ttk.RIGHT) self.skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt(), bootstyle="secondary.TButton")