BetterCurseForge/patcher/patcher_gui.py

427 lines
19 KiB
Python

#!/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
# Import patch core logic
try:
from . import patch_core
except ImportError:
import patch_core
VERSION = "1.0.0"
# ═══════════════════════════════════════════════════════════════════════════════
# 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._configure_styles()
self._build_ui()
self._refresh_status()
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")
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__":
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
if arg == "--watch":
# Silent background watcher mode
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()