#!/usr/bin/env python3 """ BetterCurseForge — Graphical Patcher & Management Suite ======================================================== Single executable GUI for patching CurseForge, managing themes/plugins, and providing zero-timer automatic re-patching after updates. """ import os import sys if os.name == "nt": if len(sys.argv) > 1 and sys.argv[1].lower() not in ("--watch", "--wait-for-update", "--ensure-watcher"): import ctypes ctypes.windll.kernel32.AttachConsole(-1) if sys.stdout is not None: sys.stdout.reconfigure(encoding="utf-8", errors="replace") if sys.stderr is not None: sys.stderr.reconfigure(encoding="utf-8", errors="replace") import json import subprocess import threading import tkinter as tk from tkinter import ttk, messagebox, scrolledtext from pathlib import Path from datetime import datetime try: from PIL import Image, ImageTk HAS_PIL = True except ImportError: HAS_PIL = False # Import patch core logic try: from . import patch_core except ImportError: import patch_core VERSION = "1.0.0" def find_asset(filename: str) -> Path | None: candidates = [ Path(getattr(sys, "_MEIPASS", "")) / "assets" / filename if hasattr(sys, "_MEIPASS") else None, Path(getattr(sys, "_MEIPASS", "")) / filename if hasattr(sys, "_MEIPASS") else None, Path(sys.executable).parent / "assets" / filename if getattr(sys, "frozen", False) else None, getattr(patch_core, "PERMANENT_ASSETS", Path()) / filename, Path(__file__).parent.parent / "assets" / filename, Path.cwd() / "assets" / filename, ] for c in candidates: if c and c.exists(): return c return None # ═══════════════════════════════════════════════════════════════════════════════ # COLOR PALETTE (Modern Discord / Vencord Dark Theme) # ═══════════════════════════════════════════════════════════════════════════════ COLORS = { "bg": "#121214", "bg_card": "#18191c", "bg_elevated": "#202225", "bg_input": "#0e0f10", "purple": "#5865f2", "purple_hover": "#4752c4", "purple_dark": "#3c45a5", "purple_glow": "#7983f5", "green": "#57f287", "green_dark": "#237e44", "red": "#ed4245", "yellow": "#fee75c", "text": "#f2f3f5", "text_dim": "#949ba4", "text_muted": "#5c6067", "border": "#2b2d31", "border_focus": "#5865f2", } # ═══════════════════════════════════════════════════════════════════════════════ # GUI APPLICATION # ═══════════════════════════════════════════════════════════════════════════════ class BetterCFApp: def __init__(self): self.root = tk.Tk() self.root.title("BetterCurseForge Patcher") self.root.geometry("780x560") self.root.minsize(720, 500) self.root.configure(bg=COLORS["bg"]) self.install_dir = None self.is_working = False self._icon_photo = None self._header_icon_photo = None self._configure_styles() self._setup_window_icon() self._build_ui() self._refresh_status() def _setup_window_icon(self): ico_path = find_asset("icon.ico") if ico_path and os.name == "nt": try: self.root.iconbitmap(default=str(ico_path.resolve())) except Exception: pass png_path = find_asset("icon_512.png") or find_asset("icon.png") if png_path: try: if HAS_PIL: im = Image.open(png_path) self._icon_photo = ImageTk.PhotoImage(im.resize((64, 64), Image.Resampling.LANCZOS)) self.root.iconphoto(True, self._icon_photo) else: self._icon_photo = tk.PhotoImage(file=str(png_path)) self.root.iconphoto(True, self._icon_photo) except Exception: pass def _configure_styles(self): style = ttk.Style() style.theme_use("clam") style.configure(".", background=COLORS["bg"], foreground=COLORS["text"], font=("Segoe UI", 10)) style.configure("TFrame", background=COLORS["bg"]) style.configure("Card.TFrame", background=COLORS["bg_card"]) style.configure("CardTitle.TLabel", font=("Segoe UI", 11, "bold"), foreground=COLORS["text"]) style.configure("StatusKey.TLabel", font=("Segoe UI", 9), foreground=COLORS["text_dim"]) style.configure("StatusVal.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["text"]) style.configure("Patched.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["green"]) style.configure("NotPatched.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["red"]) style.configure("Error.TLabel", font=("Segoe UI", 9, "bold"), foreground=COLORS["red"]) def _build_ui(self): # Header header = tk.Frame(self.root, bg=COLORS["bg_card"], padx=20, pady=12) header.pack(fill="x") title_frame = tk.Frame(header, bg=COLORS["bg_card"]) title_frame.pack(side="left") # Header icon thumbnail png_path = find_asset("icon_512.png") or find_asset("icon.png") if png_path and HAS_PIL: try: header_img = Image.open(png_path).resize((32, 32), Image.Resampling.LANCZOS) self._header_icon_photo = ImageTk.PhotoImage(header_img) icon_lbl = tk.Label(title_frame, image=self._header_icon_photo, bg=COLORS["bg_card"]) icon_lbl.pack(side="left", padx=(0, 10)) except Exception: pass title_lbl = tk.Label( title_frame, text="BetterCurseForge", font=("Segoe UI", 16, "bold"), bg=COLORS["bg_card"], fg=COLORS["purple_glow"] ) title_lbl.pack(side="left") ver_lbl = tk.Label( title_frame, text=f"v{VERSION}", font=("Segoe UI", 9), bg=COLORS["bg_card"], fg=COLORS["text_dim"] ) ver_lbl.pack(side="left", padx=(8, 0), pady=(4, 0)) sub_lbl = tk.Label( header, text="Ad-free launcher with custom themes and plugins", font=("Segoe UI", 9), bg=COLORS["bg_card"], fg=COLORS["text_dim"] ) sub_lbl.pack(side="left", padx=(16, 0), pady=(4, 0)) # Main Layout (2 Columns) main = ttk.Frame(self.root, padding="14") main.pack(fill="both", expand=True) left = ttk.Frame(main) left.pack(side="left", fill="both", expand=False, padx=(0, 10)) # ── Status Card ── status_card = tk.Frame(left, bg=COLORS["bg_card"], highlightbackground=COLORS["border"], highlightthickness=1, padx=14, pady=12) status_card.pack(fill="x", pady=(0, 10)) ttk.Label(status_card, text="📋 Launcher Status", style="CardTitle.TLabel").pack(anchor="w") tk.Frame(status_card, height=1, bg=COLORS["border"]).pack(fill="x", pady=(6, 8)) self.status_grid = tk.Frame(status_card, bg=COLORS["bg_card"]) self.status_grid.pack(fill="x") self._status_labels = {} for i, (key, label) in enumerate([ ("install", "Install Path"), ("status", "Patch Status"), ("watcher", "Auto-Repatch"), ("themes", "Themes"), ("plugins", "Plugins"), ]): ttk.Label(self.status_grid, text=label, style="StatusKey.TLabel").grid( row=i, column=0, sticky="w", pady=2, padx=(0, 12)) lbl = ttk.Label(self.status_grid, text="—", style="StatusVal.TLabel") lbl.grid(row=i, column=1, sticky="w", pady=2) self._status_labels[key] = lbl # ── Action Buttons ── btn_frame = tk.Frame(left, bg=COLORS["bg"]) btn_frame.pack(fill="x") self.patch_btn = tk.Button( btn_frame, text="⚡ Patch CurseForge", font=("Segoe UI", 11, "bold"), bg=COLORS["purple"], fg="#ffffff", activebackground=COLORS["purple_hover"], activeforeground="#ffffff", relief="flat", cursor="hand2", pady=9, command=self._on_patch, ) self.patch_btn.pack(fill="x", pady=(0, 5)) self.unpatch_btn = tk.Button( btn_frame, text="🔄 Restore Vanilla", font=("Segoe UI", 10), bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"], activeforeground=COLORS["text"], relief="flat", cursor="hand2", pady=7, command=self._on_unpatch, ) self.unpatch_btn.pack(fill="x", pady=(0, 5)) self.watcher_btn = tk.Button( btn_frame, text="🛡️ Auto-Repatch on Update", font=("Segoe UI", 10), bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"], activeforeground=COLORS["text"], relief="flat", cursor="hand2", pady=7, command=self._on_toggle_watcher, ) self.watcher_btn.pack(fill="x", pady=(0, 8)) # Secondary Actions sub_row = tk.Frame(btn_frame, bg=COLORS["bg"]) sub_row.pack(fill="x") self.launch_btn = tk.Button( sub_row, text="🚀 Launch", font=("Segoe UI", 9), bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"], activeforeground=COLORS["text"], relief="flat", cursor="hand2", padx=10, pady=5, command=self._on_launch, ) self.launch_btn.pack(side="left", padx=(0, 4), fill="x", expand=True) self.themes_btn = tk.Button( sub_row, text="🎨 Themes", font=("Segoe UI", 9), bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"], activeforeground=COLORS["text"], relief="flat", cursor="hand2", padx=8, pady=5, command=self._on_open_themes, ) self.themes_btn.pack(side="left", padx=(0, 4), fill="x", expand=True) self.plugins_btn = tk.Button( sub_row, text="🔌 Plugins", font=("Segoe UI", 9), bg=COLORS["bg_elevated"], fg=COLORS["text_dim"], activebackground=COLORS["border"], activeforeground=COLORS["text"], relief="flat", cursor="hand2", padx=8, pady=5, command=self._on_open_plugins, ) self.plugins_btn.pack(side="left", fill="x", expand=True) # ── Right Column: Logs ── right = ttk.Frame(main) right.pack(side="right", fill="both", expand=True) log_card = tk.Frame(right, bg=COLORS["bg_card"], highlightbackground=COLORS["border"], highlightthickness=1, padx=12, pady=10) log_card.pack(fill="both", expand=True) log_header = tk.Frame(log_card, bg=COLORS["bg_card"]) log_header.pack(fill="x", pady=(0, 6)) ttk.Label(log_header, text="📝 Activity Log", style="CardTitle.TLabel").pack(side="left") clear_btn = tk.Button( log_header, text="Clear", font=("Segoe UI", 8), bg=COLORS["bg_card"], fg=COLORS["text_muted"], activebackground=COLORS["bg_card"], activeforeground=COLORS["text"], relief="flat", cursor="hand2", command=self._clear_log ) clear_btn.pack(side="right") self.log_text = scrolledtext.ScrolledText( log_card, wrap="word", font=("Cascadia Code", 9), height=18, bg=COLORS["bg_input"], fg=COLORS["text_dim"], insertbackground=COLORS["text"], relief="flat", highlightbackground=COLORS["border"], highlightthickness=1, padx=8, pady=8, ) self.log_text.pack(fill="both", expand=True) self.log_text.tag_configure("timestamp", foreground=COLORS["text_muted"]) self.log_text.tag_configure("info", foreground=COLORS["text"]) self.log_text.tag_configure("success", foreground=COLORS["green"]) self.log_text.tag_configure("error", foreground=COLORS["red"]) self.log_text.tag_configure("warn", foreground=COLORS["yellow"]) self.log_text.tag_configure("header", foreground=COLORS["purple_glow"], font=("Segoe UI", 9, "bold")) self.log_text.configure(state="disabled") # ── Logging ───────────────────────────────────────────────────────────── def log(self, message: str, level: str = "info"): ts = datetime.now().strftime("%H:%M:%S") self.log_text.configure(state="normal") self.log_text.insert("end", f"[{ts}] ", "timestamp") tag = level if message.startswith("[+]") or message.startswith("✓") or "success" in message.lower(): tag = "success" elif message.startswith("[-]") or message.startswith("✗") or "fail" in message.lower() or "error" in message.lower(): tag = "error" elif message.startswith("[!]") or "warn" in message.lower(): tag = "warn" self.log_text.insert("end", f"{message}\n", tag) self.log_text.see("end") self.log_text.configure(state="disabled") def _clear_log(self): self.log_text.configure(state="normal") self.log_text.delete("1.0", "end") self.log_text.configure(state="disabled") # ── Refresh Status ────────────────────────────────────────────────────── def _refresh_status(self): self.install_dir = patch_core.get_curseforge_dir() if not self.install_dir: self._status_labels["install"].configure(text="Not Found", style="Error.TLabel") self._status_labels["status"].configure(text="N/A", style="StatusVal.TLabel") self.patch_btn.configure(state="disabled") self.unpatch_btn.configure(state="disabled") self.log("CurseForge installation not found!", "error") return path_str = str(self.install_dir) if len(path_str) > 32: path_str = "..." + path_str[-29:] self._status_labels["install"].configure(text=path_str, style="StatusVal.TLabel") is_patched = patch_core.is_curseforge_patched(self.install_dir) if is_patched: self._status_labels["status"].configure(text="✓ PATCHED", style="Patched.TLabel") self.patch_btn.configure(text="🔁 Re-Patch CurseForge") self.unpatch_btn.configure(state="normal") else: self._status_labels["status"].configure(text="✗ NOT PATCHED", style="NotPatched.TLabel") self.patch_btn.configure(text="⚡ Patch CurseForge") self.unpatch_btn.configure(state="disabled") # Watcher status autostart = patch_core.is_autostart_watcher_enabled() running = patch_core.is_watcher_running() if is_patched and not running and autostart: patch_core.start_background_watcher() running = patch_core.is_watcher_running() if running: self._status_labels["watcher"].configure(text="Active (Instant)", style="Patched.TLabel") self.watcher_btn.configure(text="🛡️ Auto-Repatch: ON", bg=COLORS["green_dark"], fg="#ffffff") elif autostart: self._status_labels["watcher"].configure(text="Enabled (Idle)", style="Patched.TLabel") self.watcher_btn.configure(text="🛡️ Auto-Repatch: ON", bg=COLORS["green_dark"], fg="#ffffff") else: self._status_labels["watcher"].configure(text="Inactive", style="StatusKey.TLabel") self.watcher_btn.configure(text="🛡️ Enable Auto-Repatch", bg=COLORS["bg_elevated"], fg=COLORS["text_dim"]) # Themes & Plugins counts user_dir = patch_core.get_user_data_dir() n_themes = len(list((user_dir / "themes").glob("*.css"))) n_plugins = len([p for p in (user_dir / "plugins").iterdir() if p.is_dir()]) self._status_labels["themes"].configure(text=f"{n_themes} installed") self._status_labels["plugins"].configure(text=f"{n_plugins} installed") self.patch_btn.configure(state="normal") # ── Actions ───────────────────────────────────────────────────────────── def _on_patch(self): if self.is_working: return self.is_working = True self.patch_btn.configure(state="disabled") self.unpatch_btn.configure(state="disabled") def task(): self.log("Starting patch process...", "header") success = patch_core.patch_curseforge(log_callback=lambda msg: self.root.after(0, self.log, msg)) if success: self.root.after(0, self.log, "[+] Auto-repatch active. Watcher running in background.", "success") self.root.after(0, self._after_action) threading.Thread(target=task, daemon=True).start() def _on_unpatch(self): if self.is_working: return self.is_working = True self.patch_btn.configure(state="disabled") self.unpatch_btn.configure(state="disabled") def task(): self.log("Restoring vanilla CurseForge...", "header") success = patch_core.unpatch_curseforge(log_callback=lambda msg: self.root.after(0, self.log, msg)) self.root.after(0, self._after_action) threading.Thread(target=task, daemon=True).start() def _after_action(self): self.is_working = False self._refresh_status() def _on_toggle_watcher(self): current = patch_core.is_autostart_watcher_enabled() or patch_core.is_watcher_running() new_state = not current patch_core.set_autostart_watcher(new_state) if new_state: patch_core.start_background_watcher() self.log("[+] Auto-repatch enabled. Watching resources/ directory for updates.", "success") else: patch_core.stop_background_watcher() self.log("[-] Auto-repatch disabled.", "warn") self._refresh_status() def _on_launch(self): if not self.install_dir: return exe = self.install_dir / "CurseForge.exe" if exe.exists(): self.log(f"Launching CurseForge: {exe}") creation_flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP subprocess.Popen([str(exe)], cwd=str(self.install_dir), creationflags=creation_flags) else: self.log("CurseForge.exe not found!", "error") def _on_open_themes(self): user_dir = patch_core.get_user_data_dir() themes_dir = user_dir / "themes" os.startfile(str(themes_dir)) self.log(f"Opened themes folder: {themes_dir}") def _on_open_plugins(self): user_dir = patch_core.get_user_data_dir() plugins_dir = user_dir / "plugins" os.startfile(str(plugins_dir)) self.log(f"Opened plugins folder: {plugins_dir}") def run(self): self.log(f"BetterCurseForge Patcher v{VERSION} ready.") self.root.mainloop() # ═══════════════════════════════════════════════════════════════════════════════ # MAIN ENTRY POINT # ═══════════════════════════════════════════════════════════════════════════════ if __name__ == "__main__": try: if len(sys.argv) > 1: arg = sys.argv[1].lower() if arg == "--watch": patch_core.run_watcher_loop() sys.exit(0) elif arg == "--wait-for-update": pid = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None patch_core.wait_for_update_and_repatch(pid) sys.exit(0) elif arg == "--ensure-watcher": if not patch_core.is_watcher_running(): patch_core.start_background_watcher() sys.exit(0) elif arg == "patch": patch_core.patch_curseforge() sys.exit(0) elif arg == "unpatch": patch_core.unpatch_curseforge() sys.exit(0) elif arg == "status": cf = patch_core.get_curseforge_dir() print("CurseForge:", cf) print("Patched:", patch_core.is_curseforge_patched(cf) if cf else False) print("Watcher:", "RUNNING" if patch_core.is_watcher_running() else "STOPPED") sys.exit(0) app = BetterCFApp() app.run() except SystemExit: pass except Exception as e: if len(sys.argv) > 1 and sys.argv[1].lower() in ("--watch", "--wait-for-update", "--ensure-watcher"): try: log_dir = patch_core.get_user_data_dir() with open(log_dir / "watcher.log", "a", encoding="utf-8") as f: f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [BackgroundError] {e}\n") except Exception: pass else: raise