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")