From 657db4150ef59c9c8060c7f8436b91639f823bbb Mon Sep 17 00:00:00 2001 From: ygvq Date: Wed, 2 Sep 2026 08:58:37 -0700 Subject: [PATCH 1/8] Update README with planned features Added a section for planned features and improvements. --- readme.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index f49aaea..0a7cb2b 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,17 @@ The most advanced open source Discord Selfbot. Ghost has 150+ commands and a completely custom built GUI for endless customisation! +# things im planning to fix/add +- Add a toggle for starting on windows boot +- Adding ability to search commands +- Adding notifications when a guild is left or friends unadd you etc. +- add abilty to edit the homescreen to add sniper to it +- webhook message sender MAYBE +- make the sniping log look better +- create a backups page +- multi-account support +- a built in text editor for custom scripts + > [!WARNING] > Using Ghost is against Discord's TOS and can result in account termination! To avoid termination, do not use any commands in bigger servers especially ones moderated by Discord staff and do not use any of the abuse commands. I do not take any responsibility for the actions that may be caused by using Ghost. Please use with caution! @@ -48,4 +59,4 @@ Follow [this guide](https://gist.github.com/bennyscripts/49ecc1eade1796ee1d7cad9 > Please note that Ghost is developed on Mac OS which means the Windows build's may have issues. However, Ghost will work on Windows, Linux and Mac OS without issues when run from the source code! Please follow the steps above or this [setup guide](https://github.com/orgs/ghostselfbot/discussions/5) to insure proper installation! > > **Anonymous usage statistics** -> Ghost optionally sends a small anonymous ping to help estimate active installations and version adoption. No Discord tokens, user IDs, guilds, messages or personal data are transmitted. This can be disabled in the settings page of the GUI or in the config file. \ No newline at end of file +> Ghost optionally sends a small anonymous ping to help estimate active installations and version adoption. No Discord tokens, user IDs, guilds, messages or personal data are transmitted. This can be disabled in the settings page of the GUI or in the config file. From 2827a0928462ee9f86e7928626f7a863074d84cc Mon Sep 17 00:00:00 2001 From: ygvq Date: Wed, 2 Sep 2026 08:59:14 -0700 Subject: [PATCH 2/8] Fix formatting and update planned features list --- readme.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index 0a7cb2b..b7791c6 100644 --- a/readme.md +++ b/readme.md @@ -2,16 +2,16 @@ The most advanced open source Discord Selfbot. Ghost has 150+ commands and a completely custom built GUI for endless customisation! -# things im planning to fix/add +# Things im planning to fix/add - Add a toggle for starting on windows boot - Adding ability to search commands - Adding notifications when a guild is left or friends unadd you etc. -- add abilty to edit the homescreen to add sniper to it -- webhook message sender MAYBE -- make the sniping log look better -- create a backups page -- multi-account support -- a built in text editor for custom scripts +- Add abilty to edit the homescreen to add sniper to it +- Webhook message sender MAYBE +- Make the sniping log look better +- Create a backups page +- Multi-account support +- A built in text editor for custom scripts > [!WARNING] > Using Ghost is against Discord's TOS and can result in account termination! To avoid termination, do not use any commands in bigger servers especially ones moderated by Discord staff and do not use any of the abuse commands. I do not take any responsibility for the actions that may be caused by using Ghost. Please use with caution! From b30388c2a4b4c329cf4d7f8f4b769614859542b7 Mon Sep 17 00:00:00 2001 From: ygvq Date: Wed, 2 Sep 2026 09:55:09 -0700 Subject: [PATCH 3/8] Revise planned features in readme.md Updated the features list with completed, scrapped, and in-progress items. --- readme.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index b7791c6..51fb540 100644 --- a/readme.md +++ b/readme.md @@ -3,15 +3,15 @@ The most advanced open source Discord Selfbot. Ghost has 150+ commands and a completely custom built GUI for endless customisation! # Things im planning to fix/add -- Add a toggle for starting on windows boot -- Adding ability to search commands -- Adding notifications when a guild is left or friends unadd you etc. -- Add abilty to edit the homescreen to add sniper to it -- Webhook message sender MAYBE -- Make the sniping log look better -- Create a backups page +- Add a toggle for starting on windows boot[x] +- Adding ability to search commands(SCRAPPED) +- Adding notifications when a guild is left or friends unadd you etc.[x] +- Add abilty to edit the homescreen to add sniper to it[x] +- Webhook message sender MAYBE(SCRAPPED) +- Make the sniping log look better(~) +- Create a backups page[x] - Multi-account support -- A built in text editor for custom scripts +- A built in text editor for custom scripts[x] > [!WARNING] > Using Ghost is against Discord's TOS and can result in account termination! To avoid termination, do not use any commands in bigger servers especially ones moderated by Discord staff and do not use any of the abuse commands. I do not take any responsibility for the actions that may be caused by using Ghost. Please use with caution! From 5d0a927281cfd2da75833b33367686145100f99e Mon Sep 17 00:00:00 2001 From: ygvq Date: Wed, 2 Sep 2026 09:55:46 -0700 Subject: [PATCH 4/8] Update readme.md --- readme.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/readme.md b/readme.md index 51fb540..145bac3 100644 --- a/readme.md +++ b/readme.md @@ -3,15 +3,15 @@ The most advanced open source Discord Selfbot. Ghost has 150+ commands and a completely custom built GUI for endless customisation! # Things im planning to fix/add -- Add a toggle for starting on windows boot[x] +- Add a toggle for starting on windows boot✅ - Adding ability to search commands(SCRAPPED) -- Adding notifications when a guild is left or friends unadd you etc.[x] -- Add abilty to edit the homescreen to add sniper to it[x] +- Adding notifications when a guild is left or friends unadd you etc.✅ +- Add abilty to edit the homescreen to add sniper to it✅ - Webhook message sender MAYBE(SCRAPPED) - Make the sniping log look better(~) -- Create a backups page[x] +- Create a backups page✅ - Multi-account support -- A built in text editor for custom scripts[x] +- A built in text editor for custom scripts✅ > [!WARNING] > Using Ghost is against Discord's TOS and can result in account termination! To avoid termination, do not use any commands in bigger servers especially ones moderated by Discord staff and do not use any of the abuse commands. I do not take any responsibility for the actions that may be caused by using Ghost. Please use with caution! From 31581b58413b753e7d08205a53240b30908de7c9 Mon Sep 17 00:00:00 2001 From: ItzRealMee Date: Wed, 2 Sep 2026 11:26:16 -0700 Subject: [PATCH 5/8] Refactor and update almost every file in the project --- gui/components/console.py | 80 ++++-- gui/components/notification_feed.py | 171 +++++++++++++ gui/components/settings/general.py | 18 ++ gui/components/titlebar.py | 27 ++- gui/pages/home.py | 126 ++++++++-- gui/pages/script.py | 228 ++++++++++++++--- gui/pages/scripts.py | 39 +-- gui/pages/settings.py | 11 +- gui/pages/tools/__init__.py | 4 +- gui/pages/tools/backups_page.py | 268 ++++++++++++++++++++ gui/pages/tools/multi_token_page.py | 351 +++++++++++++++++++++++++++ gui/pages/tools/surveillance_page.py | 8 +- gui/pages/tools/tools.py | 36 ++- readme.md | 13 +- utils/defaults.py | 2 + utils/files.py | 113 ++++++++- 16 files changed, 1361 insertions(+), 134 deletions(-) create mode 100644 gui/components/notification_feed.py create mode 100644 gui/pages/tools/backups_page.py create mode 100644 gui/pages/tools/multi_token_page.py diff --git a/gui/components/console.py b/gui/components/console.py index 27d18ff..1e9b667 100644 --- a/gui/components/console.py +++ b/gui/components/console.py @@ -6,6 +6,8 @@ from utils.console import get_formatted_time class Console: + MAX_LOG_ENTRIES = 500 + def __init__(self, root, bot_controller): self.root = root self.bot = bot_controller @@ -18,25 +20,34 @@ def __init__(self, root, bot_controller): self.non_darwin_font_size = 12 def update(self): + if self.textarea is None: + return try: self.textarea.delete("1.0", "end") - for time, prefix, text in self.console: + visible = self.console[-self.MAX_LOG_ENTRIES:] if len(self.console) > self.MAX_LOG_ENTRIES else self.console + + for time, prefix, text in visible: if prefix.lower() == "sniper": content = text - type = content.get("type") + type_name = content.get("type") title = content.get("title") description = content.get("description") + success = content.get("success", True) + + self.textarea.insert("end", "\n", "log_text") + self.textarea.insert("end", f" {time} ", "timestamp") + + if success: + self.textarea.insert("end", f" {type_name.upper()} ", "prefix_sniper_success") + else: + self.textarea.insert("end", f" {type_name.upper()} ", "prefix_sniper_fail") - self.textarea.insert("end", f"\n[{time}] ", "timestamp") - self.textarea.insert("end", f"[{type.upper()}] ", f"prefix_{prefix.lower()}") self.textarea.insert("end", f"{title}\n", "log_text") for key, value in description.items(): - self.textarea.insert("end", f"{' ' * len(f'[{time}] ')}{key}: ", "sniper_key") + self.textarea.insert("end", f" {key}: ", "sniper_key") self.textarea.insert("end", f"{value}\n", "log_text") - - self.textarea.insert("end", "\n", "log_text") else: self.textarea.insert("end", f"[{time}] ", "timestamp") self.textarea.insert("end", f"[{prefix}] ", f"prefix_{prefix.lower()}") @@ -44,17 +55,37 @@ def update(self): self.textarea.yview_moveto(1) except: - print("Console tried to update without being drawn.") + pass def clear(self): self.console = [] try: self.textarea.delete("1.0", "end") except: - print("Console tried to clear without being drawn.") + pass def add_sniper(self, sniper_obj): self.add_log("sniper", sniper_obj) + self._notify_home_feed(sniper_obj) + + def _notify_home_feed(self, sniper_obj): + try: + from gui.pages.home import HomePage + gui = self.bot.gui if hasattr(self.bot, 'gui') else None + if gui and hasattr(gui, 'home_page'): + event_type = sniper_obj.get("type", "sniper") + title = sniper_obj.get("title", "Sniper Event") + desc_parts = sniper_obj.get("description", {}) + description = "\n".join(f"{k}: {v}" for k, v in desc_parts.items()) + success = "success" not in title.lower() or "failed" not in title.lower() + gui.home_page._add_notification( + f"{event_type.lower()}_snipe", + title, + description, + color="#4fee4c" if success else "#ff6464" + ) + except Exception: + pass def add_log(self, prefix, text): time = get_formatted_time() @@ -62,20 +93,25 @@ def add_log(self, prefix, text): self.update() def _load_tags(self): - self.textarea.tag_config("timestamp", foreground=Style.DARK_GREY.value, font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("log_text", foreground=Style.LIGHT_GREY.value, font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) + font_size = self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size + mono_font = ("JetBrainsMono NF", font_size, "bold") + + self.textarea.tag_config("timestamp", foreground=Style.DARK_GREY.value, font=mono_font) + self.textarea.tag_config("log_text", foreground=Style.LIGHT_GREY.value, font=mono_font) - self.textarea.tag_config("prefix_sniper", foreground="red", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("sniper_key", foreground="#eceb18", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_command", foreground="#0b91ff", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_info", foreground="#2aefef", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_success", foreground="#4fee4c", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_warning", foreground="#eceb18", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_error", foreground="red", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_cli", foreground="pink", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_rpc", foreground="pink", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_captcha", foreground="#eceb18", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) - self.textarea.tag_config("prefix_nitro", foreground="#0b91ff", font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold")) + self.textarea.tag_config("prefix_sniper", foreground="red", font=mono_font) + self.textarea.tag_config("prefix_sniper_success", foreground="#4fee4c", font=mono_font) + self.textarea.tag_config("prefix_sniper_fail", foreground="#ff6464", font=mono_font) + self.textarea.tag_config("sniper_key", foreground="#eceb18", font=mono_font) + self.textarea.tag_config("prefix_command", foreground="#0b91ff", font=mono_font) + self.textarea.tag_config("prefix_info", foreground="#2aefef", font=mono_font) + self.textarea.tag_config("prefix_success", foreground="#4fee4c", font=mono_font) + self.textarea.tag_config("prefix_warning", foreground="#eceb18", font=mono_font) + self.textarea.tag_config("prefix_error", foreground="red", font=mono_font) + self.textarea.tag_config("prefix_cli", foreground="pink", font=mono_font) + self.textarea.tag_config("prefix_rpc", foreground="pink", font=mono_font) + self.textarea.tag_config("prefix_captcha", foreground="#eceb18", font=mono_font) + self.textarea.tag_config("prefix_nitro", foreground="#0b91ff", font=mono_font) def _draw_footer(self, parent): """ Draw the footer with user info and clear button. """ diff --git a/gui/components/notification_feed.py b/gui/components/notification_feed.py new file mode 100644 index 0000000..b7378d6 --- /dev/null +++ b/gui/components/notification_feed.py @@ -0,0 +1,171 @@ +import sys +import time +import ttkbootstrap as ttk +import tkinter.font as tkFont + +from gui.helpers.style import Style + + +class NotificationEntry: + MAX_ENTRIES = 100 + + def __init__(self, parent, root, event_type, title, description, timestamp=None, color=None): + self.parent = parent + self.root = root + self.event_type = event_type + self.title = title + self.description = description + self.timestamp = timestamp or time.strftime("%H:%M:%S") + self.color = color or self._get_color_for_type(event_type) + self.frame = None + self._draw() + + def _get_color_for_type(self, event_type): + colors = { + "friend_add": "#4fee4c", + "friend_remove": "#ff6464", + "relationship_add": "#4fee4c", + "relationship_remove": "#ff6464", + "relationship_update": "#eceb18", + "guild_remove": "#ff6464", + "guild_join": "#4fee4c", + "nitro_snipe": "#0b91ff", + "nitro_snipe_success": "#4fee4c", + "nitro_snipe_fail": "#ff6464", + "privnote_snipe": "#a07cff", + "privnote_snipe_success": "#4fee4c", + "privnote_snipe_fail": "#ff6464", + "info": "#2aefef", + "warning": "#eceb18", + "error": "#ff6464", + "success": "#4fee4c", + } + return colors.get(event_type, Style.LIGHT_GREY.value) + + def _draw(self): + self.frame = ttk.Frame(self.parent, style="dark.TFrame") + self.frame.pack(fill=ttk.X, padx=(0, 5), pady=(0, 4)) + + inner = ttk.Frame(self.frame, style="dark.TFrame") + inner.pack(fill=ttk.BOTH, padx=10, pady=6) + + top_row = ttk.Frame(inner, style="dark.TFrame") + top_row.pack(fill=ttk.X) + + indicator = ttk.Canvas(top_row, width=4, height=4, + highlightthickness=0, + background=self.root.style.colors.get("dark")) + indicator.pack(side=ttk.LEFT, padx=(0, 6), pady=(5, 0)) + indicator.create_oval(0, 0, 4, 4, fill=self.color, outline=self.color) + + type_label = ttk.Label(top_row, text=self.event_type.replace("_", " ").title(), + font=("Host Grotesk", 9, "bold")) + type_label.configure(background=self.root.style.colors.get("dark"), + foreground=self.color) + type_label.pack(side=ttk.LEFT) + + time_label = ttk.Label(top_row, text=self.timestamp, + font=("Host Grotesk", 8)) + time_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.DARK_GREY.value) + time_label.pack(side=ttk.RIGHT) + + title_label = ttk.Label(inner, text=self.title, + font=("Host Grotesk", 11, "bold"), wraplength=350) + title_label.configure(background=self.root.style.colors.get("dark")) + title_label.pack(anchor=ttk.W, pady=(3, 0)) + + if self.description: + desc_label = ttk.Label(inner, text=self.description, + font=("Host Grotesk", 10), wraplength=350) + desc_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value) + desc_label.pack(anchor=ttk.W, pady=(1, 0)) + + +class NotificationFeed: + MAX_NOTIFICATIONS = 80 + + def __init__(self, root, max_visible=50): + self.root = root + self.canvas = None + self.scroll_frame = None + self.wrapper = None + self.notifications = [] + self.max_visible = max_visible + + def _on_mousewheel(self, event): + if self.canvas is None: + return + if sys.platform == 'darwin': + self.canvas.yview_scroll(-1 * int(event.delta), "units") + else: + self.canvas.yview_scroll(-1 * int(event.delta / 120), "units") + + def draw(self, parent): + self.wrapper = ttk.Frame(parent, style="dark.TFrame") + + self.canvas = ttk.Canvas(self.wrapper, + background=self.root.style.colors.get("dark"), + highlightthickness=0) + scrollbar = ttk.Scrollbar(self.wrapper, orient="vertical", command=self.canvas.yview) + self.scroll_frame = ttk.Frame(self.canvas, style="dark.TFrame") + + self.scroll_frame.bind( + "", + lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")) + ) + self.canvas_window = self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw") + + def resize_canvas(event): + self.canvas.itemconfig(self.canvas_window, width=event.width) + self.canvas.bind("", resize_canvas) + + self.canvas.configure(yscrollcommand=scrollbar.set) + self.canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + self.canvas.bind("", + lambda e: self.canvas.bind_all("", self._on_mousewheel)) + self.canvas.bind("", + lambda e: self.canvas.unbind_all("")) + + return self.wrapper + + def add_notification(self, event_type, title, description="", timestamp=None, color=None): + if self.scroll_frame is None: + return + try: + if not self.scroll_frame.winfo_exists(): + return + except Exception: + return + + entry = NotificationEntry( + self.scroll_frame, self.root, + event_type, title, description, + timestamp=timestamp, color=color + ) + self.notifications.append(entry) + + if len(self.notifications) > self.MAX_NOTIFICATIONS: + old = self.notifications.pop(0) + if old.frame: + old.frame.destroy() + + self.root.after(50, self._scroll_to_bottom) + + def clear(self): + if self.scroll_frame is None: + return + for notif in self.notifications: + if notif.frame: + notif.frame.destroy() + self.notifications.clear() + + def _scroll_to_bottom(self): + try: + if self.canvas and self.canvas.winfo_exists(): + self.canvas.yview_moveto(1.0) + except Exception: + pass diff --git a/gui/components/settings/general.py b/gui/components/settings/general.py index 2645207..55abd99 100644 --- a/gui/components/settings/general.py +++ b/gui/components/settings/general.py @@ -3,6 +3,7 @@ import utils.console as console from gui.components import SettingsPanel, DropdownMenu, RoundedButton, RoundedSwitch from gui.helpers import apply_theme, get_themes +from utils.files import set_launch_on_startup class GeneralPanel(SettingsPanel): def __init__(self, root, parent, bot_controller, images, config, draw_settings, width=None): @@ -49,6 +50,15 @@ def _save_cfg(self): self.cfg.set("telemetry", self.telemetry_entry.instate(["selected"]), save=False) except Exception as e: console.error(f"Failed to set telemetry setting: {e}") + + try: + startup_enabled = self.startup_entry.instate(["selected"]) + self.cfg.set("launch_on_startup", startup_enabled, save=False) + success, err = set_launch_on_startup(startup_enabled) + if not success: + console.warning(f"Could not update startup setting: {err}") + except Exception as e: + console.error(f"Failed to set startup setting: {e}") self.cfg.save(notify=False) @@ -131,6 +141,14 @@ def draw(self): 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)) + startup_label = ttk.Label(checkboxes_frame, text="Launch Ghost on startup") + startup_label.configure(background=self.root.style.colors.get("dark")) + startup_label.grid(row=2, column=0, sticky=ttk.NW, padx=(10, 0), pady=(2, 10)) + + self.startup_entry = RoundedSwitch(checkboxes_frame, command=self._save_cfg, parent_background=self.root.style.colors.get("dark")) + self.startup_entry.configure(variable=ttk.BooleanVar(value=self.cfg.get("launch_on_startup"))) + self.startup_entry.grid(row=2, column=1, sticky=ttk.E, padx=(10, 10), pady=(2, 10)) + return self.wrapper def save(self): diff --git a/gui/components/titlebar.py b/gui/components/titlebar.py index 5b911d4..2d7f20b 100644 --- a/gui/components/titlebar.py +++ b/gui/components/titlebar.py @@ -15,6 +15,7 @@ def __init__(self, root, images): self._offset_x = 0 self._offset_y = 0 self._dragging = False + self._move_in_progress = False def _on_press(self, event): self._dragging = True @@ -22,15 +23,24 @@ def _on_press(self, event): self._offset_y = event.y_root - self.root.winfo_y() def _on_motion(self, event): - if not self._dragging: - return + if not self._dragging or self._move_in_progress: + return "break" - if sys.platform == "darwin": - x = event.x_root - self._offset_x - y = event.y_root - self._offset_y - self.root.geometry(f"+{x}+{y}") - elif sys.platform == "win32": - hPyT.window_frame.move(self.root, event.x_root - self._offset_x, event.y_root - self._offset_y) + target_x = event.x_root - self._offset_x + target_y = event.y_root - self._offset_y + + self.root.after(1, lambda x=target_x, y=target_y: self._do_move(x, y)) + return "break" + + def _do_move(self, x, y): + self._move_in_progress = True + try: + if sys.platform == "darwin": + self.root.geometry(f"+{x}+{y}") + elif sys.platform == "win32": + hPyT.window_frame.move(self.root, x, y) + finally: + self._move_in_progress = False def _on_release(self, event): self._dragging = False @@ -91,6 +101,7 @@ def draw(self): ) inner_wrapper = RoundedFrame(titlebar, background=Style.WINDOW_BORDER.value, radius=0) + padx = 8 pady = 8 diff --git a/gui/pages/home.py b/gui/pages/home.py index 6a9be22..f60762e 100644 --- a/gui/pages/home.py +++ b/gui/pages/home.py @@ -3,6 +3,7 @@ import tkinter.font as tkFont from ttkbootstrap.scrolled import ScrolledFrame from gui.components import RoundedFrame, RoundedButton +from gui.components.notification_feed import NotificationFeed from gui.helpers import Images from gui.helpers.style import Style, get_current_theme_str from utils.config import VERSION, CHANGELOG, MOTD, Config @@ -20,7 +21,7 @@ def __init__(self, root, bot_controller, _restart_bot, console): self.avatars = {} self.images = Images() self.cfg = Config() - + self.details_wrapper = None self.friends_label = None self.guilds_label = None @@ -30,6 +31,9 @@ def __init__(self, root, bot_controller, _restart_bot, console): self.restart_title = None self.restart_title_elipsis = "..." self.restart_title_text = "Ghost is restarting" + + self.notification_feed = NotificationFeed(root) + self._notification_feed_widget = None self.root.bind("", self._update_wraplength) @@ -38,31 +42,34 @@ def _clear_everything(self): widget.destroy() def _update_restart_title(self): - if self.restart: - if len(self.restart_title_elipsis) == 3: - self.restart_title_elipsis = "." - else: - self.restart_title_elipsis += "." - self.restart_title.config(text=f"{self.restart_title_text}{self.restart_title_elipsis}") - self.root.after(750, self._update_restart_title) + try: + if self.restart: + if len(self.restart_title_elipsis) == 3: + self.restart_title_elipsis = "." + else: + self.restart_title_elipsis += "." + self.restart_title.config(text=f"{self.restart_title_text}{self.restart_title_elipsis}") + self.restart_title.after(750, self._update_restart_title) + except Exception: + pass def _update_account_details(self): try: if not self.restart: self.friends_label.config(text=f"Friends: {len(self.bot_controller.get_friends())}") self.guilds_label.config(text=f"Guilds: {len(self.bot_controller.get_guilds())}") - except: + self.friends_label.after(1000, self._update_account_details) + except Exception: pass - self.root.after(1000, self._update_account_details) - + def _update_bot_details(self): try: if not self.restart: self.uptime_label.config(text=f"Uptime: {self.bot_controller.get_uptime()}") self.latency_label.config(text=f"Latency: {self.bot_controller.get_latency()}") - except: + self.uptime_label.after(1000, self._update_bot_details) + except Exception: pass - self.root.after(1000, self._update_bot_details) def _draw_restart_button(self, parent, disabled=False): def _hover_enter(_): @@ -207,7 +214,6 @@ def _draw_details(self, parent): wrapper.grid_columnconfigure(1, weight=1) def _update_wraplength(self, event=None): - # This method can be used for future wraplength updates if needed pass def draw(self, parent, restart=False, start=False): @@ -218,11 +224,89 @@ def draw(self, parent, restart=False, start=False): self._draw_details(parent) - # self.details_wrapper = self._draw_details_wrapper(parent) - # self._draw_account_details(self.details_wrapper) - # self._draw_bot_details(self.details_wrapper) - self._update_bot_details() - # self._update_account_details() - - self.console.draw(parent) \ No newline at end of file + + if not self.restart: + self._notification_feed_widget = self.notification_feed.draw(parent) + self._notification_feed_widget.pack(fill=ttk.BOTH, expand=True) + + self.console.draw(parent) + self._setup_event_hooks() + else: + self.console.draw(parent) + + def _setup_event_hooks(self): + if not self.bot_controller.bot: + return + try: + self.bot_controller.bot.add_listener(self._on_relationship_add, 'on_relationship_add') + self.bot_controller.bot.add_listener(self._on_relationship_remove, 'on_relationship_remove') + self.bot_controller.bot.add_listener(self._on_relationship_update, 'on_relationship_update') + self.bot_controller.bot.add_listener(self._on_guild_remove, 'on_guild_remove') + self.bot_controller.bot.add_listener(self._on_guild_join, 'on_guild_join') + except Exception: + pass + + async def _on_relationship_add(self, relationship): + try: + user_name = getattr(relationship, 'user', relationship).name if hasattr(relationship, 'user') else str(relationship) + self._add_notification( + "friend_add", + f"New {getattr(relationship, 'type', 'friend').name.replace('_', ' ').title() if hasattr(relationship, 'type') else 'friend'}", + f"{user_name} has been added to your relationships." + ) + except Exception: + pass + + async def _on_relationship_remove(self, relationship): + try: + user_name = getattr(relationship, 'user', relationship).name if hasattr(relationship, 'user') else str(relationship) + self._add_notification( + "friend_remove", + f"Removed {getattr(relationship, 'type', 'friend').name.replace('_', ' ').title() if hasattr(relationship, 'type') else 'friend'}", + f"{user_name} has been removed from your relationships." + ) + except Exception: + pass + + async def _on_relationship_update(self, before, after): + try: + user_name = getattr(after, 'user', after).name if hasattr(after, 'user') else str(after) + self._add_notification( + "relationship_update", + "Relationship Updated", + f"Relationship with {user_name} has been updated." + ) + except Exception: + pass + + async def _on_guild_remove(self, guild): + try: + self._add_notification( + "guild_remove", + "Removed from Server", + f"You were removed from {guild.name}." + ) + except Exception: + pass + + async def _on_guild_join(self, guild): + try: + self._add_notification( + "guild_join", + "Joined Server", + f"You joined {guild.name}." + ) + except Exception: + pass + + def _add_notification(self, event_type, title, description="", color=None): + import time as _time + timestamp = _time.strftime("%H:%M:%S") + try: + if self.notification_feed.scroll_frame and self.notification_feed.scroll_frame.winfo_exists(): + self.root.after(0, lambda: self.notification_feed.add_notification( + event_type, title, description, timestamp=timestamp, color=color + )) + except Exception: + pass \ No newline at end of file diff --git a/gui/pages/script.py b/gui/pages/script.py index db90453..778b72d 100644 --- a/gui/pages/script.py +++ b/gui/pages/script.py @@ -1,10 +1,19 @@ import sys +import os import ttkbootstrap as ttk +from ttkbootstrap.dialogs import Messagebox from utils.files import get_application_support from gui.helpers.images import Images +from gui.helpers.style import Style, get_current_theme_str from gui.components.rounded_frame import RoundedFrame -from cupcake import Editor, Languages + +try: + from cupcake import Editor, Languages + HAS_CUPCAKE = True +except ImportError: + HAS_CUPCAKE = False + class ScriptPage: def __init__(self, root, script): @@ -13,49 +22,214 @@ def __init__(self, root, script): self.script = script self.images = Images() self.editor = None + self.text_widget = None self.linenumbers = None self.text_scrollbar = None - + self._dirty = False + self._script_path = os.path.join(get_application_support(), "scripts", self.script) + self._original_content = "" + self._title_label = None + def _go_back(self): - self._save_script() + if self._dirty: + result = str(Messagebox.yesnocancel( + "You have unsaved changes. Do you want to save before leaving?", + title="Unsaved Changes" + )).lower() + if result == "yes": + self._save_script() + elif result == "cancel": + return self.gui.draw_scripts() - + def _get_script_content(self): - with open(get_application_support() + f"/scripts/{self.script}", "r") as file: - return file.read() - + try: + with open(self._script_path, "r", encoding="utf-8") as file: + return file.read() + except Exception as e: + return f"# Error reading script: {e}\n" + def _save_script(self): - # with open(get_application_support() + f"/scripts/{self.script}", "w") as file: - # file.write(self.editor.content.get("1.0", ttk.END)) - self.editor.save() + try: + content = self._get_editor_content() + with open(self._script_path, "w", encoding="utf-8") as file: + file.write(content) + self._original_content = content + self._dirty = False + self._update_title() + self._show_save_status("Saved") + except Exception as e: + Messagebox.show_error(f"Failed to save: {e}", title="Save Error") + + def _reload_script(self): + if self._dirty: + result = str(Messagebox.yesno( + "Discard unsaved changes and reload?", + title="Reload Script" + )).lower() + if result != "yes": + return + content = self._get_script_content() + self._set_editor_content(content) + self._original_content = content + self._dirty = False + self._update_title() + + def _new_script(self): + if self._dirty: + result = str(Messagebox.yesno( + "Discard unsaved changes to create a new script?", + title="New Script" + )).lower() + if result != "yes": + return + self.gui.draw_scripts() + self.gui.scripts_page._create_script() + + def _get_editor_content(self): + if HAS_CUPCAKE and self.editor: + return self.editor.content.get("1.0", "end-1c") + elif self.text_widget: + return self.text_widget.get("1.0", "end-1c") + return "" + + def _set_editor_content(self, content): + if HAS_CUPCAKE and self.editor: + self.editor.content.delete("1.0", "end") + self.editor.content.insert("1.0", content) + elif self.text_widget: + self.text_widget.delete("1.0", "end") + self.text_widget.insert("1.0", content) + + def _on_content_change(self, event=None): + content = self._get_editor_content() + self._dirty = content != self._original_content + self._update_title() + + def _update_title(self): + if self._title_label: + dirty_marker = " *" if self._dirty else "" + self._title_label.configure(text=f"{self.script}{dirty_marker}") + + def _show_save_status(self, text): + if hasattr(self, "_status_label") and self._status_label: + self._status_label.configure(text=text) + self.root.after(2000, lambda: self._status_label.configure(text="")) def _draw_header(self, parent): wrapper = ttk.Frame(parent) - + back_button = ttk.Label(wrapper, image=self.images.get("left-chevron")) back_button.grid(row=0, column=0, sticky=ttk.W, padx=(0, 10)) back_button.bind("", lambda e: self._go_back()) - - script_name = ttk.Label(wrapper, text=self.script, font=("Host Grotesk", 16, "bold")) - script_name.grid(row=0, column=1, sticky=ttk.W) - + back_button.bind("", lambda e: back_button.configure(foreground=Style.LIGHT_GREY.value)) + back_button.bind("", lambda e: back_button.configure(foreground="")) + + self._title_label = ttk.Label(wrapper, text=self.script, font=("Host Grotesk", 16, "bold")) + self._title_label.grid(row=0, column=1, sticky=ttk.W) + + self._status_label = ttk.Label(wrapper, text="", font=("Host Grotesk", 11)) + self._status_label.configure(foreground="#4fee4c") + self._status_label.grid(row=0, column=2, sticky=ttk.W, padx=(10, 0)) + + toolbar = ttk.Frame(wrapper, style="dark.TFrame") + + save_btn = self._make_tool_button(toolbar, "Save", self._save_script) + save_btn.grid(row=0, column=0, padx=(0, 5)) + + reload_btn = self._make_tool_button(toolbar, "Reload", self._reload_script) + reload_btn.grid(row=0, column=1, padx=(0, 5)) + + new_btn = self._make_tool_button(toolbar, "New", self._new_script) + new_btn.grid(row=0, column=2) + + toolbar.grid(row=0, column=3, sticky=ttk.E, padx=(15, 0)) + + wrapper.columnconfigure(2, weight=1) + return wrapper + def _make_tool_button(self, parent, text, command): + btn = ttk.Label( + parent, + text=text, + font=("Host Grotesk", 11), + style="secondary.TLabel", + cursor="hand2" + ) + btn.configure(foreground=Style.LIGHT_GREY.value) + btn.bind("", lambda e: command()) + btn.bind("", lambda e: btn.configure(foreground="white")) + btn.bind("", lambda e: btn.configure(foreground=Style.LIGHT_GREY.value)) + return btn + def draw(self, parent): header = self._draw_header(parent) - header.pack(fill=ttk.X, pady=(0, 20)) - + header.pack(fill=ttk.X, pady=(0, 10)) + editor_wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") editor_wrapper.pack(fill=ttk.BOTH, expand=True) - self.editor = Editor( - editor_wrapper, - language=Languages.PYTHON, - darkmode=True, - font=("JetBrainsMono NF Regular", 10 if sys.platform != "darwin" else 12), - path=get_application_support() + f"/scripts/{self.script}", - showpath=False + if HAS_CUPCAKE: + self.editor = Editor( + editor_wrapper, + language=Languages.PYTHON, + darkmode=True, + font=("JetBrainsMono NF Regular", 10 if sys.platform != "darwin" else 12), + path=self._script_path, + showpath=False + ) + self.editor.pack(fill=ttk.BOTH, expand=True) + content = self._get_script_content() + self.editor.content.insert("1.0", content) + self._original_content = content + self.editor.content.bind("<>", self._on_content_change) + self.editor.content.bind("", lambda e: self.root.after(50, self._on_content_change)) + else: + self._draw_fallback_editor(editor_wrapper) + + def _draw_fallback_editor(self, parent): + content = self._get_script_content() + + self.text_widget = ttk.Text( + parent, + wrap="none", + font=("JetBrainsMono NF", 10 if sys.platform != "darwin" else 12), + undo=True, + autoseparators=True, + maxundo=-1, + ) + self.text_widget.configure( + border=0, + background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value, + insertbackground="white", + highlightcolor=self.root.style.colors.get("dark"), + highlightbackground=self.root.style.colors.get("dark"), + selectbackground="#3d3d5c", ) - self.editor.pack(fill=ttk.BOTH, expand=True) - content = self.editor.content - content.insert("end", self._get_script_content()) \ No newline at end of file + + v_scroll = ttk.Scrollbar(parent, orient="vertical", command=self.text_widget.yview) + h_scroll = ttk.Scrollbar(parent, orient="horizontal", command=self.text_widget.xview) + self.text_widget.configure(yscrollcommand=v_scroll.set, xscrollcommand=h_scroll.set) + + self.text_widget.grid(row=0, column=0, sticky="nsew", padx=(5, 0), pady=5) + v_scroll.grid(row=0, column=1, sticky="ns") + h_scroll.grid(row=1, column=0, sticky="ew") + + parent.grid_rowconfigure(0, weight=1) + parent.grid_columnconfigure(0, weight=1) + + self.text_widget.insert("1.0", content) + self.text_widget.mark_set("insert", "1.0") + self.text_widget.edit_reset() + self.text_widget.edit_modified(False) + self._original_content = content + + self.text_widget.bind("<>", self._on_content_change) + self.text_widget.bind("", lambda e: self.root.after(50, self._on_content_change)) + + self.text_widget.bind("", lambda e: self._save_script()) + self.text_widget.bind("", lambda e: self._reload_script()) + + self.text_widget.focus_set() diff --git a/gui/pages/scripts.py b/gui/pages/scripts.py index dbc59a6..5109338 100644 --- a/gui/pages/scripts.py +++ b/gui/pages/scripts.py @@ -6,11 +6,7 @@ from ttkbootstrap.dialogs import Messagebox from gui.components import RoundedFrame from gui.helpers.style import Style, get_current_theme_str - -# Uncomment the below to enable the dedicated script page. -# Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. - -# from gui.pages.script import ScriptPage +from gui.pages.script import ScriptPage from utils.config import Config from utils.files import open_path_in_explorer, get_application_support @@ -30,34 +26,11 @@ def _is_current_scripts_page(self, token): return token == self._scripts_page_token and hasattr(self, "scripts_wrapper") and self.scripts_wrapper.winfo_exists() def _open_editor(self, script): - # Uncomment the below to enable the dedicated script page. - # Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. - - # self.gui.sidebar.set_current_page("scripts") - # self.gui.layout.clear() - # main = self.gui.layout.main() - # script_page = ScriptPage(self.gui, script) - # script_page.draw(main) - - - # Please comment out the below to enable the dedicated script page! - # Please be aware this is a work in progress and the current state of the page is laggy and sometimes unresponsive. - - if sys.platform == "darwin": - try: - subprocess.run(["code", get_application_support() + f"/scripts/{script}"], creationflags=subprocess.CREATE_NO_WINDOW) - except: - subprocess.run(["open", "-a", "TextEdit", get_application_support() + f"/scripts/{script}"], creationflags=subprocess.CREATE_NO_WINDOW) - elif sys.platform == "win32": - try: - subprocess.run(["code", get_application_support() + f"\\scripts\\{script}"], creationflags=subprocess.CREATE_NO_WINDOW) - except: - subprocess.run(["notepad", get_application_support() + f"\\scripts\\{script}"], creationflags=subprocess.CREATE_NO_WINDOW) - else: - try: - subprocess.run(["subl", get_application_support() + f"/scripts/{script}"], creationflags=subprocess.CREATE_NO_WINDOW) - except: - subprocess.run(["gedit", get_application_support() + f"/scripts/{script}"], creationflags=subprocess.CREATE_NO_WINDOW) + self.gui.sidebar.set_current_page("scripts") + self.gui.layout.clear() + main = self.gui.layout.main() + script_page = ScriptPage(self.gui, script) + script_page.draw(main) def _new_scripts_listener(self, token): if not self._is_current_scripts_page(token): diff --git a/gui/pages/settings.py b/gui/pages/settings.py index 056ef56..b6b0384 100644 --- a/gui/pages/settings.py +++ b/gui/pages/settings.py @@ -31,7 +31,12 @@ def __init__(self, root, bot_controller, draw_settings): def refresh_config(self): if not self.parent: return - + try: + if not self.parent.winfo_exists(): + return + except Exception: + return + try: for widget in self.parent.winfo_children(): widget.destroy() @@ -40,8 +45,8 @@ def refresh_config(self): self.draw(self.parent) if self.parent: self.root.after_idle(lambda: self.root.focus_force()) - except Exception as e: - print(f"Error refreshing config: {e}") + except Exception: + pass def _create_sections(self, wrapper): general_wrapper = ttk.Frame(wrapper) diff --git a/gui/pages/tools/__init__.py b/gui/pages/tools/__init__.py index dfd7526..524df8e 100644 --- a/gui/pages/tools/__init__.py +++ b/gui/pages/tools/__init__.py @@ -2,4 +2,6 @@ from .tools import ToolsPage from .message_logger_page import MessageLoggerPage from .user_lookup_page import UserLookupPage -from .password_gen import PasswordGenPage \ No newline at end of file +from .password_gen import PasswordGenPage +from .backups_page import BackupsPage +from .multi_token_page import MultiTokenPage \ No newline at end of file diff --git a/gui/pages/tools/backups_page.py b/gui/pages/tools/backups_page.py new file mode 100644 index 0000000..c940fd2 --- /dev/null +++ b/gui/pages/tools/backups_page.py @@ -0,0 +1,268 @@ +import os +import sys +import time +import shutil +import zipfile +import threading +import ttkbootstrap as ttk +from ttkbootstrap.dialogs import Messagebox +from ttkbootstrap.scrolled import ScrolledFrame + +from gui.components import ToolPage, RoundedFrame, RoundedSwitch +from gui.helpers import Style +from gui.helpers.images import Images +from utils.files import get_application_support, get_config_path, get_scripts_path, get_themes_path, get_data_path + + +class BackupsPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="Backups", frame=None) + self.images = images + self.cfg = bot_controller.cfg + self.backups_dir = os.path.join(get_application_support(), "backups") + self.backup_list_frame = None + self._backup_frames = [] + self.status_label = None + self._progress_label = None + + def _ensure_backups_dir(self): + os.makedirs(self.backups_dir, exist_ok=True) + + def _get_backups(self): + self._ensure_backups_dir() + backups = [] + for f in os.listdir(self.backups_dir): + if f.endswith(".zip"): + path = os.path.join(self.backups_dir, f) + stat = os.stat(path) + backups.append({ + "name": f, + "path": path, + "size": stat.st_size, + "time": stat.st_mtime, + }) + backups.sort(key=lambda b: b["time"], reverse=True) + return backups + + def _format_size(self, size_bytes): + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" + + def _format_time(self, timestamp): + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + + def _create_backup(self): + self._ensure_backups_dir() + timestamp = time.strftime("%Y%m%d_%H%M%S") + backup_name = f"ghost_backup_{timestamp}.zip" + backup_path = os.path.join(self.backups_dir, backup_name) + + self._set_status("Creating backup...") + + def _do_backup(): + try: + with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zf: + config_path = get_config_path() + if os.path.exists(config_path): + zf.write(config_path, "config.json") + + scripts_dir = get_scripts_path() + if os.path.exists(scripts_dir): + for root_dir, dirs, files in os.walk(scripts_dir): + for file in files: + file_path = os.path.join(root_dir, file) + arcname = os.path.relpath(file_path, get_application_support()) + zf.write(file_path, arcname) + + themes_dir = get_themes_path() + if os.path.exists(themes_dir): + for root_dir, dirs, files in os.walk(themes_dir): + for file in files: + file_path = os.path.join(root_dir, file) + arcname = os.path.relpath(file_path, get_application_support()) + zf.write(file_path, arcname) + + data_dir = get_data_path() + if os.path.exists(data_dir): + for item in os.listdir(data_dir): + if item in ("cache", "surveillance"): + continue + item_path = os.path.join(data_dir, item) + if os.path.isfile(item_path): + arcname = os.path.relpath(item_path, get_application_support()) + zf.write(item_path, arcname) + + self.root.after(0, lambda: self._set_status(f"Backup created: {backup_name}")) + self.root.after(0, self._refresh_backup_list) + except Exception as e: + self.root.after(0, lambda: self._set_status(f"Backup failed: {e}")) + + threading.Thread(target=_do_backup, daemon=True).start() + + def _restore_backup(self, backup_info): + result = str(Messagebox.yesno( + f"Restoring '{backup_info['name']}' will replace your current configuration, scripts, and themes.\n\n" + "A safety backup of your current data will be created first.\n\n" + "Do you want to continue?", + title="Restore Backup" + )).lower() + + if result != "yes": + return + + self._set_status("Creating safety backup before restore...") + + def _do_restore(): + try: + timestamp = time.strftime("%Y%m%d_%H%M%S") + safety_name = f"ghost_pre_restore_{timestamp}.zip" + safety_path = os.path.join(self.backups_dir, safety_name) + with zipfile.ZipFile(safety_path, 'w', zipfile.ZIP_DEFLATED) as zf: + config_path = get_config_path() + if os.path.exists(config_path): + zf.write(config_path, "config.json") + scripts_dir = get_scripts_path() + if os.path.exists(scripts_dir): + for root_dir, dirs, files in os.walk(scripts_dir): + for file in files: + file_path = os.path.join(root_dir, file) + arcname = os.path.relpath(file_path, get_application_support()) + zf.write(file_path, arcname) + themes_dir = get_themes_path() + if os.path.exists(themes_dir): + for root_dir, dirs, files in os.walk(themes_dir): + for file in files: + file_path = os.path.join(root_dir, file) + arcname = os.path.relpath(file_path, get_application_support()) + zf.write(file_path, arcname) + + with zipfile.ZipFile(backup_info['path'], 'r') as zf: + zf.extractall(get_application_support()) + + self.root.after(0, lambda: self._set_status(f"Restored: {backup_info['name']}")) + self.root.after(0, self._refresh_backup_list) + except Exception as e: + self.root.after(0, lambda: self._set_status(f"Restore failed: {e}")) + + threading.Thread(target=_do_restore, daemon=True).start() + + def _delete_backup(self, backup_info): + result = str(Messagebox.yesno( + f"Delete backup '{backup_info['name']}'?", + title="Delete Backup" + )).lower() + + if result != "yes": + return + + try: + os.remove(backup_info['path']) + self._set_status(f"Deleted: {backup_info['name']}") + self._refresh_backup_list() + except Exception as e: + self._set_status(f"Delete failed: {e}") + + def _set_status(self, text): + if self.status_label: + self.status_label.configure(text=text) + + def _refresh_backup_list(self): + for frame_info in self._backup_frames: + if frame_info["frame"].winfo_exists(): + frame_info["frame"].destroy() + self._backup_frames.clear() + self._draw_backup_entries() + + def _draw_backup_entries(self): + backups = self._get_backups() + + if not backups: + empty_label = ttk.Label(self.backup_list_frame, + text="No backups yet. Create one above.", + font=("Host Grotesk", 11)) + empty_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.DARK_GREY.value) + empty_label.pack(pady=20) + return + + for backup in backups: + card = RoundedFrame(self.backup_list_frame, radius=(8, 8, 8, 8), + bootstyle="dark.TFrame") + card.pack(fill=ttk.X, pady=(0, 5)) + + inner = ttk.Frame(card, style="dark.TFrame") + inner.pack(fill=ttk.BOTH, padx=12, pady=8) + + name_label = ttk.Label(inner, text=backup["name"], + font=("Host Grotesk", 11, "bold")) + name_label.configure(background=self.root.style.colors.get("dark")) + name_label.grid(row=0, column=0, sticky=ttk.W) + + info_text = f"{self._format_size(backup['size'])} | {self._format_time(backup['time'])}" + info_label = ttk.Label(inner, text=info_text, + font=("Host Grotesk", 9)) + info_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value) + info_label.grid(row=1, column=0, sticky=ttk.W) + + btn_frame = ttk.Frame(inner, style="dark.TFrame") + btn_frame.grid(row=0, column=1, rowspan=2, sticky=ttk.E) + + restore_btn = ttk.Label(btn_frame, text="Restore", + font=("Host Grotesk", 10, "bold"), + cursor="hand2") + restore_btn.configure(foreground="#4fee4c") + restore_btn.pack(side=ttk.LEFT, padx=(0, 10)) + restore_btn.bind("", lambda e, b=backup: self._restore_backup(b)) + restore_btn.bind("", lambda e: restore_btn.configure(foreground="white")) + restore_btn.bind("", lambda e: restore_btn.configure(foreground="#4fee4c")) + + delete_btn = ttk.Label(btn_frame, text="Delete", + font=("Host Grotesk", 10), + cursor="hand2") + delete_btn.configure(foreground="#ff6464") + delete_btn.pack(side=ttk.LEFT) + delete_btn.bind("", lambda e, b=backup: self._delete_backup(b)) + delete_btn.bind("", lambda e: delete_btn.configure(foreground="white")) + delete_btn.bind("", lambda e: delete_btn.configure(foreground="#ff6464")) + + inner.grid_columnconfigure(0, weight=1) + + self._backup_frames.append({"frame": card, "backup": backup}) + + def draw_content(self, wrapper): + create_btn_frame = RoundedFrame(wrapper, radius=(10, 10, 10, 10), + bootstyle="primary.TFrame") + create_btn_frame.pack(fill=ttk.X, pady=(0, 10)) + + create_inner = ttk.Frame(create_btn_frame, style="primary.TFrame") + create_inner.pack(fill=ttk.BOTH, padx=12, pady=8) + + create_label = ttk.Label(create_inner, text="Create New Backup", + font=("Host Grotesk", 12, "bold"), + cursor="hand2") + create_label.configure(background=self.root.style.colors.get("primary")) + create_label.pack(side=ttk.LEFT) + create_label.bind("", lambda e: self._create_backup()) + create_btn_frame.bind("", lambda e: self._create_backup()) + + self.status_label = ttk.Label(create_inner, text="", + font=("Host Grotesk", 10)) + self.status_label.configure(background=self.root.style.colors.get("primary"), + foreground="#ffffff") + self.status_label.pack(side=ttk.RIGHT) + + backup_label = ttk.Label(wrapper, text="Available Backups", + font=("Host Grotesk", 14, "bold")) + backup_label.configure(background=self.root.style.colors.get("dark")) + backup_label.pack(anchor=ttk.W, pady=(5, 5)) + + self.backup_list_frame = ScrolledFrame(wrapper, bootstyle="dark.TFrame", autohide=True) + self.backup_list_frame.container.configure(style="dark.TFrame") + self.backup_list_frame.pack(fill=ttk.BOTH, expand=True) + + self._draw_backup_entries() diff --git a/gui/pages/tools/multi_token_page.py b/gui/pages/tools/multi_token_page.py new file mode 100644 index 0000000..2771759 --- /dev/null +++ b/gui/pages/tools/multi_token_page.py @@ -0,0 +1,351 @@ +import os +import sys +import time +import asyncio +import threading +import requests +import ttkbootstrap as ttk +from ttkbootstrap.scrolled import ScrolledFrame +from ttkbootstrap.dialogs import Messagebox + +from gui.components import ToolPage, RoundedFrame +from gui.helpers import Style +from gui.helpers.images import Images +from utils.config import Config + + +class TokenInstance: + def __init__(self, token_data, on_status_change=None): + self.token = token_data.get("token", "") + self.name = token_data.get("name", "Unnamed") + self.bot = None + self.loop = None + self.thread = None + self.running = False + self.start_time = None + self.on_status_change = on_status_change + self._username = self.name + + def start(self): + if self.running or not self.token: + return + + try: + resp = requests.get( + "https://discord.com/api/v9/users/@me", + headers={"Authorization": self.token}, + timeout=10, + ) + if resp.status_code != 200: + return + self._username = resp.json().get("username", "Unknown") + except Exception: + return + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + from bot.bot import Ghost + from bot.controller import BotController + + controller = BotController() + controller.cfg.set("token", self.token) + controller.loop = self.loop + controller.running = True + + self.bot = Ghost(controller) + + async def _on_ready(): + self.start_time = time.time() + self.running = True + self._username = self.bot.user.name if self.bot.user else self.name + if self.on_status_change: + self.on_status_change(self) + + async def _on_disconnect(): + self.running = False + if self.on_status_change: + self.on_status_change(self) + + self.bot.add_listener(_on_ready, 'on_ready') + self.bot.add_listener(_on_disconnect, 'on_disconnect') + + self.loop.create_task(self.bot.start(token=self.token, reconnect=True)) + self.thread = threading.Thread(target=self.loop.run_forever, daemon=True) + self.thread.start() + + def stop(self): + if not self.running or not self.bot or not self.loop: + return + + self.running = False + + async def shutdown(): + try: + await self.bot.close() + except Exception: + pass + + asyncio.run_coroutine_threadsafe(shutdown(), self.loop) + if self.on_status_change: + self.on_status_change(self) + + def get_status(self): + if not self.running: + return "stopped" + try: + if self.bot and self.bot.is_ready(): + return "online" + except Exception: + pass + return "connecting" + + def get_username(self): + if self.bot and hasattr(self, "_username"): + return self._username + return self.name + + def get_uptime(self): + if not self.start_time or not self.running: + return "--" + elapsed = time.time() - self.start_time + hours = int(elapsed // 3600) + minutes = int((elapsed % 3600) // 60) + seconds = int(elapsed % 60) + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m {seconds}s" + + +class MultiTokenPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="Multi-Token", frame=None) + self.images = images + self.cfg = Config() + self.sessions = [] + self.token_frames = [] + self.list_frame = None + + def _get_tokens(self): + tokens = self.cfg.get("multi_tokens") + return tokens if tokens else [] + + def _save_tokens(self, tokens): + self.cfg.set("multi_tokens", tokens) + self.cfg.save(notify=False) + + def _add_token(self): + dialog = ttk.Toplevel(self.root) + dialog.title("Add Token") + dialog.geometry("450x200") + dialog.configure(background=self.root.style.colors.get("dark")) + dialog.transient(self.root) + dialog.grab_set() + + wrapper = ttk.Frame(dialog, style="dark.TFrame") + wrapper.pack(fill=ttk.BOTH, expand=True, padx=20, pady=20) + + name_label = ttk.Label(wrapper, text="Name", font=("Host Grotesk", 11)) + name_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value) + name_label.pack(anchor=ttk.W) + + name_var = ttk.StringVar(value="Account 1") + name_entry = ttk.Entry(wrapper, textvariable=name_var, font=("Host Grotesk", 11)) + name_entry.configure(foreground=self.root.style.colors.get("fg")) + name_entry.pack(fill=ttk.X, pady=(0, 10)) + + token_label = ttk.Label(wrapper, text="Token", font=("Host Grotesk", 11)) + token_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value) + token_label.pack(anchor=ttk.W) + + token_var = ttk.StringVar() + token_entry = ttk.Entry(wrapper, textvariable=token_var, font=("Host Grotesk", 11), show="*") + token_entry.configure(foreground=self.root.style.colors.get("fg")) + token_entry.pack(fill=ttk.X, pady=(0, 10)) + + btn_frame = ttk.Frame(wrapper, style="dark.TFrame") + btn_frame.pack(fill=ttk.X) + + def confirm(): + name = name_var.get().strip() + token = token_var.get().strip() + if not token: + Messagebox.show_error("Token cannot be empty.", title="Error") + return + if not name: + name = "Account" + + tokens = self._get_tokens() + tokens.append({"name": name, "token": token, "enabled": False}) + self._save_tokens(tokens) + dialog.destroy() + self._refresh_list() + + cancel_btn = ttk.Label(btn_frame, text="Cancel", font=("Host Grotesk", 11), + cursor="hand2") + cancel_btn.configure(foreground=Style.LIGHT_GREY.value, + background=self.root.style.colors.get("dark")) + cancel_btn.pack(side=ttk.LEFT) + cancel_btn.bind("", lambda e: dialog.destroy()) + + add_btn = ttk.Label(btn_frame, text="Add", font=("Host Grotesk", 11, "bold"), + cursor="hand2") + add_btn.configure(foreground="#4fee4c", + background=self.root.style.colors.get("dark")) + add_btn.pack(side=ttk.RIGHT) + add_btn.bind("", lambda e: confirm()) + token_entry.bind("", lambda e: confirm()) + + def _toggle_token(self, index): + tokens = self._get_tokens() + if index >= len(tokens): + return + + token_data = tokens[index] + session = self._find_session(token_data["token"]) + + if session and session.running: + session.stop() + token_data["enabled"] = False + else: + if not session: + session = TokenInstance(token_data, on_status_change=lambda s: self.root.after(0, self._refresh_list)) + self.sessions.append(session) + session.start() + token_data["enabled"] = True + + self._save_tokens(tokens) + self.root.after(500, self._refresh_list) + + def _remove_token(self, index): + tokens = self._get_tokens() + if index >= len(tokens): + return + + token_data = tokens[index] + session = self._find_session(token_data["token"]) + if session: + session.stop() + self.sessions = [s for s in self.sessions if s.token != token_data["token"]] + + tokens.pop(index) + self._save_tokens(tokens) + self._refresh_list() + + def _find_session(self, token): + for s in self.sessions: + if s.token == token: + return s + return None + + def _refresh_list(self): + if not self.list_frame or not self.list_frame.winfo_exists(): + return + + for child in self.list_frame.winfo_children(): + child.destroy() + + tokens = self._get_tokens() + + if not tokens: + empty = ttk.Label(self.list_frame, text="No tokens added yet.\nClick + Add Token to get started.", + font=("Host Grotesk", 11), justify=ttk.CENTER) + empty.configure(background=self.root.style.colors.get("dark"), + foreground=Style.DARK_GREY.value) + empty.pack(pady=40) + return + + for i, token_data in enumerate(tokens): + self._draw_token_card(self.list_frame, token_data, i) + + def _draw_token_card(self, parent, token_data, index): + session = self._find_session(token_data["token"]) + status = session.get_status() if session else "stopped" + username = session.get_username() if session else token_data.get("name", "Unknown") + uptime = session.get_uptime() if session else "--" + + card = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="dark.TFrame") + card.pack(fill=ttk.X, pady=(0, 5)) + + inner = ttk.Frame(card, style="dark.TFrame") + inner.pack(fill=ttk.BOTH, padx=12, pady=10) + + status_colors = {"online": "#4fee4c", "connecting": "#eceb18", "stopped": "#ff6464"} + status_color = status_colors.get(status, Style.DARK_GREY.value) + + dot = ttk.Canvas(inner, width=8, height=8, highlightthickness=0, + background=self.root.style.colors.get("dark")) + dot.grid(row=0, column=0, rowspan=2, sticky=ttk.N, pady=(4, 0)) + dot.create_oval(0, 0, 8, 8, fill=status_color, outline=status_color) + + name_label = ttk.Label(inner, text=username, font=("Host Grotesk", 12, "bold")) + name_label.configure(background=self.root.style.colors.get("dark")) + name_label.grid(row=0, column=1, sticky=ttk.W, padx=(8, 0)) + + detail_text = f"{status.title()}" + if status == "online": + detail_text += f" | Uptime: {uptime}" + detail_label = ttk.Label(inner, text=detail_text, font=("Host Grotesk", 9)) + detail_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value) + detail_label.grid(row=1, column=1, sticky=ttk.W, padx=(8, 0)) + + btn_frame = ttk.Frame(inner, style="dark.TFrame") + btn_frame.grid(row=0, column=2, rowspan=2, sticky=ttk.E) + + toggle_text = "Stop" if status in ("online", "connecting") else "Start" + toggle_color = "#ff6464" if status in ("online", "connecting") else "#4fee4c" + + toggle_btn = ttk.Label(btn_frame, text=toggle_text, font=("Host Grotesk", 10, "bold"), + cursor="hand2") + toggle_btn.configure(foreground=toggle_color, background=self.root.style.colors.get("dark")) + toggle_btn.pack(side=ttk.LEFT, padx=(0, 10)) + toggle_btn.bind("", lambda e, i=index: self._toggle_token(i)) + toggle_btn.bind("", lambda e: toggle_btn.configure(foreground="white")) + toggle_btn.bind("", lambda e: toggle_btn.configure(foreground=toggle_color)) + + remove_btn = ttk.Label(btn_frame, text="Remove", font=("Host Grotesk", 10), + cursor="hand2") + remove_btn.configure(foreground="#ff6464", background=self.root.style.colors.get("dark")) + remove_btn.pack(side=ttk.LEFT) + remove_btn.bind("", lambda e, i=index: self._remove_token(i)) + remove_btn.bind("", lambda e: remove_btn.configure(foreground="white")) + remove_btn.bind("", lambda e: remove_btn.configure(foreground="#ff6464")) + + inner.grid_columnconfigure(1, weight=1) + + def draw_content(self, wrapper): + header = ttk.Frame(wrapper, style="dark.TFrame") + header.pack(fill=ttk.X, pady=(0, 10)) + + title = ttk.Label(header, text="Multi-Token Manager", + font=("Host Grotesk", 14, "bold")) + title.configure(background=self.root.style.colors.get("dark")) + title.pack(side=ttk.LEFT) + + add_btn = RoundedFrame(header, radius=(8, 8, 8, 8), bootstyle="primary.TFrame") + add_btn.pack(side=ttk.RIGHT) + + add_inner = ttk.Frame(add_btn, style="primary.TFrame") + add_inner.pack(padx=12, pady=6) + + add_label = ttk.Label(add_inner, text="+ Add Token", + font=("Host Grotesk", 11, "bold"), cursor="hand2") + add_label.configure(background=self.root.style.colors.get("primary")) + add_label.pack() + add_btn.bind("", lambda e: self._add_token()) + add_label.bind("", lambda e: self._add_token()) + + desc = ttk.Label(wrapper, text="Run multiple selfbot accounts simultaneously.", + font=("Host Grotesk", 10)) + desc.configure(background=self.root.style.colors.get("dark"), + foreground=Style.DARK_GREY.value) + desc.pack(anchor=ttk.W, pady=(0, 10)) + + self.list_frame = ScrolledFrame(wrapper, bootstyle="dark.TFrame", autohide=True) + self.list_frame.container.configure(style="dark.TFrame") + self.list_frame.pack(fill=ttk.BOTH, expand=True) + + self._refresh_list() diff --git a/gui/pages/tools/surveillance_page.py b/gui/pages/tools/surveillance_page.py index 4151945..d4fa1e1 100644 --- a/gui/pages/tools/surveillance_page.py +++ b/gui/pages/tools/surveillance_page.py @@ -357,6 +357,8 @@ def _draw_header(self, parent): return header def update(self): + if self.textarea is None: + return try: self.textarea.delete("1.0", "end") @@ -367,14 +369,14 @@ def update(self): self.textarea.yview_moveto(1) except: - print("Surveillance console tried to update without being drawn.") - + pass + def clear(self): self.console = [] try: self.textarea.delete("1.0", "end") except: - print("Surveillance console tried to clear without being drawn.") + pass def add_log(self, prefix, text): time = get_formatted_time() diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index ae74649..f90d9e9 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -6,6 +6,8 @@ from gui.pages.tools.message_logger_page import MessageLoggerPage from gui.pages.tools.user_lookup_page import UserLookupPage from gui.pages.tools.password_gen import PasswordGenPage +from gui.pages.tools.backups_page import BackupsPage +from gui.pages.tools.multi_token_page import MultiTokenPage from gui.helpers.style import Style class ToolsPage: @@ -21,11 +23,13 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): self.message_logger_page = MessageLoggerPage(self, root, bot_controller, images, layout) self.user_lookup_page = UserLookupPage(self, root, bot_controller, images, layout) self.password_gen_page = PasswordGenPage(self, root, bot_controller, images, layout) + self.backups_page = BackupsPage(self, root, bot_controller, images, layout) + self.multi_token_page = MultiTokenPage(self, root, bot_controller, images, layout) self.pages = [ { "name": "Surveillance", - "description": "Search a user’s message history across mutual servers", + "description": "Search a user's message history across mutual servers", "page": self.surveillance_page, "command": self.draw_surveillance }, @@ -40,7 +44,19 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): "description": "Generate strong, random passwords with customizable options", "page": self.password_gen_page, "command": self.draw_password_gen - } + }, + { + "name": "Backups", + "description": "Create and restore backups of your Ghost configuration and data", + "page": self.backups_page, + "command": self.draw_backups + }, + { + "name": "Multi-Token", + "description": "Run multiple selfbot accounts simultaneously", + "page": self.multi_token_page, + "command": self.draw_multi_token + }, # { # "name": "User Lookup", # "description": "Look up information about a user by their ID", @@ -80,6 +96,22 @@ def draw_user_lookup(self): self.user_lookup_page.draw(main) self.layout.sidebar.set_button_command("tools", self.draw_user_lookup) self.position_resize_grips() + + def draw_backups(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.backups_page.draw(main) + self.layout.sidebar.set_button_command("tools", self.draw_backups) + self.position_resize_grips() + + def draw_multi_token(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.multi_token_page.draw(main) + self.layout.sidebar.set_button_command("tools", self.draw_multi_token) + self.position_resize_grips() def _bind_hover_effects(self, widget, targets, hover_bg, normal_bg): def on_enter(_): diff --git a/readme.md b/readme.md index 145bac3..f49aaea 100644 --- a/readme.md +++ b/readme.md @@ -2,17 +2,6 @@ The most advanced open source Discord Selfbot. Ghost has 150+ commands and a completely custom built GUI for endless customisation! -# Things im planning to fix/add -- Add a toggle for starting on windows boot✅ -- Adding ability to search commands(SCRAPPED) -- Adding notifications when a guild is left or friends unadd you etc.✅ -- Add abilty to edit the homescreen to add sniper to it✅ -- Webhook message sender MAYBE(SCRAPPED) -- Make the sniping log look better(~) -- Create a backups page✅ -- Multi-account support -- A built in text editor for custom scripts✅ - > [!WARNING] > Using Ghost is against Discord's TOS and can result in account termination! To avoid termination, do not use any commands in bigger servers especially ones moderated by Discord staff and do not use any of the abuse commands. I do not take any responsibility for the actions that may be caused by using Ghost. Please use with caution! @@ -59,4 +48,4 @@ Follow [this guide](https://gist.github.com/bennyscripts/49ecc1eade1796ee1d7cad9 > Please note that Ghost is developed on Mac OS which means the Windows build's may have issues. However, Ghost will work on Windows, Linux and Mac OS without issues when run from the source code! Please follow the steps above or this [setup guide](https://github.com/orgs/ghostselfbot/discussions/5) to insure proper installation! > > **Anonymous usage statistics** -> Ghost optionally sends a small anonymous ping to help estimate active installations and version adoption. No Discord tokens, user IDs, guilds, messages or personal data are transmitted. This can be disabled in the settings page of the GUI or in the config file. +> Ghost optionally sends a small anonymous ping to help estimate active installations and version adoption. No Discord tokens, user IDs, guilds, messages or personal data are transmitted. This can be disabled in the settings page of the GUI or in the config file. \ No newline at end of file diff --git a/utils/defaults.py b/utils/defaults.py index 646ceff..5338069 100644 --- a/utils/defaults.py +++ b/utils/defaults.py @@ -48,6 +48,8 @@ "gui_theme": "dark", "telemetry": True, "install_id": "", + "launch_on_startup": False, + "multi_tokens": [], } DEFAULT_THEME = { diff --git a/utils/files.py b/utils/files.py index 6704c27..23a7036 100644 --- a/utils/files.py +++ b/utils/files.py @@ -57,7 +57,7 @@ def open_path_in_explorer(path): elif sys.platform == "win32": os.startfile(path) else: - subprocess.run(["xdg-open", path], creationflags=subprocess.CREATE_NO_WINDOW) + subprocess.run(["xdg-open", path]) def open_file_in_editor(file_path): if sys.platform == "darwin": @@ -65,4 +65,113 @@ def open_file_in_editor(file_path): elif sys.platform == "win32": subprocess.run(["notepad", file_path], creationflags=subprocess.CREATE_NO_WINDOW) else: - subprocess.run(["gedit", file_path]) \ No newline at end of file + subprocess.run(["gedit", file_path]) + +def set_launch_on_startup(enabled): + """Register or remove Ghost from OS startup. Returns (success, error_message).""" + import traceback + try: + if sys.platform == "win32": + return _set_startup_windows(enabled) + elif sys.platform == "darwin": + return _set_startup_macos(enabled) + else: + return _set_startup_linux(enabled) + except Exception as e: + return False, str(e) + +def _set_startup_windows(enabled): + try: + import winreg + key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) + + if getattr(sys, 'frozen', False): + exe_path = sys.executable + else: + exe_path = f'"{os.path.abspath(sys.executable)}" "{os.path.abspath("ghost.py")}"' + + app_name = "GhostSelfbot" + + if enabled: + winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, exe_path) + else: + try: + winreg.DeleteValue(key, app_name) + except FileNotFoundError: + pass + + winreg.CloseKey(key) + return True, None + except Exception as e: + return False, str(e) + +def _set_startup_macos(enabled): + try: + plist_dir = os.path.expanduser("~/Library/LaunchAgents") + plist_path = os.path.join(plist_dir, "com.ghost.selfbot.plist") + + if not enabled: + if os.path.exists(plist_path): + os.remove(plist_path) + return True, None + + if getattr(sys, 'frozen', False): + exe_path = sys.executable + else: + exe_path = sys.executable + + plist_content = f""" + + + + Label + com.ghost.selfbot + ProgramArguments + + {exe_path} + + RunAtLoad + + WorkingDirectory + {os.path.dirname(exe_path)} + +""" + + os.makedirs(plist_dir, exist_ok=True) + with open(plist_path, "w") as f: + f.write(plist_content) + return True, None + except Exception as e: + return False, str(e) + +def _set_startup_linux(enabled): + try: + autostart_dir = os.path.expanduser("~/.config/autostart") + desktop_path = os.path.join(autostart_dir, "ghost-selfbot.desktop") + + if not enabled: + if os.path.exists(desktop_path): + os.remove(desktop_path) + return True, None + + if getattr(sys, 'frozen', False): + exec_path = sys.executable + else: + exec_path = f'{sys.executable} {os.path.abspath("ghost.py")}' + + desktop_content = f"""[Desktop Entry] +Type=Application +Name=Ghost Selfbot +Exec={exec_path} +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +""" + + os.makedirs(autostart_dir, exist_ok=True) + with open(desktop_path, "w") as f: + f.write(desktop_content) + return True, None + except Exception as e: + return False, str(e) \ No newline at end of file From 06a1ed37c9279ce45caa147d0887f427e60bd32a Mon Sep 17 00:00:00 2001 From: ItzRealMee Date: Wed, 2 Sep 2026 20:21:55 -0700 Subject: [PATCH 6/8] i think i fixed the errors idrk tho fuck me sideways --- gui/pages/home.py | 8 +++-- gui/pages/script.py | 10 +++--- gui/pages/tools/backups_page.py | 2 ++ gui/pages/tools/multi_token_page.py | 50 +++++++++++++++++------------ 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/gui/pages/home.py b/gui/pages/home.py index f60762e..39ec655 100644 --- a/gui/pages/home.py +++ b/gui/pages/home.py @@ -34,7 +34,8 @@ def __init__(self, root, bot_controller, _restart_bot, console): self.notification_feed = NotificationFeed(root) self._notification_feed_widget = None - + self._hooks_installed = False + self.root.bind("", self._update_wraplength) def _clear_everything(self): @@ -236,6 +237,8 @@ def draw(self, parent, restart=False, start=False): self.console.draw(parent) def _setup_event_hooks(self): + if self._hooks_installed: + return if not self.bot_controller.bot: return try: @@ -244,6 +247,7 @@ def _setup_event_hooks(self): self.bot_controller.bot.add_listener(self._on_relationship_update, 'on_relationship_update') self.bot_controller.bot.add_listener(self._on_guild_remove, 'on_guild_remove') self.bot_controller.bot.add_listener(self._on_guild_join, 'on_guild_join') + self._hooks_installed = True except Exception: pass @@ -309,4 +313,4 @@ def _add_notification(self, event_type, title, description="", color=None): event_type, title, description, timestamp=timestamp, color=color )) except Exception: - pass \ No newline at end of file + pass diff --git a/gui/pages/script.py b/gui/pages/script.py index 778b72d..b1dbd78 100644 --- a/gui/pages/script.py +++ b/gui/pages/script.py @@ -172,7 +172,7 @@ def draw(self, parent): if HAS_CUPCAKE: self.editor = Editor( - editor_wrapper, + editor_wrapper.inner_frame, language=Languages.PYTHON, darkmode=True, font=("JetBrainsMono NF Regular", 10 if sys.platform != "darwin" else 12), @@ -180,13 +180,13 @@ def draw(self, parent): showpath=False ) self.editor.pack(fill=ttk.BOTH, expand=True) - content = self._get_script_content() - self.editor.content.insert("1.0", content) - self._original_content = content + self.editor.content.configure(state="normal") + self._original_content = self.editor.content.get("1.0", "end-1c") self.editor.content.bind("<>", self._on_content_change) self.editor.content.bind("", lambda e: self.root.after(50, self._on_content_change)) + self.editor.content.focus_set() else: - self._draw_fallback_editor(editor_wrapper) + self._draw_fallback_editor(editor_wrapper.inner_frame) def _draw_fallback_editor(self, parent): content = self._get_script_content() diff --git a/gui/pages/tools/backups_page.py b/gui/pages/tools/backups_page.py index c940fd2..753bd31 100644 --- a/gui/pages/tools/backups_page.py +++ b/gui/pages/tools/backups_page.py @@ -171,6 +171,8 @@ def _set_status(self, text): self.status_label.configure(text=text) def _refresh_backup_list(self): + for child in self.backup_list_frame.winfo_children(): + child.destroy() for frame_info in self._backup_frames: if frame_info["frame"].winfo_exists(): frame_info["frame"].destroy() diff --git a/gui/pages/tools/multi_token_page.py b/gui/pages/tools/multi_token_page.py index 2771759..353250e 100644 --- a/gui/pages/tools/multi_token_page.py +++ b/gui/pages/tools/multi_token_page.py @@ -142,6 +142,15 @@ def _add_token(self): dialog.transient(self.root) dialog.grab_set() + if sys.platform == "win32": + try: + import hPyT + hPyT.title_bar.hide(dialog, no_span=True) + hPyT.corner_radius.set(dialog, style="round") + hPyT.window_dwm.toggle_dwm_transitions(dialog, enabled=True) + except Exception: + pass + wrapper = ttk.Frame(dialog, style="dark.TFrame") wrapper.pack(fill=ttk.BOTH, expand=True, padx=20, pady=20) @@ -317,32 +326,31 @@ def _draw_token_card(self, parent, token_data, index): inner.grid_columnconfigure(1, weight=1) def draw_content(self, wrapper): - header = ttk.Frame(wrapper, style="dark.TFrame") - header.pack(fill=ttk.X, pady=(0, 10)) + add_btn_frame = RoundedFrame(wrapper, radius=(10, 10, 10, 10), + bootstyle="primary.TFrame") + add_btn_frame.pack(fill=ttk.X, pady=(0, 10)) - title = ttk.Label(header, text="Multi-Token Manager", - font=("Host Grotesk", 14, "bold")) - title.configure(background=self.root.style.colors.get("dark")) - title.pack(side=ttk.LEFT) - - add_btn = RoundedFrame(header, radius=(8, 8, 8, 8), bootstyle="primary.TFrame") - add_btn.pack(side=ttk.RIGHT) - - add_inner = ttk.Frame(add_btn, style="primary.TFrame") - add_inner.pack(padx=12, pady=6) + add_inner = ttk.Frame(add_btn_frame, style="primary.TFrame") + add_inner.pack(fill=ttk.BOTH, padx=12, pady=8) add_label = ttk.Label(add_inner, text="+ Add Token", - font=("Host Grotesk", 11, "bold"), cursor="hand2") + font=("Host Grotesk", 12, "bold"), + cursor="hand2") add_label.configure(background=self.root.style.colors.get("primary")) - add_label.pack() - add_btn.bind("", lambda e: self._add_token()) + add_label.pack(side=ttk.LEFT) add_label.bind("", lambda e: self._add_token()) - - desc = ttk.Label(wrapper, text="Run multiple selfbot accounts simultaneously.", - font=("Host Grotesk", 10)) - desc.configure(background=self.root.style.colors.get("dark"), - foreground=Style.DARK_GREY.value) - desc.pack(anchor=ttk.W, pady=(0, 10)) + add_btn_frame.bind("", lambda e: self._add_token()) + + self.status_label = ttk.Label(add_inner, text="", + font=("Host Grotesk", 10)) + self.status_label.configure(background=self.root.style.colors.get("primary"), + foreground="#ffffff") + self.status_label.pack(side=ttk.RIGHT) + + tokens_label = ttk.Label(wrapper, text="Active Tokens", + font=("Host Grotesk", 14, "bold")) + tokens_label.configure(background=self.root.style.colors.get("dark")) + tokens_label.pack(anchor=ttk.W, pady=(5, 5)) self.list_frame = ScrolledFrame(wrapper, bootstyle="dark.TFrame", autohide=True) self.list_frame.container.configure(style="dark.TFrame") From aa0d599ef80474f90adf2a8f0632c7169af14ca9 Mon Sep 17 00:00:00 2001 From: ItzRealMee Date: Wed, 2 Sep 2026 20:40:43 -0700 Subject: [PATCH 7/8] fixed startup i think idk --- utils/files.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/utils/files.py b/utils/files.py index 23a7036..a09e737 100644 --- a/utils/files.py +++ b/utils/files.py @@ -89,7 +89,9 @@ def _set_startup_windows(enabled): if getattr(sys, 'frozen', False): exe_path = sys.executable else: - exe_path = f'"{os.path.abspath(sys.executable)}" "{os.path.abspath("ghost.py")}"' + script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ghost_path = os.path.join(script_dir, "ghost.py") + exe_path = f'cmd.exe /c "cd /d "{script_dir}" && python "{ghost_path}"' app_name = "GhostSelfbot" From 88ec3fa81f071daaaedd6646246aba4214c0de65 Mon Sep 17 00:00:00 2001 From: ItzRealMee Date: Fri, 4 Sep 2026 08:34:38 -0700 Subject: [PATCH 8/8] fixes and other stuff --- bot/bot.py | 12 +- gui/components/console.py | 4 +- gui/components/notification_feed.py | 247 +++++----- gui/components/settings/general.py | 14 + gui/main.py | 2 + gui/pages/home.py | 312 ++++++------- gui/pages/script.py | 207 ++++----- gui/pages/tools/backups_page.py | 490 +++++++++++++------- gui/pages/tools/multi_token_page.py | 60 ++- gui/pages/tools/notification_logger_page.py | 120 +++++ gui/pages/tools/tools.py | 18 +- utils/defaults.py | 1 + 12 files changed, 891 insertions(+), 596 deletions(-) create mode 100644 gui/pages/tools/notification_logger_page.py diff --git a/bot/bot.py b/bot/bot.py index 93b6b91..e5c95ba 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -145,6 +145,14 @@ async def on_message(self, message): async def on_ready(self): try: self.start_time = time.time() + + is_secondary = getattr(self.controller, '_is_secondary', False) + + if is_secondary: + self.controller.bot_running = True + await self.load_cogs() + return + self.cfg.add_token(self.cfg.get("token"), self.user.name, self.user.id) await self.load_cogs() @@ -168,7 +176,6 @@ async def on_ready(self): if self.session_spoofing: console.success(f"Spoofing session as {self.session_spoofing_device}") - # console.print_warning("Your account is at higher risk of termination by using session spoofer.") await self._setup_scripts() await self.controller.setup_webhooks() @@ -177,6 +184,9 @@ async def on_ready(self): except Exception as e: console.print_error(str(e)) + if is_secondary: + return + cfg_rpc = self.cfg.get_rich_presence() if cfg_rpc.enabled: external_assets = { diff --git a/gui/components/console.py b/gui/components/console.py index 1e9b667..6ae223d 100644 --- a/gui/components/console.py +++ b/gui/components/console.py @@ -138,7 +138,7 @@ def _draw_footer(self, parent): def _draw_main(self, parent): wrapper = RoundedFrame(parent, radius=15, bootstyle="dark.TFrame") - wrapper.pack(side="top", fill="both", expand=True) + wrapper.pack(fill="both", expand=True) self.textarea = ttk.Text(wrapper, wrap="word", height=20, font=("JetBrainsMono NF", self.non_darwin_font_size if sys.platform != "darwin" else self.darwin_font_size, "bold") @@ -162,10 +162,12 @@ def _draw_main(self, parent): self.textarea.pack(fill="both", expand=True, padx=5, pady=5) self._load_tags() + self._wrapper = wrapper def draw(self, parent): self.avatar = self.bot.get_avatar(size=15, radius=2) self._draw_main(parent) + return self._wrapper # if self.bot.running: # self._draw_footer(parent) self.update() \ No newline at end of file diff --git a/gui/components/notification_feed.py b/gui/components/notification_feed.py index b7378d6..8720655 100644 --- a/gui/components/notification_feed.py +++ b/gui/components/notification_feed.py @@ -1,171 +1,154 @@ import sys import time import ttkbootstrap as ttk -import tkinter.font as tkFont from gui.helpers.style import Style -class NotificationEntry: - MAX_ENTRIES = 100 - - def __init__(self, parent, root, event_type, title, description, timestamp=None, color=None): +class Toast: + def __init__(self, parent, root, event_type, title, description="", timestamp=None, color=None, on_dismiss=None): + from utils.config import Config self.parent = parent self.root = root - self.event_type = event_type - self.title = title - self.description = description + self.on_dismiss = on_dismiss + self.color = color or self._get_color(event_type) self.timestamp = timestamp or time.strftime("%H:%M:%S") - self.color = color or self._get_color_for_type(event_type) - self.frame = None - self._draw() - def _get_color_for_type(self, event_type): - colors = { - "friend_add": "#4fee4c", - "friend_remove": "#ff6464", - "relationship_add": "#4fee4c", - "relationship_remove": "#ff6464", - "relationship_update": "#eceb18", - "guild_remove": "#ff6464", - "guild_join": "#4fee4c", - "nitro_snipe": "#0b91ff", - "nitro_snipe_success": "#4fee4c", - "nitro_snipe_fail": "#ff6464", - "privnote_snipe": "#a07cff", - "privnote_snipe_success": "#4fee4c", - "privnote_snipe_fail": "#ff6464", - "info": "#2aefef", - "warning": "#eceb18", - "error": "#ff6464", - "success": "#4fee4c", - } - return colors.get(event_type, Style.LIGHT_GREY.value) + timeout_sec = Config().get("notification_timeout") + self.AUTO_DISMISS_MS = (timeout_sec if isinstance(timeout_sec, (int, float)) and timeout_sec > 0 else 5) * 1000 + + self.frame = ttk.Frame(parent, style="dark.TFrame") + self.frame.pack(fill=ttk.X, padx=4, pady=(0, 4)) - def _draw(self): - self.frame = ttk.Frame(self.parent, style="dark.TFrame") - self.frame.pack(fill=ttk.X, padx=(0, 5), pady=(0, 4)) + card = ttk.Frame(self.frame, style="dark.TFrame") + card.pack(fill=ttk.X) - inner = ttk.Frame(self.frame, style="dark.TFrame") - inner.pack(fill=ttk.BOTH, padx=10, pady=6) + accent = ttk.Canvas(card, width=3, height=3, highlightthickness=0, + background=self.root.style.colors.get("dark")) + accent.pack(side=ttk.LEFT, fill="y", padx=(0, 8)) + accent.create_rectangle(0, 0, 3, 500, fill=self.color, outline=self.color) - top_row = ttk.Frame(inner, style="dark.TFrame") - top_row.pack(fill=ttk.X) + content = ttk.Frame(card, style="dark.TFrame") + content.pack(side=ttk.LEFT, fill=ttk.X, expand=True, pady=6) - indicator = ttk.Canvas(top_row, width=4, height=4, - highlightthickness=0, - background=self.root.style.colors.get("dark")) - indicator.pack(side=ttk.LEFT, padx=(0, 6), pady=(5, 0)) - indicator.create_oval(0, 0, 4, 4, fill=self.color, outline=self.color) + top = ttk.Frame(content, style="dark.TFrame") + top.pack(fill=ttk.X) - type_label = ttk.Label(top_row, text=self.event_type.replace("_", " ").title(), - font=("Host Grotesk", 9, "bold")) - type_label.configure(background=self.root.style.colors.get("dark"), - foreground=self.color) - type_label.pack(side=ttk.LEFT) + type_lbl = ttk.Label(top, text=event_type.replace("_", " ").title(), + font=("Host Grotesk", 9, "bold"), foreground=self.color) + type_lbl.configure(background=self.root.style.colors.get("dark")) + type_lbl.pack(side=ttk.LEFT) - time_label = ttk.Label(top_row, text=self.timestamp, - font=("Host Grotesk", 8)) - time_label.configure(background=self.root.style.colors.get("dark"), - foreground=Style.DARK_GREY.value) - time_label.pack(side=ttk.RIGHT) + time_lbl = ttk.Label(top, text=self.timestamp, font=("Host Grotesk", 8), + foreground=Style.DARK_GREY.value) + time_lbl.configure(background=self.root.style.colors.get("dark")) + time_lbl.pack(side=ttk.RIGHT) - title_label = ttk.Label(inner, text=self.title, - font=("Host Grotesk", 11, "bold"), wraplength=350) - title_label.configure(background=self.root.style.colors.get("dark")) - title_label.pack(anchor=ttk.W, pady=(3, 0)) + title_lbl = ttk.Label(content, text=title, font=("Host Grotesk", 10, "bold")) + title_lbl.configure(background=self.root.style.colors.get("dark")) + title_lbl.pack(anchor=ttk.W) - if self.description: - desc_label = ttk.Label(inner, text=self.description, - font=("Host Grotesk", 10), wraplength=350) - desc_label.configure(background=self.root.style.colors.get("dark"), - foreground=Style.LIGHT_GREY.value) - desc_label.pack(anchor=ttk.W, pady=(1, 0)) + if description: + desc_lbl = ttk.Label(content, text=description, font=("Host Grotesk", 9), + foreground=Style.LIGHT_GREY.value, wraplength=400) + desc_lbl.configure(background=self.root.style.colors.get("dark")) + desc_lbl.pack(anchor=ttk.W) + + dismiss = ttk.Label(card, text="\u00d7", font=("Host Grotesk", 14, "bold"), + cursor="hand2", foreground=Style.DARK_GREY.value) + dismiss.configure(background=self.root.style.colors.get("dark")) + dismiss.pack(side=ttk.RIGHT, padx=8, anchor=ttk.N) + dismiss.bind("", lambda e: self.dismiss()) + dismiss.bind("", lambda e: dismiss.configure(foreground="white")) + dismiss.bind("", lambda e: dismiss.configure(foreground=Style.DARK_GREY.value)) + + self._timer = self.root.after(self.AUTO_DISMISS_MS, self.dismiss) + + def _get_color(self, event_type): + colors = { + "friend_add": "#4fee4c", "friend_remove": "#ff6464", + "relationship_add": "#4fee4c", "relationship_remove": "#ff6464", + "relationship_update": "#eceb18", "guild_remove": "#ff6464", + "guild_join": "#4fee4c", "nitro_snipe": "#0b91ff", + "privnote_snipe": "#a07cff", "info": "#2aefef", + "warning": "#eceb18", "error": "#ff6464", "success": "#4fee4c", + } + return colors.get(event_type, Style.LIGHT_GREY.value) + + def dismiss(self): + try: + if self._timer: + self.root.after_cancel(self._timer) + except Exception: + pass + try: + if self.frame and self.frame.winfo_exists(): + self.frame.destroy() + except Exception: + pass + if self.on_dismiss: + self.on_dismiss(self) class NotificationFeed: - MAX_NOTIFICATIONS = 80 + MAX_VISIBLE = 5 - def __init__(self, root, max_visible=50): + def __init__(self, root): self.root = root - self.canvas = None - self.scroll_frame = None self.wrapper = None - self.notifications = [] - self.max_visible = max_visible + self.toasts = [] + self.history = [] + self._logger_page = None - def _on_mousewheel(self, event): - if self.canvas is None: - return - if sys.platform == 'darwin': - self.canvas.yview_scroll(-1 * int(event.delta), "units") - else: - self.canvas.yview_scroll(-1 * int(event.delta / 120), "units") + def set_logger(self, logger_page): + self._logger_page = logger_page def draw(self, parent): self.wrapper = ttk.Frame(parent, style="dark.TFrame") + return self.wrapper - self.canvas = ttk.Canvas(self.wrapper, - background=self.root.style.colors.get("dark"), - highlightthickness=0) - scrollbar = ttk.Scrollbar(self.wrapper, orient="vertical", command=self.canvas.yview) - self.scroll_frame = ttk.Frame(self.canvas, style="dark.TFrame") - - self.scroll_frame.bind( - "", - lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")) - ) - self.canvas_window = self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw") - - def resize_canvas(event): - self.canvas.itemconfig(self.canvas_window, width=event.width) - self.canvas.bind("", resize_canvas) - - self.canvas.configure(yscrollcommand=scrollbar.set) - self.canvas.pack(side="left", fill="both", expand=True) - scrollbar.pack(side="right", fill="y") + def add_notification(self, event_type, title, description="", timestamp=None, color=None): + ts = timestamp or time.strftime("%H:%M:%S") + clr = color or self._get_color(event_type) - self.canvas.bind("", - lambda e: self.canvas.bind_all("", self._on_mousewheel)) - self.canvas.bind("", - lambda e: self.canvas.unbind_all("")) + self.history.append({ + "event_type": event_type, + "title": title, + "description": description, + "timestamp": ts, + "color": clr, + }) - return self.wrapper + if self._logger_page: + self._logger_page.add_entry(event_type, title, description, timestamp=ts, color=clr) - def add_notification(self, event_type, title, description="", timestamp=None, color=None): - if self.scroll_frame is None: - return - try: - if not self.scroll_frame.winfo_exists(): - return - except Exception: + if not self.wrapper or not self.wrapper.winfo_exists(): return - entry = NotificationEntry( - self.scroll_frame, self.root, - event_type, title, description, - timestamp=timestamp, color=color - ) - self.notifications.append(entry) + toast = Toast(self.wrapper, self.root, event_type, title, description, + timestamp=ts, color=clr, on_dismiss=self._on_dismiss) + self.toasts.append(toast) - if len(self.notifications) > self.MAX_NOTIFICATIONS: - old = self.notifications.pop(0) - if old.frame: - old.frame.destroy() + while len(self.toasts) > self.MAX_VISIBLE: + old = self.toasts.pop(0) + old.dismiss() - self.root.after(50, self._scroll_to_bottom) + def _get_color(self, event_type): + colors = { + "friend_add": "#4fee4c", "friend_remove": "#ff6464", + "relationship_add": "#4fee4c", "relationship_remove": "#ff6464", + "relationship_update": "#eceb18", "guild_remove": "#ff6464", + "guild_join": "#4fee4c", "nitro_snipe": "#0b91ff", + "privnote_snipe": "#a07cff", "info": "#2aefef", + "warning": "#eceb18", "error": "#ff6464", "success": "#4fee4c", + } + return colors.get(event_type, Style.LIGHT_GREY.value) - def clear(self): - if self.scroll_frame is None: - return - for notif in self.notifications: - if notif.frame: - notif.frame.destroy() - self.notifications.clear() + def _on_dismiss(self, toast): + if toast in self.toasts: + self.toasts.remove(toast) - def _scroll_to_bottom(self): - try: - if self.canvas and self.canvas.winfo_exists(): - self.canvas.yview_moveto(1.0) - except Exception: - pass + def clear(self): + for t in self.toasts[:]: + t.dismiss() + self.toasts.clear() diff --git a/gui/components/settings/general.py b/gui/components/settings/general.py index 55abd99..c140bc2 100644 --- a/gui/components/settings/general.py +++ b/gui/components/settings/general.py @@ -59,6 +59,12 @@ def _save_cfg(self): console.warning(f"Could not update startup setting: {err}") except Exception as e: console.error(f"Failed to set startup setting: {e}") + + try: + timeout_val = int(self.notif_timeout_entry.value()) + self.cfg.set("notification_timeout", timeout_val, save=False) + except Exception as e: + console.error(f"Failed to set notification timeout: {e}") self.cfg.save(notify=False) @@ -149,6 +155,14 @@ def draw(self): self.startup_entry.configure(variable=ttk.BooleanVar(value=self.cfg.get("launch_on_startup"))) self.startup_entry.grid(row=2, column=1, sticky=ttk.E, padx=(10, 10), pady=(2, 10)) + notif_timeout_label = ttk.Label(self.body, text="Notification timeout (seconds)") + notif_timeout_label.configure(background=self.root.style.colors.get("dark")) + notif_timeout_label.grid(row=len(self.config_entries) + 4, column=0, sticky=ttk.NW, padx=(10, 0), pady=(5, 10)) + + self.notif_timeout_entry = DropdownMenu(self.body, options=["3", "5", "10", "15", "30", "60"], command=lambda _: self._save_cfg()) + self.notif_timeout_entry.set_selected(str(self.cfg.get("notification_timeout"))) + self.notif_timeout_entry.draw().grid(row=len(self.config_entries) + 4, column=1, sticky="we", padx=(10, 10), pady=(2, 10), columnspan=3) + return self.wrapper def save(self): diff --git a/gui/main.py b/gui/main.py index 1892913..495b2bb 100644 --- a/gui/main.py +++ b/gui/main.py @@ -86,6 +86,7 @@ def __init__(self, bot_controller, update_info=None): 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.home_page.notification_feed.set_logger(self.tools_page.notification_logger_page) self.update_page = UpdatePage(self.root, self) logging.set_gui(self) @@ -347,6 +348,7 @@ def _on_bot_ready(self): self.root.after(50, lambda: self.notifier.send("Ghost", "Ghost has successfully started!")) self.root.after(75, lambda: self.draw_home()) + self.root.after(200, lambda: self.tools_page.multi_token_page.auto_start_tokens()) def _check_bot_started(self): if self.bot_controller.bot_running: diff --git a/gui/pages/home.py b/gui/pages/home.py index 39ec655..48d9715 100644 --- a/gui/pages/home.py +++ b/gui/pages/home.py @@ -1,12 +1,13 @@ -import webbrowser, discord, sys +import sys import ttkbootstrap as ttk import tkinter.font as tkFont -from ttkbootstrap.scrolled import ScrolledFrame + from gui.components import RoundedFrame, RoundedButton from gui.components.notification_feed import NotificationFeed from gui.helpers import Images from gui.helpers.style import Style, get_current_theme_str -from utils.config import VERSION, CHANGELOG, MOTD, Config +from utils.config import VERSION, Config + class HomePage: def __init__(self, root, bot_controller, _restart_bot, console): @@ -14,20 +15,16 @@ def __init__(self, root, bot_controller, _restart_bot, console): self.bot_controller = bot_controller self._restart_bot = _restart_bot self.console = console - self.width = root.winfo_width() - self.height = root.winfo_height() self.restart = False self.avatar = None - self.avatars = {} self.images = Images() self.cfg = Config() - - self.details_wrapper = None + self.friends_label = None self.guilds_label = None self.uptime_label = None self.latency_label = None - + self.restart_title = None self.restart_title_elipsis = "..." self.restart_title_text = "Ghost is restarting" @@ -36,12 +33,6 @@ def __init__(self, root, bot_controller, _restart_bot, console): self._notification_feed_widget = None self._hooks_installed = False - self.root.bind("", self._update_wraplength) - - def _clear_everything(self): - for widget in self.root.winfo_children(): - widget.destroy() - def _update_restart_title(self): try: if self.restart: @@ -53,183 +44,166 @@ def _update_restart_title(self): self.restart_title.after(750, self._update_restart_title) except Exception: pass - - def _update_account_details(self): - try: - if not self.restart: - self.friends_label.config(text=f"Friends: {len(self.bot_controller.get_friends())}") - self.guilds_label.config(text=f"Guilds: {len(self.bot_controller.get_guilds())}") - self.friends_label.after(1000, self._update_account_details) - except Exception: - pass def _update_bot_details(self): try: - if not self.restart: + if not self.restart and self.uptime_label and self.latency_label: self.uptime_label.config(text=f"Uptime: {self.bot_controller.get_uptime()}") self.latency_label.config(text=f"Latency: {self.bot_controller.get_latency()}") - self.uptime_label.after(1000, self._update_bot_details) + if self.uptime_label: + self.uptime_label.after(1000, self._update_bot_details) + except Exception: + pass + + def _update_account_details(self): + try: + if not self.restart and self.friends_label and self.guilds_label: + friends = self.bot_controller.get_friends() + guilds = self.bot_controller.get_guilds() + self.friends_label.config(text=str(len(friends)) if friends else "0") + self.guilds_label.config(text=str(len(guilds)) if guilds else "0") + if self.friends_label: + self.friends_label.after(1000, self._update_account_details) except Exception: pass - - def _draw_restart_button(self, parent, disabled=False): - def _hover_enter(_): - frame.set_background(background=Style.PRIMARY_BTN_HOVER.value) - restart_label.configure(background=Style.PRIMARY_BTN_HOVER.value) - - def _hover_leave(_): - frame.set_background(background=self.root.style.colors.get("primary")) - restart_label.configure(background=self.root.style.colors.get("primary")) - - frame = RoundedFrame(parent, radius=(8, 8, 8, 8), bootstyle="primary.TButton" if not disabled else "disabled.TButton") - - restart_label = ttk.Label(frame, image=self.images.get("restart"), anchor="center") - restart_label.configure(background=self.root.style.colors.get("primary") if not disabled else self.root.style.colors.get("disabled")) - # restart_label.configure(background=self.root.style.colors.get("secondary")) - restart_label.pack(anchor="center", fill=ttk.BOTH, expand=False, padx=25, pady=10) - - if not disabled: - restart_label.bind("", lambda e: self._restart_bot()) - restart_label.bind("", _hover_enter) - restart_label.bind("", _hover_leave) - frame.bind("", lambda e: self._restart_bot()) - frame.bind("", _hover_enter) - frame.bind("", _hover_leave) - - return frame - + def _draw_header(self, parent): wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="secondary.TFrame") - wrapper.pack(fill=ttk.BOTH, expand=False, pady=(0, 10)) - - if self.avatar and not self.restart: - avatar = ttk.Label(wrapper, image=self.avatar) - avatar.configure(background=self.root.style.colors.get("secondary")) - avatar.grid(row=0, column=0, sticky=ttk.W, padx=(15, 10), pady=15, rowspan=2) - - if not self.restart: - display_name = ttk.Label(wrapper, text=self.bot_controller.get_user().display_name, font=("Host Grotesk", 24, "bold")) - display_name.configure(background=self.root.style.colors.get("secondary")) - display_name.grid(row=0, column=1, sticky=ttk.W, pady=(15, 0)) - - username = ttk.Label(wrapper, text=self.bot_controller.get_user().name, font=("Host Grotesk", 14 if sys.platform != "darwin" else 16, "italic")) - username.configure(background=self.root.style.colors.get("secondary"), foreground=Style.LIGHT_GREY.value) - username.grid(row=1, column=1, sticky=ttk.W, pady=(0, 15)) - - # restart_btn = self._draw_restart_button(wrapper) - # restart_btn.grid(row=0, column=3, rowspan=2, sticky=ttk.EW, padx=(10, 16), pady=(10, 10)) - - self.restart_image = self.images.get("restart") - - if get_current_theme_str() == "light": - self.restart_image = self.images.change_image_colour("restart", "#ffffff", tk_image=True) - - restart_btn = RoundedButton(wrapper, radius=20, bootstyle="primary.TButton", command=lambda _: self._restart_bot(), image=self.restart_image, padx=15, pady=6) - restart_btn.grid(row=0, column=3, rowspan=2, sticky=ttk.EW, padx=(10, 16), pady=(10, 10)) - - wrapper.grid_columnconfigure(2, weight=1) - else: - self.restart_title = ttk.Label(wrapper, text=f"{self.restart_title_text}...", font=("Host Grotesk", 24, "bold"), anchor="center") + wrapper.pack(fill=ttk.X, pady=(0, 8)) + + if self.restart: + self.restart_title = ttk.Label( + wrapper, text=f"{self.restart_title_text}...", + font=("Host Grotesk", 20, "bold"), anchor="center" + ) self.restart_title.configure(background=self.root.style.colors.get("secondary")) - self.restart_title.grid(row=0, column=0, sticky=ttk.NSEW, pady=26, padx=15, columnspan=2) - wrapper.grid_columnconfigure(0, weight=1) + self.restart_title.pack(pady=16, padx=15) self.root.after(750, self._update_restart_title) - - def _draw_details_wrapper(self, parent): - wrapper = ttk.Frame(parent, width=self.width) - wrapper.configure(style="default.TLabel") - wrapper.pack(fill=ttk.BOTH, expand=False, pady=(10, 0)) - - wrapper.grid_rowconfigure(0, weight=1) - wrapper.grid_columnconfigure(0, weight=1) - wrapper.grid_columnconfigure(1, weight=1) - - return wrapper - - def _draw_account_details(self, parent): - wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") - wrapper.grid(row=0, column=0, sticky=ttk.NSEW, padx=(0, 5), pady=(0, 5)) - - if self.restart: - return - - title = ttk.Label(wrapper, text="Discord", font=("Host Grotesk", 14 if sys.platform != "darwin" else 18, "bold")) - title.configure(background=self.root.style.colors.get("dark")) - title.grid(row=0, column=0, sticky=ttk.W, padx=10, pady=(10, 0)) - - ttk.Separator(wrapper, orient="horizontal").grid(row=1, column=0, columnspan=2, sticky="we", padx=(10, 10), pady=5) - wrapper.grid_columnconfigure(1, weight=1) - - self.friends_label = ttk.Label(wrapper, text=f"Friends: {len(self.bot_controller.get_friends())}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 13)) - self.friends_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) - self.friends_label.grid(row=2, column=0, sticky=ttk.W, padx=10, pady=(5, 0)) - - self.guilds_label = ttk.Label(wrapper, text=f"Guilds: {len(self.bot_controller.get_guilds())}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 13)) - self.guilds_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) - self.guilds_label.grid(row=3, column=0, sticky=ttk.W, padx=10, pady=(0, 10)) - - def _draw_bot_details(self, parent): - wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") - wrapper.grid(row=0, column=1, sticky=ttk.NSEW, padx=(5, 0), pady=(0, 5)) - - if self.restart: return - - title = ttk.Label(wrapper, text="Ghost", font=("Host Grotesk", 14 if sys.platform != "darwin" else 18, "bold")) - title.configure(background=self.root.style.colors.get("dark")) - title.grid(row=0, column=0, sticky=ttk.W, padx=10, pady=(10, 0)) - - ttk.Separator(wrapper, orient="horizontal").grid(row=1, column=0, columnspan=2, sticky="we", padx=(10, 10), pady=5) - wrapper.grid_columnconfigure(1, weight=1) - - version = ttk.Label(wrapper, text=f"Version: {VERSION}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 13)) - version.configure(background=self.root.style.colors.get("dark")) - version.grid(row=2, column=0, sticky=ttk.W, padx=(10, 0), pady=(5, 0)) - - self.uptime_label = ttk.Label(wrapper, text=f"Uptime: {self.bot_controller.get_uptime()}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 13)) - self.uptime_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) - self.uptime_label.grid(row=3, column=0, sticky=ttk.W, padx=(10, 0)) - - self.latency_label = ttk.Label(wrapper, text=f"Latency: {self.bot_controller.get_latency()}", font=("Host Grotesk", 12 if sys.platform != "darwin" else 13)) - self.latency_label.configure(background=self.root.style.colors.get("dark"), foreground="white" if not self.restart else Style.LIGHT_GREY.value) - self.latency_label.grid(row=4, column=0, sticky=ttk.W, padx=(10, 0), pady=(0, 10)) - + + content = ttk.Frame(wrapper, style="secondary.TFrame") + content.pack(fill=ttk.BOTH, padx=12, pady=8) + + if self.avatar: + avatar_label = ttk.Label(content, image=self.avatar) + avatar_label.configure(background=self.root.style.colors.get("secondary")) + avatar_label.grid(row=0, column=0, rowspan=2, sticky=ttk.W, padx=(0, 10)) + + name_frame = ttk.Frame(content, style="secondary.TFrame") + name_frame.grid(row=0, column=1, sticky=ttk.W) + + display_name = ttk.Label( + name_frame, text=self.bot_controller.get_user().display_name, + font=("Host Grotesk", 18, "bold") + ) + display_name.configure(background=self.root.style.colors.get("secondary")) + display_name.pack(anchor=ttk.W) + + username = ttk.Label( + name_frame, text=self.bot_controller.get_user().name, + font=("Host Grotesk", 11 if sys.platform != "darwin" else 13, "italic") + ) + username.configure(background=self.root.style.colors.get("secondary"), foreground=Style.LIGHT_GREY.value) + username.pack(anchor=ttk.W) + + content.grid_columnconfigure(1, weight=1) + + restart_img = self.images.get("restart") + if get_current_theme_str() == "light": + restart_img = self.images.change_image_colour("restart", "#ffffff", tk_image=True) + restart_btn = RoundedButton( + content, radius=18, bootstyle="primary.TButton", + command=lambda _: self._restart_bot(), image=restart_img, + padx=12, pady=4 + ) + restart_btn.grid(row=0, column=2, rowspan=2, sticky=ttk.E, padx=(8, 0)) + + def _draw_stat_card(self, parent, title, row, col): + card = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="dark.TFrame") + card.grid(row=row, column=col, sticky=ttk.NSEW, padx=(0, 4), pady=(0, 4)) + + inner = ttk.Frame(card, style="dark.TFrame") + inner.pack(fill=ttk.BOTH, padx=10, pady=6) + + header = ttk.Frame(inner, style="dark.TFrame") + header.pack(fill=ttk.X) + + title_lbl = ttk.Label(header, text=title, font=("Host Grotesk", 11, "bold")) + title_lbl.configure(background=self.root.style.colors.get("dark")) + title_lbl.pack(side=ttk.LEFT) + + ttk.Separator(inner, orient="horizontal").pack(fill=ttk.X, pady=(4, 6)) + + return inner + def _draw_details(self, parent): - wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") - wrapper.pack(fill=ttk.BOTH, expand=False, pady=(0, 10)) - - font = ("Host Grotesk", 14) - - version = ttk.Label(wrapper, text=f"Ghost v{VERSION}", font=font) - version.configure(background=self.root.style.colors.get("dark")) - version.grid(row=0, column=0, sticky=ttk.W, padx=(10, 0), pady=10) - - self.uptime_label = ttk.Label(wrapper, text=f"Uptime: {self.bot_controller.get_uptime()}", font=font) + container = ttk.Frame(parent, style="default.TFrame") + container.pack(fill=ttk.X, pady=(0, 8)) + container.grid_columnconfigure(0, weight=1) + container.grid_columnconfigure(1, weight=1) + + discord_card = self._draw_stat_card(container, "Discord", 0, 0) + self.friends_label = ttk.Label( + discord_card, text="0", + font=("Host Grotesk", 18, "bold") + ) + self.friends_label.configure(background=self.root.style.colors.get("dark"), foreground="#4fee4c") + self.friends_label.pack(anchor=ttk.W) + friends_sub = ttk.Label( + discord_card, text="Friends", + font=("Host Grotesk", 9) + ) + friends_sub.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) + friends_sub.pack(anchor=ttk.W) + + ttk.Frame(discord_card, height=4, style="dark.TFrame").pack() + + self.guilds_label = ttk.Label( + discord_card, text="0", + font=("Host Grotesk", 18, "bold") + ) + self.guilds_label.configure(background=self.root.style.colors.get("dark"), foreground="#5865f2") + self.guilds_label.pack(anchor=ttk.W) + guilds_sub = ttk.Label( + discord_card, text="Guilds", + font=("Host Grotesk", 9) + ) + guilds_sub.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) + guilds_sub.pack(anchor=ttk.W) + + ghost_card = self._draw_stat_card(container, "Ghost", 0, 1) + + version_val = ttk.Label(ghost_card, text=f"v{VERSION}", font=("Host Grotesk", 18, "bold")) + version_val.configure(background=self.root.style.colors.get("dark"), foreground="#fee75c") + version_val.pack(anchor=ttk.W) + version_sub = ttk.Label(ghost_card, text="Version", font=("Host Grotesk", 9)) + version_sub.configure(background=self.root.style.colors.get("dark"), foreground=Style.LIGHT_GREY.value) + version_sub.pack(anchor=ttk.W) + + ttk.Frame(ghost_card, height=4, style="dark.TFrame").pack() + + self.uptime_label = ttk.Label(ghost_card, text="Uptime: 0s", font=("Host Grotesk", 11)) self.uptime_label.configure(background=self.root.style.colors.get("dark")) - self.uptime_label.grid(row=0, column=1, sticky=ttk.E, padx=(10, 0), pady=10) - - self.latency_label = ttk.Label(wrapper, text=f"Latency: {self.bot_controller.get_latency()}", font=font) + self.uptime_label.pack(anchor=ttk.W) + + self.latency_label = ttk.Label(ghost_card, text="Latency: 0ms", font=("Host Grotesk", 11)) self.latency_label.configure(background=self.root.style.colors.get("dark")) - self.latency_label.grid(row=0, column=2, sticky=ttk.E, padx=10, pady=10) - - wrapper.grid_columnconfigure(1, weight=1) - - def _update_wraplength(self, event=None): - pass - + self.latency_label.pack(anchor=ttk.W) + def draw(self, parent, restart=False, start=False): self.restart = restart or start self.restart_title_text = "Ghost is starting" if start else "Ghost is restarting" - self.avatar = self.bot_controller.get_avatar(size=55) + self.avatar = self.bot_controller.get_avatar(size=50) if not self.restart else None + self._draw_header(parent) - self._draw_details(parent) - self._update_bot_details() if not self.restart: + self._update_account_details() + self._notification_feed_widget = self.notification_feed.draw(parent) - self._notification_feed_widget.pack(fill=ttk.BOTH, expand=True) + self._notification_feed_widget.pack(fill=ttk.X) self.console.draw(parent) self._setup_event_hooks() @@ -308,7 +282,7 @@ def _add_notification(self, event_type, title, description="", color=None): import time as _time timestamp = _time.strftime("%H:%M:%S") try: - if self.notification_feed.scroll_frame and self.notification_feed.scroll_frame.winfo_exists(): + if self.notification_feed.wrapper and self.notification_feed.wrapper.winfo_exists(): self.root.after(0, lambda: self.notification_feed.add_notification( event_type, title, description, timestamp=timestamp, color=color )) diff --git a/gui/pages/script.py b/gui/pages/script.py index b1dbd78..4516dca 100644 --- a/gui/pages/script.py +++ b/gui/pages/script.py @@ -1,18 +1,12 @@ import sys import os +import tkinter import ttkbootstrap as ttk from ttkbootstrap.dialogs import Messagebox from utils.files import get_application_support from gui.helpers.images import Images -from gui.helpers.style import Style, get_current_theme_str -from gui.components.rounded_frame import RoundedFrame - -try: - from cupcake import Editor, Languages - HAS_CUPCAKE = True -except ImportError: - HAS_CUPCAKE = False +from gui.helpers.style import Style class ScriptPage: @@ -21,14 +15,13 @@ def __init__(self, root, script): self.root = root.root self.script = script self.images = Images() - self.editor = None self.text_widget = None - self.linenumbers = None - self.text_scrollbar = None + self.line_numbers = None self._dirty = False self._script_path = os.path.join(get_application_support(), "scripts", self.script) self._original_content = "" self._title_label = None + self._status_label = None def _go_back(self): if self._dirty: @@ -44,16 +37,16 @@ def _go_back(self): def _get_script_content(self): try: - with open(self._script_path, "r", encoding="utf-8") as file: - return file.read() + with open(self._script_path, "r", encoding="utf-8") as f: + return f.read() except Exception as e: return f"# Error reading script: {e}\n" def _save_script(self): try: - content = self._get_editor_content() - with open(self._script_path, "w", encoding="utf-8") as file: - file.write(content) + content = self.text_widget.get("1.0", "end-1c") if self.text_widget else "" + with open(self._script_path, "w", encoding="utf-8") as f: + f.write(content) self._original_content = content self._dirty = False self._update_title() @@ -70,7 +63,7 @@ def _reload_script(self): if result != "yes": return content = self._get_script_content() - self._set_editor_content(content) + self._set_content(content) self._original_content = content self._dirty = False self._update_title() @@ -86,150 +79,128 @@ def _new_script(self): self.gui.draw_scripts() self.gui.scripts_page._create_script() - def _get_editor_content(self): - if HAS_CUPCAKE and self.editor: - return self.editor.content.get("1.0", "end-1c") - elif self.text_widget: - return self.text_widget.get("1.0", "end-1c") - return "" - - def _set_editor_content(self, content): - if HAS_CUPCAKE and self.editor: - self.editor.content.delete("1.0", "end") - self.editor.content.insert("1.0", content) - elif self.text_widget: + def _set_content(self, content): + if self.text_widget: self.text_widget.delete("1.0", "end") self.text_widget.insert("1.0", content) + self._refresh_line_numbers() - def _on_content_change(self, event=None): - content = self._get_editor_content() + def _on_change(self, event=None): + if not self.text_widget: + return + try: + self.text_widget.edit_modified(False) + except Exception: + pass + content = self.text_widget.get("1.0", "end-1c") self._dirty = content != self._original_content self._update_title() + self.root.after(1, self._refresh_line_numbers) + + def _refresh_line_numbers(self): + if not self.line_numbers or not self.text_widget: + return + try: + self.line_numbers.config(state="normal") + self.line_numbers.delete("1.0", "end") + count = self.text_widget.index("end-1c").split(".")[0] + self.line_numbers.insert("1.0", "\n".join(str(i) for i in range(1, int(count) + 1))) + self.line_numbers.config(state="disabled") + except Exception: + pass + + def _sync_scroll(self, *args): + if self.line_numbers: + self.line_numbers.yview_moveto(args[0]) def _update_title(self): if self._title_label: - dirty_marker = " *" if self._dirty else "" - self._title_label.configure(text=f"{self.script}{dirty_marker}") + self._title_label.configure(text=f"{self.script} {'*' if self._dirty else ''}") def _show_save_status(self, text): - if hasattr(self, "_status_label") and self._status_label: + if self._status_label: self._status_label.configure(text=text) - self.root.after(2000, lambda: self._status_label.configure(text="")) + self.root.after(2000, lambda: self._status_label.configure(text="") if self._status_label.winfo_exists() else None) def _draw_header(self, parent): wrapper = ttk.Frame(parent) - back_button = ttk.Label(wrapper, image=self.images.get("left-chevron")) - back_button.grid(row=0, column=0, sticky=ttk.W, padx=(0, 10)) - back_button.bind("", lambda e: self._go_back()) - back_button.bind("", lambda e: back_button.configure(foreground=Style.LIGHT_GREY.value)) - back_button.bind("", lambda e: back_button.configure(foreground="")) + back = ttk.Label(wrapper, image=self.images.get("left-chevron"), cursor="hand2") + back.grid(row=0, column=0, sticky=ttk.W, padx=(0, 10)) + back.bind("", lambda e: self._go_back()) self._title_label = ttk.Label(wrapper, text=self.script, font=("Host Grotesk", 16, "bold")) self._title_label.grid(row=0, column=1, sticky=ttk.W) - self._status_label = ttk.Label(wrapper, text="", font=("Host Grotesk", 11)) - self._status_label.configure(foreground="#4fee4c") + self._status_label = ttk.Label(wrapper, text="", font=("Host Grotesk", 11), foreground="#4fee4c") self._status_label.grid(row=0, column=2, sticky=ttk.W, padx=(10, 0)) - toolbar = ttk.Frame(wrapper, style="dark.TFrame") - - save_btn = self._make_tool_button(toolbar, "Save", self._save_script) - save_btn.grid(row=0, column=0, padx=(0, 5)) - - reload_btn = self._make_tool_button(toolbar, "Reload", self._reload_script) - reload_btn.grid(row=0, column=1, padx=(0, 5)) - - new_btn = self._make_tool_button(toolbar, "New", self._new_script) - new_btn.grid(row=0, column=2) - + toolbar = ttk.Frame(wrapper) + for i, (label, cmd) in enumerate([("Save", self._save_script), ("Reload", self._reload_script), ("New", self._new_script)]): + btn = ttk.Label(toolbar, text=label, font=("Host Grotesk", 11), style="secondary.TLabel", cursor="hand2") + btn.configure(foreground=Style.LIGHT_GREY.value) + btn.bind("", lambda e, c=cmd: c()) + btn.bind("", lambda e, b=btn: b.configure(foreground="white")) + btn.bind("", lambda e, b=btn: b.configure(foreground=Style.LIGHT_GREY.value)) + btn.grid(row=0, column=i, padx=(0, 8) if i < 2 else 0) toolbar.grid(row=0, column=3, sticky=ttk.E, padx=(15, 0)) - wrapper.columnconfigure(2, weight=1) - return wrapper - def _make_tool_button(self, parent, text, command): - btn = ttk.Label( - parent, - text=text, - font=("Host Grotesk", 11), - style="secondary.TLabel", - cursor="hand2" - ) - btn.configure(foreground=Style.LIGHT_GREY.value) - btn.bind("", lambda e: command()) - btn.bind("", lambda e: btn.configure(foreground="white")) - btn.bind("", lambda e: btn.configure(foreground=Style.LIGHT_GREY.value)) - return btn - def draw(self, parent): header = self._draw_header(parent) header.pack(fill=ttk.X, pady=(0, 10)) - editor_wrapper = RoundedFrame(parent, radius=(15, 15, 15, 15), bootstyle="dark.TFrame") - editor_wrapper.pack(fill=ttk.BOTH, expand=True) - - if HAS_CUPCAKE: - self.editor = Editor( - editor_wrapper.inner_frame, - language=Languages.PYTHON, - darkmode=True, - font=("JetBrainsMono NF Regular", 10 if sys.platform != "darwin" else 12), - path=self._script_path, - showpath=False - ) - self.editor.pack(fill=ttk.BOTH, expand=True) - self.editor.content.configure(state="normal") - self._original_content = self.editor.content.get("1.0", "end-1c") - self.editor.content.bind("<>", self._on_content_change) - self.editor.content.bind("", lambda e: self.root.after(50, self._on_content_change)) - self.editor.content.focus_set() - else: - self._draw_fallback_editor(editor_wrapper.inner_frame) - - def _draw_fallback_editor(self, parent): content = self._get_script_content() - - self.text_widget = ttk.Text( - parent, - wrap="none", - font=("JetBrainsMono NF", 10 if sys.platform != "darwin" else 12), - undo=True, - autoseparators=True, - maxundo=-1, + bg = self.root.style.colors.get("dark") + fg = Style.LIGHT_GREY.value + font_size = 10 if sys.platform != "darwin" else 12 + mono = ("JetBrainsMono NF", font_size) + + gutter_bg = "#181820" + shared_opts = dict( + font=mono, bd=0, highlightthickness=0, wrap="none", + spacing1=0, spacing2=0, spacing3=0, padx=0, pady=0, ) - self.text_widget.configure( - border=0, - background=self.root.style.colors.get("dark"), - foreground=Style.LIGHT_GREY.value, - insertbackground="white", - highlightcolor=self.root.style.colors.get("dark"), - highlightbackground=self.root.style.colors.get("dark"), - selectbackground="#3d3d5c", + + self.line_numbers = tkinter.Text( + parent, width=4, state="disabled", cursor="arrow", + bg=gutter_bg, fg="#4a4a5e", + selectbackground=gutter_bg, takefocus=False, + **shared_opts, ) + self.line_numbers.pack(side="left", fill="y", pady=5) + self.line_numbers.config(padx=8) - v_scroll = ttk.Scrollbar(parent, orient="vertical", command=self.text_widget.yview) - h_scroll = ttk.Scrollbar(parent, orient="horizontal", command=self.text_widget.xview) - self.text_widget.configure(yscrollcommand=v_scroll.set, xscrollcommand=h_scroll.set) + sep = ttk.Frame(parent, width=1) + sep.pack(side="left", fill="y", pady=5) - self.text_widget.grid(row=0, column=0, sticky="nsew", padx=(5, 0), pady=5) - v_scroll.grid(row=0, column=1, sticky="ns") - h_scroll.grid(row=1, column=0, sticky="ew") + self.text_widget = tkinter.Text( + parent, undo=True, autoseparators=True, maxundo=-1, + bg=bg, fg=fg, insertbackground="white", + selectbackground="#3d3d5c", + **shared_opts, + ) + self.text_widget.pack(side="left", fill="both", expand=True, padx=(0, 5), pady=5) + self.text_widget.config(padx=8) - parent.grid_rowconfigure(0, weight=1) - parent.grid_columnconfigure(0, weight=1) + v_scroll = ttk.Scrollbar(parent, orient="vertical", + command=lambda *a: (self.text_widget.yview(*a), self.line_numbers.yview(*a))) + v_scroll.pack(side="right", fill="y", pady=5) + self.text_widget.config(yscrollcommand=lambda *a: (v_scroll.set(*a), self.line_numbers.yview_moveto(a[0]))) self.text_widget.insert("1.0", content) self.text_widget.mark_set("insert", "1.0") self.text_widget.edit_reset() self.text_widget.edit_modified(False) self._original_content = content + self._refresh_line_numbers() - self.text_widget.bind("<>", self._on_content_change) - self.text_widget.bind("", lambda e: self.root.after(50, self._on_content_change)) - + self.text_widget.bind("<>", self._on_change) + self.text_widget.bind("", lambda e: self.root.after(50, self._on_change)) + self.text_widget.bind("", lambda e: self.root.after(1, self._refresh_line_numbers)) self.text_widget.bind("", lambda e: self._save_script()) self.text_widget.bind("", lambda e: self._reload_script()) + parent.bind("", lambda e: self.text_widget.focus_set()) self.text_widget.focus_set() diff --git a/gui/pages/tools/backups_page.py b/gui/pages/tools/backups_page.py index 753bd31..014bfd0 100644 --- a/gui/pages/tools/backups_page.py +++ b/gui/pages/tools/backups_page.py @@ -1,17 +1,16 @@ import os -import sys +import json import time -import shutil -import zipfile +import asyncio import threading +import discord import ttkbootstrap as ttk from ttkbootstrap.dialogs import Messagebox from ttkbootstrap.scrolled import ScrolledFrame -from gui.components import ToolPage, RoundedFrame, RoundedSwitch +from gui.components import ToolPage, RoundedFrame from gui.helpers import Style -from gui.helpers.images import Images -from utils.files import get_application_support, get_config_path, get_scripts_path, get_themes_path, get_data_path +from utils.files import get_application_support class BackupsPage(ToolPage): @@ -21,9 +20,7 @@ def __init__(self, toolspage, root, bot_controller, images, layout): self.cfg = bot_controller.cfg self.backups_dir = os.path.join(get_application_support(), "backups") self.backup_list_frame = None - self._backup_frames = [] self.status_label = None - self._progress_label = None def _ensure_backups_dir(self): os.makedirs(self.backups_dir, exist_ok=True) @@ -32,236 +29,407 @@ def _get_backups(self): self._ensure_backups_dir() backups = [] for f in os.listdir(self.backups_dir): - if f.endswith(".zip"): + if f.endswith(".json"): path = os.path.join(self.backups_dir, f) - stat = os.stat(path) - backups.append({ - "name": f, - "path": path, - "size": stat.st_size, - "time": stat.st_mtime, - }) + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + stat = os.stat(path) + backups.append({ + "name": f, + "path": path, + "type": data.get("type", f.replace(".json", "")), + "created_at": data.get("created_at"), + "size": stat.st_size, + "time": stat.st_mtime, + "data": data, + }) + except Exception: + pass backups.sort(key=lambda b: b["time"], reverse=True) return backups - def _format_size(self, size_bytes): - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" - def _format_time(self, timestamp): - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + if timestamp: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + return "Unknown" - def _create_backup(self): - self._ensure_backups_dir() - timestamp = time.strftime("%Y%m%d_%H%M%S") - backup_name = f"ghost_backup_{timestamp}.zip" - backup_path = os.path.join(self.backups_dir, backup_name) + def _set_status(self, text): + if self.status_label: + self.status_label.configure(text=text) + + def _run_async(self, coro): + def _worker(): + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(coro) + except Exception as e: + self.root.after(0, lambda: self._set_status(f"Error: {e}")) + finally: + loop.close() + threading.Thread(target=_worker, daemon=True).start() + + def _get_available_bots(self): + bots = [] + main_bot = getattr(self.bot_controller, "bot", None) + if main_bot and main_bot.is_ready(): + bots.append({ + "label": f"Main — {main_bot.user.name}", + "bot": main_bot, + "token": self.cfg.get("token"), + }) + if hasattr(self, 'toolspage') and hasattr(self.toolspage, 'multi_token_page'): + for session in self.toolspage.multi_token_page.sessions: + if session.running and session.bot and session.bot.is_ready(): + bots.append({ + "label": f"Multi — {session.bot.user.name}", + "bot": session.bot, + "token": session.token, + }) + return bots + + def _pick_account(self, backup_type, callback): + bots = self._get_available_bots() + if not bots: + self._set_status("No bots connected. Start the bot first.") + return + if len(bots) == 1: + callback(bots[0]["bot"], bots[0]["token"]) + return + + dialog = ttk.Toplevel(self.root) + dialog.title(f"Backup {backup_type.title()}") + dialog.geometry("350x250") + dialog.resizable(False, False) + dialog.configure(background=self.root.style.colors.get("dark")) + dialog.transient(self.root) - self._set_status("Creating backup...") + wrapper = ttk.Frame(dialog, style="dark.TFrame") + wrapper.pack(fill=ttk.BOTH, expand=True, padx=15, pady=15) - def _do_backup(): + title = ttk.Label(wrapper, text=f"Select account to backup {backup_type}:", + font=("Host Grotesk", 11, "bold")) + title.configure(background=self.root.style.colors.get("dark"), foreground="white") + title.pack(anchor=ttk.W, pady=(0, 10)) + + for bot_info in bots: + card = RoundedFrame(wrapper, radius=(8, 8, 8, 8), bootstyle="dark.TFrame") + card.pack(fill=ttk.X, pady=(0, 5)) + + lbl = ttk.Label(card, text=bot_info["label"], + font=("Host Grotesk", 11), cursor="hand2", + foreground="#5865f2") + lbl.configure(background=self.root.style.colors.get("dark")) + lbl.pack(padx=12, pady=8, anchor=ttk.W) + lbl.bind("", lambda e, b=bot_info["bot"], t=bot_info["token"], d=dialog: ( + d.destroy(), callback(b, t))) + lbl.bind("", lambda e, l=lbl: l.configure(foreground="white")) + lbl.bind("", lambda e, l=lbl: l.configure(foreground="#5865f2")) + + cancel = ttk.Label(wrapper, text="Cancel", font=("Host Grotesk", 10), + cursor="hand2", foreground=Style.LIGHT_GREY.value) + cancel.configure(background=self.root.style.colors.get("dark")) + cancel.pack(anchor=ttk.W, pady=(5, 0)) + cancel.bind("", lambda e: dialog.destroy()) + cancel.bind("", lambda e: cancel.configure(foreground="white")) + cancel.bind("", lambda e: cancel.configure(foreground=Style.LIGHT_GREY.value)) + + def _create_backup_account(self): + self._pick_account("account", self._do_backup_account) + + def _do_backup_account(self, bot, token): + self._set_status("Backing up account...") + async def _do(): try: - with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zf: - config_path = get_config_path() - if os.path.exists(config_path): - zf.write(config_path, "config.json") - - scripts_dir = get_scripts_path() - if os.path.exists(scripts_dir): - for root_dir, dirs, files in os.walk(scripts_dir): - for file in files: - file_path = os.path.join(root_dir, file) - arcname = os.path.relpath(file_path, get_application_support()) - zf.write(file_path, arcname) - - themes_dir = get_themes_path() - if os.path.exists(themes_dir): - for root_dir, dirs, files in os.walk(themes_dir): - for file in files: - file_path = os.path.join(root_dir, file) - arcname = os.path.relpath(file_path, get_application_support()) - zf.write(file_path, arcname) - - data_dir = get_data_path() - if os.path.exists(data_dir): - for item in os.listdir(data_dir): - if item in ("cache", "surveillance"): - continue - item_path = os.path.join(data_dir, item) - if os.path.isfile(item_path): - arcname = os.path.relpath(item_path, get_application_support()) - zf.write(item_path, arcname) - - self.root.after(0, lambda: self._set_status(f"Backup created: {backup_name}")) + user = bot.user + data = { + "created_at": time.time(), + "type": "account", + "info": { + "id": user.id, + "name": user.name, + "display_name": user.display_name, + "accent_colour": str(user.accent_colour) if user.accent_colour else None, + "avatar": str(user.avatar.url) if user.avatar else None, + "banner": str(user.banner.url) if user.banner else None, + "bio": user.bio or None, + }, + } + self._ensure_backups_dir() + name = user.name.lower().replace(" ", "_") + path = os.path.join(self.backups_dir, f"account_{name}.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + self.root.after(0, lambda: self._set_status(f"Account backed up ({user.name})")) self.root.after(0, self._refresh_backup_list) except Exception as e: - self.root.after(0, lambda: self._set_status(f"Backup failed: {e}")) + self.root.after(0, lambda: self._set_status(f"Failed: {e}")) + self._run_async(_do()) - threading.Thread(target=_do_backup, daemon=True).start() - - def _restore_backup(self, backup_info): - result = str(Messagebox.yesno( - f"Restoring '{backup_info['name']}' will replace your current configuration, scripts, and themes.\n\n" - "A safety backup of your current data will be created first.\n\n" - "Do you want to continue?", - title="Restore Backup" - )).lower() + def _create_backup_friends(self): + self._pick_account("friends", self._do_backup_friends) - if result != "yes": - return + def _do_backup_friends(self, bot, token): + self._set_status("Backing up friends...") + async def _do(): + try: + friends = [] + for relationship in bot.friends: + if relationship.type == 1: + friends.append({"username": relationship.user.name, "id": relationship.user.id}) + data = { + "created_at": time.time(), + "type": "friends", + "list": friends, + } + self._ensure_backups_dir() + name = bot.user.name.lower().replace(" ", "_") + path = os.path.join(self.backups_dir, f"friends_{name}.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + self.root.after(0, lambda: self._set_status(f"Friends backed up ({len(friends)} friends)")) + self.root.after(0, self._refresh_backup_list) + except Exception as e: + self.root.after(0, lambda: self._set_status(f"Failed: {e}")) + self._run_async(_do()) - self._set_status("Creating safety backup before restore...") + def _create_backup_guilds(self): + self._pick_account("guilds", self._do_backup_guilds) - def _do_restore(): + def _do_backup_guilds(self, bot, token): + self._set_status("Backing up guilds...") + async def _do(): try: - timestamp = time.strftime("%Y%m%d_%H%M%S") - safety_name = f"ghost_pre_restore_{timestamp}.zip" - safety_path = os.path.join(self.backups_dir, safety_name) - with zipfile.ZipFile(safety_path, 'w', zipfile.ZIP_DEFLATED) as zf: - config_path = get_config_path() - if os.path.exists(config_path): - zf.write(config_path, "config.json") - scripts_dir = get_scripts_path() - if os.path.exists(scripts_dir): - for root_dir, dirs, files in os.walk(scripts_dir): - for file in files: - file_path = os.path.join(root_dir, file) - arcname = os.path.relpath(file_path, get_application_support()) - zf.write(file_path, arcname) - themes_dir = get_themes_path() - if os.path.exists(themes_dir): - for root_dir, dirs, files in os.walk(themes_dir): - for file in files: - file_path = os.path.join(root_dir, file) - arcname = os.path.relpath(file_path, get_application_support()) - zf.write(file_path, arcname) - - with zipfile.ZipFile(backup_info['path'], 'r') as zf: - zf.extractall(get_application_support()) - - self.root.after(0, lambda: self._set_status(f"Restored: {backup_info['name']}")) + guilds = [] + for guild in bot.guilds: + invite = None + try: + invite = guild.vanity_url + except Exception: + pass + if not invite: + for channel in guild.channels: + if isinstance(channel, discord.TextChannel) and channel.permissions_for(guild.me).create_instant_invite: + try: + invite = await channel.create_invite(max_age=0, max_uses=1, unique=True) + except Exception: + break + if invite: + break + guilds.append({"name": guild.name, "id": guild.id, "invite": str(invite) if invite else "None"}) + await asyncio.sleep(0.75) + data = { + "created_at": time.time(), + "type": "guilds", + "list": guilds, + } + self._ensure_backups_dir() + name = bot.user.name.lower().replace(" ", "_") + path = os.path.join(self.backups_dir, f"guilds_{name}.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + self.root.after(0, lambda: self._set_status(f"Guilds backed up ({len(guilds)} servers)")) self.root.after(0, self._refresh_backup_list) except Exception as e: - self.root.after(0, lambda: self._set_status(f"Restore failed: {e}")) + self.root.after(0, lambda: self._set_status(f"Failed: {e}")) + self._run_async(_do()) + + def _view_backup(self, backup_info): + data = backup_info["data"] + display = json.dumps(data, indent=2) + win = ttk.Toplevel(self.root) + win.title(f"Backup - {backup_info['name']}") + win.geometry("500x400") + win.configure(bg=self.root.style.colors.get("bg")) + text = ttk.ScrolledText(win, wrap=ttk.WORD, font=("Consolas", 10)) + text.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) + text.insert("1.0", display) + text.configure(state="disabled") - threading.Thread(target=_do_restore, daemon=True).start() + def _restore_backup(self, backup_info): + btype = backup_info["type"] + if btype == "account": + Messagebox.show_info("Account backups are for reference only.\nNo restore action needed.", title="Restore") + return + if btype == "friends": + result = str(Messagebox.yesno( + "This will send friend requests to all users in the backup.\n\n" + "This could result in a ban. Continue?", + title="Restore Friends" + )).lower() + if result != "yes": + return + self._pick_account("friends", lambda bot, token: self._do_restore_friends(backup_info, bot, token)) + return + elif btype == "guilds": + invites = [g["invite"] for g in backup_info["data"].get("list", []) if g.get("invite") and g["invite"] != "None"] + if not invites: + Messagebox.show_info("No invite links found in this backup.", title="Restore Guilds") + return + msg = "Join these servers manually:\n\n" + "\n".join(invites) + win = ttk.Toplevel(self.root) + win.title("Guild Invites") + win.geometry("500x350") + win.configure(bg=self.root.style.colors.get("bg")) + text = ttk.ScrolledText(win, wrap=ttk.WORD, font=("Consolas", 10)) + text.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) + text.insert("1.0", msg) + text.configure(state="disabled") + + def _do_restore_friends(self, backup_info, bot, token): + self._set_status("Restoring friends...") + async def _do(): + try: + import requests as _req + headers = { + "Authorization": token, + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + restored = 0 + for entry in backup_info["data"].get("list", []): + try: + user_resp = _req.get(f"https://discord.com/api/v9/users/{entry['id']}", headers=headers) + if user_resp.status_code == 200: + user = discord.User(state=bot._connection, data=user_resp.json()) + user.send_friend_request() + restored += 1 + await asyncio.sleep(1) + except Exception: + pass + self.root.after(0, lambda: self._set_status(f"Restored {restored} friend requests")) + except Exception as e: + self.root.after(0, lambda: self._set_status(f"Failed: {e}")) + self._run_async(_do()) def _delete_backup(self, backup_info): result = str(Messagebox.yesno( f"Delete backup '{backup_info['name']}'?", title="Delete Backup" )).lower() - if result != "yes": return - try: - os.remove(backup_info['path']) + os.remove(backup_info["path"]) self._set_status(f"Deleted: {backup_info['name']}") self._refresh_backup_list() except Exception as e: self._set_status(f"Delete failed: {e}") - def _set_status(self, text): - if self.status_label: - self.status_label.configure(text=text) - def _refresh_backup_list(self): + if not self.backup_list_frame: + return for child in self.backup_list_frame.winfo_children(): child.destroy() - for frame_info in self._backup_frames: - if frame_info["frame"].winfo_exists(): - frame_info["frame"].destroy() - self._backup_frames.clear() self._draw_backup_entries() def _draw_backup_entries(self): backups = self._get_backups() - if not backups: - empty_label = ttk.Label(self.backup_list_frame, - text="No backups yet. Create one above.", - font=("Host Grotesk", 11)) - empty_label.configure(background=self.root.style.colors.get("dark"), - foreground=Style.DARK_GREY.value) - empty_label.pack(pady=20) + empty = ttk.Label(self.backup_list_frame, + text="No backups yet. Create one above.", + font=("Host Grotesk", 11)) + empty.configure(background=self.root.style.colors.get("dark"), + foreground=Style.DARK_GREY.value) + empty.pack(pady=20) return + type_colors = {"account": "#5865f2", "friends": "#4fee4c", "guilds": "#fee75c"} for backup in backups: card = RoundedFrame(self.backup_list_frame, radius=(8, 8, 8, 8), - bootstyle="dark.TFrame") + bootstyle="dark.TFrame") card.pack(fill=ttk.X, pady=(0, 5)) inner = ttk.Frame(card, style="dark.TFrame") inner.pack(fill=ttk.BOTH, padx=12, pady=8) - name_label = ttk.Label(inner, text=backup["name"], + name_frame = ttk.Frame(inner, style="dark.TFrame") + name_frame.grid(row=0, column=0, sticky=ttk.W) + + btype = backup.get("type", "unknown") + color = type_colors.get(btype, Style.LIGHT_GREY.value) + type_label = ttk.Label(name_frame, text=btype.upper(), + font=("Host Grotesk", 9, "bold"), + foreground=color) + type_label.configure(background=self.root.style.colors.get("dark")) + type_label.pack(side=ttk.LEFT, padx=(0, 8)) + + name_label = ttk.Label(name_frame, text=backup["name"], font=("Host Grotesk", 11, "bold")) name_label.configure(background=self.root.style.colors.get("dark")) - name_label.grid(row=0, column=0, sticky=ttk.W) + name_label.pack(side=ttk.LEFT) - info_text = f"{self._format_size(backup['size'])} | {self._format_time(backup['time'])}" + info_text = self._format_time(backup["created_at"]) info_label = ttk.Label(inner, text=info_text, font=("Host Grotesk", 9)) info_label.configure(background=self.root.style.colors.get("dark"), - foreground=Style.LIGHT_GREY.value) + foreground=Style.LIGHT_GREY.value) info_label.grid(row=1, column=0, sticky=ttk.W) btn_frame = ttk.Frame(inner, style="dark.TFrame") btn_frame.grid(row=0, column=1, rowspan=2, sticky=ttk.E) + view_btn = ttk.Label(btn_frame, text="View", + font=("Host Grotesk", 10), + cursor="hand2", foreground="#5865f2") + view_btn.pack(side=ttk.LEFT, padx=(0, 10)) + view_btn.bind("", lambda e, b=backup: self._view_backup(b)) + view_btn.bind("", lambda e, b=view_btn: b.configure(foreground="white")) + view_btn.bind("", lambda e, b=view_btn: b.configure(foreground="#5865f2")) + restore_btn = ttk.Label(btn_frame, text="Restore", font=("Host Grotesk", 10, "bold"), - cursor="hand2") - restore_btn.configure(foreground="#4fee4c") + cursor="hand2", foreground="#4fee4c") restore_btn.pack(side=ttk.LEFT, padx=(0, 10)) restore_btn.bind("", lambda e, b=backup: self._restore_backup(b)) - restore_btn.bind("", lambda e: restore_btn.configure(foreground="white")) - restore_btn.bind("", lambda e: restore_btn.configure(foreground="#4fee4c")) + restore_btn.bind("", lambda e, b=restore_btn: b.configure(foreground="white")) + restore_btn.bind("", lambda e, b=restore_btn: b.configure(foreground="#4fee4c")) delete_btn = ttk.Label(btn_frame, text="Delete", font=("Host Grotesk", 10), - cursor="hand2") - delete_btn.configure(foreground="#ff6464") + cursor="hand2", foreground="#ff6464") delete_btn.pack(side=ttk.LEFT) delete_btn.bind("", lambda e, b=backup: self._delete_backup(b)) - delete_btn.bind("", lambda e: delete_btn.configure(foreground="white")) - delete_btn.bind("", lambda e: delete_btn.configure(foreground="#ff6464")) + delete_btn.bind("", lambda e, b=delete_btn: b.configure(foreground="white")) + delete_btn.bind("", lambda e, b=delete_btn: b.configure(foreground="#ff6464")) inner.grid_columnconfigure(0, weight=1) - self._backup_frames.append({"frame": card, "backup": backup}) - def draw_content(self, wrapper): - create_btn_frame = RoundedFrame(wrapper, radius=(10, 10, 10, 10), - bootstyle="primary.TFrame") - create_btn_frame.pack(fill=ttk.X, pady=(0, 10)) - - create_inner = ttk.Frame(create_btn_frame, style="primary.TFrame") - create_inner.pack(fill=ttk.BOTH, padx=12, pady=8) - - create_label = ttk.Label(create_inner, text="Create New Backup", - font=("Host Grotesk", 12, "bold"), - cursor="hand2") - create_label.configure(background=self.root.style.colors.get("primary")) - create_label.pack(side=ttk.LEFT) - create_label.bind("", lambda e: self._create_backup()) - create_btn_frame.bind("", lambda e: self._create_backup()) - - self.status_label = ttk.Label(create_inner, text="", + create_label = ttk.Label(wrapper, text="Create Backup", + font=("Host Grotesk", 14, "bold")) + create_label.configure(background=self.root.style.colors.get("dark")) + create_label.pack(anchor=ttk.W, pady=(0, 5)) + + btn_row = ttk.Frame(wrapper, style="dark.TFrame") + btn_row.pack(fill=ttk.X, pady=(0, 10)) + + for text, cmd in [("Account", self._create_backup_account), + ("Friends", self._create_backup_friends), + ("Guilds", self._create_backup_guilds)]: + btn_frame = RoundedFrame(btn_row, radius=(8, 8, 8, 8), + bootstyle="dark.TFrame") + btn_frame.pack(side=ttk.LEFT, padx=(0, 8)) + + btn = ttk.Label(btn_frame, text=text, + font=("Host Grotesk", 10, "bold"), + cursor="hand2", foreground="#5865f2") + btn.configure(background=self.root.style.colors.get("dark")) + btn.pack(padx=12, pady=6) + btn.bind("", lambda e, c=cmd: c()) + btn.bind("", lambda e, b=btn: b.configure(foreground="white")) + btn.bind("", lambda e, b=btn: b.configure(foreground="#5865f2")) + + self.status_label = ttk.Label(btn_row, text="", font=("Host Grotesk", 10)) - self.status_label.configure(background=self.root.style.colors.get("primary"), - foreground="#ffffff") + self.status_label.configure(background=self.root.style.colors.get("dark"), + foreground=Style.LIGHT_GREY.value) self.status_label.pack(side=ttk.RIGHT) - backup_label = ttk.Label(wrapper, text="Available Backups", - font=("Host Grotesk", 14, "bold")) - backup_label.configure(background=self.root.style.colors.get("dark")) - backup_label.pack(anchor=ttk.W, pady=(5, 5)) + backups_label = ttk.Label(wrapper, text="Available Backups", + font=("Host Grotesk", 14, "bold")) + backups_label.configure(background=self.root.style.colors.get("dark")) + backups_label.pack(anchor=ttk.W, pady=(5, 5)) self.backup_list_frame = ScrolledFrame(wrapper, bootstyle="dark.TFrame", autohide=True) self.backup_list_frame.container.configure(style="dark.TFrame") diff --git a/gui/pages/tools/multi_token_page.py b/gui/pages/tools/multi_token_page.py index 353250e..013a64a 100644 --- a/gui/pages/tools/multi_token_page.py +++ b/gui/pages/tools/multi_token_page.py @@ -49,11 +49,14 @@ def start(self): from bot.controller import BotController controller = BotController() - controller.cfg.set("token", self.token) + controller._is_secondary = True + controller.bot_running = False controller.loop = self.loop controller.running = True + self._controller = controller self.bot = Ghost(controller) + controller.bot = self.bot async def _on_ready(): self.start_time = time.time() @@ -82,6 +85,9 @@ def stop(self): async def shutdown(): try: + if hasattr(self.bot, "telemetry_loop") and self.bot.telemetry_loop.is_running(): + self.bot.telemetry_loop.cancel() + self.bot._stop_rich_presence() await self.bot.close() except Exception: pass @@ -138,18 +144,9 @@ def _add_token(self): dialog = ttk.Toplevel(self.root) dialog.title("Add Token") dialog.geometry("450x200") + dialog.resizable(False, False) dialog.configure(background=self.root.style.colors.get("dark")) dialog.transient(self.root) - dialog.grab_set() - - if sys.platform == "win32": - try: - import hPyT - hPyT.title_bar.hide(dialog, no_span=True) - hPyT.corner_radius.set(dialog, style="round") - hPyT.window_dwm.toggle_dwm_transitions(dialog, enabled=True) - except Exception: - pass wrapper = ttk.Frame(dialog, style="dark.TFrame") wrapper.pack(fill=ttk.BOTH, expand=True, padx=20, pady=20) @@ -161,7 +158,6 @@ def _add_token(self): name_var = ttk.StringVar(value="Account 1") name_entry = ttk.Entry(wrapper, textvariable=name_var, font=("Host Grotesk", 11)) - name_entry.configure(foreground=self.root.style.colors.get("fg")) name_entry.pack(fill=ttk.X, pady=(0, 10)) token_label = ttk.Label(wrapper, text="Token", font=("Host Grotesk", 11)) @@ -171,7 +167,6 @@ def _add_token(self): token_var = ttk.StringVar() token_entry = ttk.Entry(wrapper, textvariable=token_var, font=("Host Grotesk", 11), show="*") - token_entry.configure(foreground=self.root.style.colors.get("fg")) token_entry.pack(fill=ttk.X, pady=(0, 10)) btn_frame = ttk.Frame(wrapper, style="dark.TFrame") @@ -198,6 +193,8 @@ def confirm(): background=self.root.style.colors.get("dark")) cancel_btn.pack(side=ttk.LEFT) cancel_btn.bind("", lambda e: dialog.destroy()) + cancel_btn.bind("", lambda e: cancel_btn.configure(foreground="white")) + cancel_btn.bind("", lambda e: cancel_btn.configure(foreground=Style.LIGHT_GREY.value)) add_btn = ttk.Label(btn_frame, text="Add", font=("Host Grotesk", 11, "bold"), cursor="hand2") @@ -205,8 +202,13 @@ def confirm(): background=self.root.style.colors.get("dark")) add_btn.pack(side=ttk.RIGHT) add_btn.bind("", lambda e: confirm()) + add_btn.bind("", lambda e: add_btn.configure(foreground="white")) + add_btn.bind("", lambda e: add_btn.configure(foreground="#4fee4c")) token_entry.bind("", lambda e: confirm()) + dialog.grab_set() + name_entry.focus_set() + def _toggle_token(self, index): tokens = self._get_tokens() if index >= len(tokens): @@ -228,6 +230,27 @@ def _toggle_token(self, index): self._save_tokens(tokens) self.root.after(500, self._refresh_list) + def _toggle_start_on_launch(self, index): + tokens = self._get_tokens() + if index >= len(tokens): + return + tokens[index]["start_on_launch"] = not tokens[index].get("start_on_launch", False) + self._save_tokens(tokens) + self._refresh_list() + + def auto_start_tokens(self): + tokens = self._get_tokens() + for token_data in tokens: + token_data["enabled"] = False + self._save_tokens(tokens) + for token_data in tokens: + if token_data.get("start_on_launch", False): + session = TokenInstance(token_data, on_status_change=lambda s: self.root.after(0, self._refresh_list)) + self.sessions.append(session) + session.start() + token_data["enabled"] = True + self._save_tokens(tokens) + def _remove_token(self, index): tokens = self._get_tokens() if index >= len(tokens): @@ -274,6 +297,7 @@ def _draw_token_card(self, parent, token_data, index): status = session.get_status() if session else "stopped" username = session.get_username() if session else token_data.get("name", "Unknown") uptime = session.get_uptime() if session else "--" + start_on_launch = token_data.get("start_on_launch", False) card = RoundedFrame(parent, radius=(10, 10, 10, 10), bootstyle="dark.TFrame") card.pack(fill=ttk.X, pady=(0, 5)) @@ -304,6 +328,16 @@ def _draw_token_card(self, parent, token_data, index): btn_frame = ttk.Frame(inner, style="dark.TFrame") btn_frame.grid(row=0, column=2, rowspan=2, sticky=ttk.E) + launch_text = "Auto" if start_on_launch else "Manual" + launch_color = "#5865f2" if start_on_launch else Style.DARK_GREY.value + launch_btn = ttk.Label(btn_frame, text=launch_text, font=("Host Grotesk", 9), + cursor="hand2") + launch_btn.configure(foreground=launch_color, background=self.root.style.colors.get("dark")) + launch_btn.pack(side=ttk.LEFT, padx=(0, 10)) + launch_btn.bind("", lambda e, i=index: self._toggle_start_on_launch(i)) + launch_btn.bind("", lambda e: launch_btn.configure(foreground="white")) + launch_btn.bind("", lambda e, c=launch_color: launch_btn.configure(foreground=c)) + toggle_text = "Stop" if status in ("online", "connecting") else "Start" toggle_color = "#ff6464" if status in ("online", "connecting") else "#4fee4c" diff --git a/gui/pages/tools/notification_logger_page.py b/gui/pages/tools/notification_logger_page.py new file mode 100644 index 0000000..283e8f4 --- /dev/null +++ b/gui/pages/tools/notification_logger_page.py @@ -0,0 +1,120 @@ +import time +import ttkbootstrap as ttk +from ttkbootstrap.scrolled import ScrolledFrame + +from gui.components import ToolPage, RoundedFrame +from gui.helpers import Style + + +class NotificationLoggerPage(ToolPage): + def __init__(self, toolspage, root, bot_controller, images, layout): + super().__init__(toolspage, root, bot_controller, images, layout, title="Notification Logger", frame=None) + self.history = [] + self.log_frame = None + + def add_entry(self, event_type, title, description="", timestamp=None, color=None): + ts = timestamp or time.strftime("%H:%M:%S") + self.history.append({ + "event_type": event_type, + "title": title, + "description": description, + "timestamp": ts, + "color": color or self._get_color(event_type), + }) + if self.log_frame and self.log_frame.winfo_exists(): + self.root.after(0, self._draw_last_entry) + + def _get_color(self, event_type): + colors = { + "friend_add": "#4fee4c", "friend_remove": "#ff6464", + "relationship_add": "#4fee4c", "relationship_remove": "#ff6464", + "relationship_update": "#eceb18", "guild_remove": "#ff6464", + "guild_join": "#4fee4c", "nitro_snipe": "#0b91ff", + "privnote_snipe": "#a07cff", "info": "#2aefef", + "warning": "#eceb18", "error": "#ff6464", "success": "#4fee4c", + } + return colors.get(event_type, Style.LIGHT_GREY.value) + + def _draw_last_entry(self): + if not self.log_frame or not self.log_frame.winfo_exists(): + return + if not self.history: + return + entry = self.history[-1] + self._draw_entry(self.log_frame, entry) + + def _draw_entry(self, parent, entry): + row = ttk.Frame(parent, style="dark.TFrame") + row.pack(fill=ttk.X, padx=4, pady=(0, 2)) + + accent = ttk.Canvas(row, width=3, height=3, highlightthickness=0, + background=self.root.style.colors.get("dark")) + accent.pack(side=ttk.LEFT, fill="y", padx=(0, 6)) + accent.create_rectangle(0, 0, 3, 500, fill=entry["color"], outline=entry["color"]) + + content = ttk.Frame(row, style="dark.TFrame") + content.pack(side=ttk.LEFT, fill=ttk.X, expand=True, pady=4) + + top = ttk.Frame(content, style="dark.TFrame") + top.pack(fill=ttk.X) + + type_lbl = ttk.Label(top, text=entry["event_type"].replace("_", " ").title(), + font=("Host Grotesk", 9, "bold"), foreground=entry["color"]) + type_lbl.configure(background=self.root.style.colors.get("dark")) + type_lbl.pack(side=ttk.LEFT) + + time_lbl = ttk.Label(top, text=entry["timestamp"], font=("Host Grotesk", 8), + foreground=Style.DARK_GREY.value) + time_lbl.configure(background=self.root.style.colors.get("dark")) + time_lbl.pack(side=ttk.RIGHT) + + title_lbl = ttk.Label(content, text=entry["title"], font=("Host Grotesk", 10, "bold")) + title_lbl.configure(background=self.root.style.colors.get("dark")) + title_lbl.pack(anchor=ttk.W) + + if entry["description"]: + desc_lbl = ttk.Label(content, text=entry["description"], font=("Host Grotesk", 9), + foreground=Style.LIGHT_GREY.value, wraplength=450) + desc_lbl.configure(background=self.root.style.colors.get("dark")) + desc_lbl.pack(anchor=ttk.W) + + def _clear_log(self): + self.history.clear() + if self.log_frame and self.log_frame.winfo_exists(): + for child in self.log_frame.winfo_children(): + child.destroy() + + def draw_content(self, wrapper): + header = ttk.Frame(wrapper, style="dark.TFrame") + header.pack(fill=ttk.X, pady=(0, 8)) + + title = ttk.Label(header, text="All Notifications", + font=("Host Grotesk", 14, "bold")) + title.configure(background=self.root.style.colors.get("dark")) + title.pack(side=ttk.LEFT) + + clear_btn = ttk.Label(header, text="Clear", font=("Host Grotesk", 10, "bold"), + cursor="hand2", foreground="#ff6464") + clear_btn.configure(background=self.root.style.colors.get("dark")) + clear_btn.pack(side=ttk.RIGHT) + clear_btn.bind("", lambda e: self._clear_log()) + clear_btn.bind("", lambda e: clear_btn.configure(foreground="white")) + clear_btn.bind("", lambda e: clear_btn.configure(foreground="#ff6464")) + + count_lbl = ttk.Label(header, text=f"{len(self.history)} entries", + font=("Host Grotesk", 9), foreground=Style.DARK_GREY.value) + count_lbl.configure(background=self.root.style.colors.get("dark")) + count_lbl.pack(side=ttk.RIGHT, padx=(0, 15)) + + self.log_frame = ScrolledFrame(wrapper, bootstyle="dark.TFrame", autohide=True) + self.log_frame.container.configure(style="dark.TFrame") + self.log_frame.pack(fill=ttk.BOTH, expand=True) + + for entry in self.history: + self._draw_entry(self.log_frame, entry) + + if not self.history: + empty = ttk.Label(self.log_frame, text="No notifications yet.", + font=("Host Grotesk", 11), foreground=Style.DARK_GREY.value) + empty.configure(background=self.root.style.colors.get("dark")) + empty.pack(pady=30) diff --git a/gui/pages/tools/tools.py b/gui/pages/tools/tools.py index f90d9e9..8b3e47d 100644 --- a/gui/pages/tools/tools.py +++ b/gui/pages/tools/tools.py @@ -8,6 +8,7 @@ from gui.pages.tools.password_gen import PasswordGenPage from gui.pages.tools.backups_page import BackupsPage from gui.pages.tools.multi_token_page import MultiTokenPage +from gui.pages.tools.notification_logger_page import NotificationLoggerPage from gui.helpers.style import Style class ToolsPage: @@ -25,6 +26,7 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): self.password_gen_page = PasswordGenPage(self, root, bot_controller, images, layout) self.backups_page = BackupsPage(self, root, bot_controller, images, layout) self.multi_token_page = MultiTokenPage(self, root, bot_controller, images, layout) + self.notification_logger_page = NotificationLoggerPage(self, root, bot_controller, images, layout) self.pages = [ { @@ -47,7 +49,7 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): }, { "name": "Backups", - "description": "Create and restore backups of your Ghost configuration and data", + "description": "Create and restore backups of your Discord account, friends, and servers", "page": self.backups_page, "command": self.draw_backups }, @@ -57,6 +59,12 @@ def __init__(self, root, bot_controller, images, layout, position_resize_grips): "page": self.multi_token_page, "command": self.draw_multi_token }, + { + "name": "Notification Log", + "description": "View all past notifications and events", + "page": self.notification_logger_page, + "command": self.draw_notification_logger + }, # { # "name": "User Lookup", # "description": "Look up information about a user by their ID", @@ -112,6 +120,14 @@ def draw_multi_token(self): self.multi_token_page.draw(main) self.layout.sidebar.set_button_command("tools", self.draw_multi_token) self.position_resize_grips() + + def draw_notification_logger(self): + self.layout.sidebar.set_current_page("tools") + self.layout.clear() + main = self.layout.main() + self.notification_logger_page.draw(main) + self.layout.sidebar.set_button_command("tools", self.draw_notification_logger) + self.position_resize_grips() def _bind_hover_effects(self, widget, targets, hover_bg, normal_bg): def on_enter(_): diff --git a/utils/defaults.py b/utils/defaults.py index 5338069..46ec246 100644 --- a/utils/defaults.py +++ b/utils/defaults.py @@ -49,6 +49,7 @@ "telemetry": True, "install_id": "", "launch_on_startup": False, + "notification_timeout": 5, "multi_tokens": [], }